@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
|
@@ -1,40 +1,51 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { inspectApiDescription } from "./api-description-inspect.js";
|
|
1
3
|
import { dsmLauncherType } from "../contract/app-launch.js";
|
|
2
|
-
import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway } from "../contract/web-launch.js";
|
|
4
|
+
import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
|
|
3
5
|
import { loadContract } from "../contract/load.js";
|
|
4
|
-
import { readComposeApp } from "./compose.js";
|
|
6
|
+
import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, } from "./compose.js";
|
|
5
7
|
import { createHash } from "node:crypto";
|
|
6
8
|
import { inspectNativeLayout } from "./native-layout.js";
|
|
7
9
|
import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
|
|
8
|
-
import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
|
|
9
|
-
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
10
|
+
import { inspectExecutor, inspectExecutorAnchor, } from "../runtime/executor-inspect.js";
|
|
11
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, } from "node:fs";
|
|
10
12
|
import { unwrapAppScripts } from "./app-scripts.js";
|
|
11
13
|
import { tmpdir } from "node:os";
|
|
12
14
|
import { join } from "node:path";
|
|
13
15
|
import * as tar from "tar";
|
|
14
|
-
import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
|
|
15
|
-
import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON } from "../build-spk/dsm-version.js";
|
|
16
|
+
import { familyOfArchToken, NATIVE_ARCH_FAMILIES, } from "../build-spk/dsm-arch.js";
|
|
17
|
+
import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DSM, PORTAL_MIN_DSM_REASON, } from "../build-spk/dsm-version.js";
|
|
16
18
|
import { ELF_MACHINE } from "./binary.js";
|
|
17
19
|
import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
|
|
18
20
|
import { daemonConfig, readDaemon } from "./daemon.js";
|
|
19
21
|
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
20
22
|
import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
|
|
21
23
|
import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
|
|
22
|
-
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit } from "./background.js";
|
|
24
|
+
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit, } from "./background.js";
|
|
23
25
|
import { renderStandaloneResource } from "./resource.js";
|
|
24
|
-
import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
|
|
25
|
-
import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
|
|
26
|
-
import { renderDelegateScopeSteps, renderDelegateInstallDraft } from "../build-spk/delegate-scope.js";
|
|
26
|
+
import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER, } from "./shared-folder.js";
|
|
27
|
+
import { renderInstallWizardFiles, renderNativeInstallWizard, } from "../build-spk/install-wizard.js";
|
|
28
|
+
import { renderDelegateScopeSteps, renderDelegateInstallDraft, } from "../build-spk/delegate-scope.js";
|
|
27
29
|
import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
|
|
30
|
+
import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
|
|
31
|
+
import { renderUiConfig } from "./dsm-window.js";
|
|
32
|
+
import { identityGatewayConfig, readIdentityGateway, } from "./identity-gateway.js";
|
|
33
|
+
import { checkCgiBinary } from "./binary.js";
|
|
28
34
|
import { renderStandaloneScript } from "./lifecycle.js";
|
|
29
35
|
import { listPortExposures, loadCompose } from "../compose/analyze.js";
|
|
30
|
-
import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
|
|
31
|
-
import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep } from "../portal/package.js";
|
|
36
|
+
import { uninstallCleanup, } from "../runtime/uninstall-cleanup.js";
|
|
37
|
+
import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderPortalLifecycle, renderPortalMustache, renderPortalWizardStep, } from "../portal/package.js";
|
|
32
38
|
import { isPortalAlias } from "../portal/alias.js";
|
|
33
39
|
import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
|
|
34
40
|
import { isDefaultLoginAsset } from "../build-spk/login-style.js";
|
|
35
41
|
import { decodePng } from "../build-spk/png.js";
|
|
36
42
|
/** The SPK kind of each sdk-package.json backend a CGI is built for: Go itself, or the runtime of an App in another language. */
|
|
37
|
-
const COMPILED_KIND = {
|
|
43
|
+
const COMPILED_KIND = {
|
|
44
|
+
go: "native-go",
|
|
45
|
+
python: "native-python",
|
|
46
|
+
node: "native-node",
|
|
47
|
+
rust: "native-rust",
|
|
48
|
+
};
|
|
38
49
|
/** Reads an SPK without installing it and reports how it would run on DSM. */
|
|
39
50
|
export async function inspectStandaloneSpk(spkPath) {
|
|
40
51
|
return inspectSpk(spkPath, "native-go");
|
|
@@ -52,21 +63,51 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
52
63
|
mkdirSync(target);
|
|
53
64
|
await tar.x({ file: join(dir, "package.tgz"), cwd: target });
|
|
54
65
|
findings.push(...inspectNativeLayout(dir, target, info));
|
|
55
|
-
for (const key of [
|
|
66
|
+
for (const key of [
|
|
67
|
+
"package",
|
|
68
|
+
"version",
|
|
69
|
+
"arch",
|
|
70
|
+
"os_min_ver",
|
|
71
|
+
"checksum",
|
|
72
|
+
])
|
|
56
73
|
if (!info[key])
|
|
57
74
|
findings.push(`Missing INFO ${key}`);
|
|
58
75
|
const icon = inspectPackageIcons(dir, findings);
|
|
59
|
-
return {
|
|
60
|
-
|
|
76
|
+
return {
|
|
77
|
+
kind: "native-layout",
|
|
78
|
+
package: info.package,
|
|
79
|
+
version: info.version,
|
|
80
|
+
arch: info.arch,
|
|
81
|
+
signed: existsSync(join(dir, "syno_signature.asc")),
|
|
82
|
+
payload,
|
|
83
|
+
findings,
|
|
84
|
+
capabilities: [],
|
|
85
|
+
icon,
|
|
86
|
+
};
|
|
61
87
|
}
|
|
62
88
|
const payload = await listPayload(join(dir, "package.tgz"), findings);
|
|
63
|
-
const paths = new Set(payload.map(entry => entry.path));
|
|
89
|
+
const paths = new Set(payload.map((entry) => entry.path));
|
|
64
90
|
let metadata = {};
|
|
65
91
|
const metadataText = readOptional(join(dir, "sdk-package.json"));
|
|
66
92
|
if (metadataText !== undefined) {
|
|
67
93
|
try {
|
|
68
94
|
const raw = JSON.parse(metadataText);
|
|
69
|
-
if ((raw.launch !== undefined && !isWebLaunchMode(raw.launch)) ||
|
|
95
|
+
if ((raw.launch !== undefined && !isWebLaunchMode(raw.launch)) ||
|
|
96
|
+
raw.schema !== 2 ||
|
|
97
|
+
!["go", "python", "node", "rust", "binary"].includes(raw.backend) ||
|
|
98
|
+
!Array.isArray(raw.capabilities) ||
|
|
99
|
+
raw.capabilities.some((name) => ![
|
|
100
|
+
"files",
|
|
101
|
+
"jobs",
|
|
102
|
+
"identity",
|
|
103
|
+
"rootOps",
|
|
104
|
+
"delegate",
|
|
105
|
+
"webapi",
|
|
106
|
+
"composeMounts",
|
|
107
|
+
].includes(String(name))) ||
|
|
108
|
+
new Set(raw.capabilities).size !== raw.capabilities.length ||
|
|
109
|
+
(raw.entry !== undefined &&
|
|
110
|
+
!["admin-only", "app-privilege"].includes(raw.entry)))
|
|
70
111
|
throw new Error();
|
|
71
112
|
metadata = raw;
|
|
72
113
|
}
|
|
@@ -74,27 +115,51 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
74
115
|
findings.push("sdk-package.json does not match the schema 2 capability inventory.");
|
|
75
116
|
}
|
|
76
117
|
}
|
|
77
|
-
|
|
118
|
+
if (metadata.compose !== undefined) {
|
|
119
|
+
const result = await inspectUnifiedCompose(dir, payload, metadata, findings);
|
|
120
|
+
result.icon = inspectPackageIcons(dir, findings);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
const uninstallHook = metadata.uninstall === undefined
|
|
124
|
+
? undefined
|
|
125
|
+
: readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
|
|
78
126
|
if (metadata.uninstall !== undefined && !uninstallHook)
|
|
79
127
|
findings.push("UNINSTALL_RECORD_INVALID the uninstall in sdk-package.json is not the uninstall setting synopkgland build writes.");
|
|
80
128
|
const binaryBackend = metadata.backend === "binary";
|
|
81
129
|
// A strict standalone inspection names the language the CGI was built from.
|
|
82
130
|
const compiled = COMPILED_KIND[metadata.backend ?? "go"] ?? "native-go";
|
|
83
|
-
const kind = (expectedKind === "native-go" ? compiled : expectedKind) ??
|
|
131
|
+
const kind = (expectedKind === "native-go" ? compiled : expectedKind) ??
|
|
132
|
+
(paths.has("compose.yaml")
|
|
133
|
+
? "container"
|
|
134
|
+
: binaryBackend
|
|
135
|
+
? "binary"
|
|
136
|
+
: compiled);
|
|
84
137
|
const compiledKind = Object.values(COMPILED_KIND).includes(kind);
|
|
85
138
|
const standalone = compiledKind || kind === "binary";
|
|
86
|
-
const combined = metadata.compose === undefined
|
|
139
|
+
const combined = metadata.compose === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: await recoverCompose(dir, metadata.compose, findings);
|
|
87
142
|
const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
|
|
88
|
-
if (combined &&
|
|
143
|
+
if (combined &&
|
|
144
|
+
(!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
|
|
89
145
|
findings.push("compose identity_gateway requires DSM >= 7.2.1");
|
|
90
146
|
await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
|
|
91
147
|
const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
|
|
92
148
|
// Before any fixed-lifecycle comparison: those see the SDK steps alone.
|
|
93
|
-
const appScripts = standalone || kind === "container"
|
|
94
|
-
|
|
149
|
+
const appScripts = standalone || kind === "container"
|
|
150
|
+
? unwrapAppScripts(join(dir, "scripts"), findings)
|
|
151
|
+
: [];
|
|
152
|
+
const sharedFolder = standalone
|
|
153
|
+
? recoverSharedFolder(readOptional(join(dir, "scripts/postinst")))
|
|
154
|
+
: undefined;
|
|
95
155
|
// A Compose App has no executor, so its SPK never carries a portal.
|
|
96
|
-
const portal = standalone
|
|
97
|
-
|
|
156
|
+
const portal = standalone
|
|
157
|
+
? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings)
|
|
158
|
+
: undefined;
|
|
159
|
+
if (portal &&
|
|
160
|
+
info.os_min_ver &&
|
|
161
|
+
OS_MIN_VER.test(info.os_min_ver) &&
|
|
162
|
+
!isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
|
|
98
163
|
findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
|
|
99
164
|
}
|
|
100
165
|
checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose);
|
|
@@ -102,15 +167,36 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
102
167
|
checkUninstall(dir, findings);
|
|
103
168
|
if (kind === "container")
|
|
104
169
|
await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
|
|
105
|
-
|
|
106
|
-
|
|
170
|
+
if (metadata.ctl || paths.has("synopkgland-api.json")) {
|
|
171
|
+
if (!metadata.ctl ||
|
|
172
|
+
!paths.has("synopkgland-api.json") ||
|
|
173
|
+
[...paths].some((path) => path.startsWith("ui/") && path.endsWith("synopkgland-api.json")))
|
|
174
|
+
findings.push("CTL_DESCRIPTOR_LAYOUT_INVALID API description must be private and match its build record.");
|
|
175
|
+
else {
|
|
176
|
+
const apiDir = join(dir, "api-description-inspection");
|
|
177
|
+
mkdirSync(apiDir);
|
|
178
|
+
await tar.x({
|
|
179
|
+
file: join(dir, "package.tgz"),
|
|
180
|
+
cwd: apiDir,
|
|
181
|
+
filter: (path) => path.replace(/^\.\//, "") === "synopkgland-api.json",
|
|
182
|
+
});
|
|
183
|
+
findings.push(...inspectApiDescription(readFileSync(join(apiDir, "synopkgland-api.json"), "utf8"), { id: info.package, version: info.version }, metadata.ctl.hash, metadata.ctl.registry));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const bridge = compiledKind && kind !== "native-go"
|
|
187
|
+
? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
|
|
188
|
+
: undefined;
|
|
189
|
+
const hasExecutor = (metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") || paths.has("executor/executor");
|
|
107
190
|
if (standalone && portal && !hasExecutor)
|
|
108
191
|
findings.push("PORTAL_REQUIRES_EXECUTOR portal needs the executor level (rootOps or delegate): after uninstall only a root purge can stop nginx serving the alias");
|
|
109
|
-
const daemon = standalone
|
|
192
|
+
const daemon = standalone
|
|
193
|
+
? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined)
|
|
194
|
+
: undefined;
|
|
110
195
|
if (background)
|
|
111
196
|
checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
|
|
112
197
|
let executor;
|
|
113
|
-
if ((metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate" || name === "webapi") ||
|
|
198
|
+
if ((metadata.capabilities ?? []).some((name) => name === "rootOps" || name === "delegate" || name === "webapi") ||
|
|
199
|
+
paths.has("executor/executor")) {
|
|
114
200
|
const executorDir = join(dir, "executor-inspection");
|
|
115
201
|
mkdirSync(executorDir);
|
|
116
202
|
await tar.x({ file: join(dir, "package.tgz"), cwd: executorDir });
|
|
@@ -119,38 +205,60 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
119
205
|
executor = inspected.executor;
|
|
120
206
|
if (executor)
|
|
121
207
|
findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
|
|
122
|
-
if (executor?.policy.portal &&
|
|
208
|
+
if (executor?.policy.portal &&
|
|
209
|
+
info.os_min_ver &&
|
|
210
|
+
OS_MIN_VER.test(info.os_min_ver) &&
|
|
211
|
+
!isDsmAtLeast(info.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
|
|
123
212
|
findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_EXECUTOR_MIN_DSM} the executor needs to register a portal`);
|
|
124
213
|
}
|
|
125
|
-
if (executor &&
|
|
214
|
+
if (executor &&
|
|
215
|
+
executor.policy.purge !== uninstallHook?.uninstall.purge.root)
|
|
126
216
|
findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH the executor policy purge "${executor.policy.purge ?? ""}" differs from uninstall.purge.root "${uninstallHook?.uninstall.purge.root ?? ""}".`);
|
|
127
|
-
if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
|
|
217
|
+
if (payload.find((p) => p.path === "executor/executor")?.mode !== "0755")
|
|
128
218
|
findings.push("Executor must be executable.");
|
|
129
|
-
if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
|
|
219
|
+
if (payload.find((p) => p.path === "executor/lifecycle")?.mode !== "0755")
|
|
130
220
|
findings.push("Executor lifecycle helper must be executable.");
|
|
131
|
-
if (![...paths].some(path => path.startsWith("daemon/"))) {
|
|
132
|
-
const lifecycleManifest = {
|
|
221
|
+
if (![...paths].some((path) => path.startsWith("daemon/"))) {
|
|
222
|
+
const lifecycleManifest = {
|
|
223
|
+
package: info.package,
|
|
224
|
+
capabilities: {
|
|
225
|
+
rootOps: true,
|
|
226
|
+
...(background ? { jobs: { worker: "sha256-v1" } } : {}),
|
|
227
|
+
},
|
|
228
|
+
...(uninstallHook ? { uninstall: uninstallHook.uninstall } : {}),
|
|
229
|
+
};
|
|
133
230
|
for (const hook of ["preuninst", "preupgrade", "start-stop-status"]) {
|
|
134
|
-
if (readOptional(join(dir, "scripts", hook)) !==
|
|
231
|
+
if (readOptional(join(dir, "scripts", hook)) !==
|
|
232
|
+
renderStandaloneScript(hook, lifecycleManifest))
|
|
135
233
|
findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
|
|
136
234
|
}
|
|
137
235
|
}
|
|
138
|
-
for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", {
|
|
236
|
+
for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", {
|
|
237
|
+
executor: true,
|
|
238
|
+
portal: Boolean(portal),
|
|
239
|
+
portalEntry: Boolean(executor?.policy.portal),
|
|
240
|
+
...uninstallWizardOptions(uninstallHook),
|
|
241
|
+
}))) {
|
|
139
242
|
if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
|
|
140
243
|
findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
|
|
141
244
|
}
|
|
142
245
|
}
|
|
143
246
|
if (bridge)
|
|
144
247
|
checkBridgeExecutor(bridge, executor, findings);
|
|
145
|
-
if (background &&
|
|
146
|
-
|
|
248
|
+
if (background &&
|
|
249
|
+
daemon &&
|
|
250
|
+
(executor?.policy.lifetime !== "until-revoked" ||
|
|
251
|
+
!metadata.capabilities?.includes("delegate"))) {
|
|
252
|
+
findings.push("daemon/background combination requires an explicitly pinned until-revoked delegate policy.");
|
|
147
253
|
}
|
|
148
254
|
if (metadata.entry)
|
|
149
255
|
await checkEntryLauncher(dir, info.package, metadata.entry, findings);
|
|
150
256
|
const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
|
|
151
|
-
const loginStyle = standalone
|
|
257
|
+
const loginStyle = standalone
|
|
258
|
+
? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
|
|
259
|
+
: undefined;
|
|
152
260
|
if (standalone)
|
|
153
|
-
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope ===
|
|
261
|
+
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor);
|
|
154
262
|
const icon = inspectPackageIcons(dir, findings);
|
|
155
263
|
const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
|
|
156
264
|
if (standalone && !cgiEntry) {
|
|
@@ -164,12 +272,29 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
164
272
|
...(launch ? { launch } : {}),
|
|
165
273
|
icon,
|
|
166
274
|
...(executor ? { executor } : {}),
|
|
167
|
-
...(portal
|
|
168
|
-
|
|
275
|
+
...(portal
|
|
276
|
+
? { portal: { appId: portal.appId, alias: portal.alias } }
|
|
277
|
+
: {}),
|
|
278
|
+
...(executor?.policy.portal
|
|
279
|
+
? {
|
|
280
|
+
portal: {
|
|
281
|
+
appId: executor.policy.app,
|
|
282
|
+
alias: executor.policy.portal.alias,
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
: {}),
|
|
169
286
|
...(metadata.entry ? { entry: metadata.entry } : {}),
|
|
170
287
|
...(appScripts.length ? { appScripts } : {}),
|
|
171
288
|
...(loginStyle ? { loginStyle } : {}),
|
|
172
|
-
...(standalone
|
|
289
|
+
...(standalone
|
|
290
|
+
? {
|
|
291
|
+
uninstallCleanup: uninstallCleanup(info.package, {
|
|
292
|
+
executor: hasExecutor,
|
|
293
|
+
portal: Boolean(portal),
|
|
294
|
+
portalEntry: Boolean(executor?.policy.portal),
|
|
295
|
+
}),
|
|
296
|
+
}
|
|
297
|
+
: {}),
|
|
173
298
|
package: info.package,
|
|
174
299
|
version: info.version,
|
|
175
300
|
arch: info.arch,
|
|
@@ -177,7 +302,10 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
177
302
|
cgi: cgiEntry?.mode === "0755" ? CGI_PAYLOAD_PATH : undefined,
|
|
178
303
|
payload,
|
|
179
304
|
findings,
|
|
180
|
-
capabilities: (metadata.capabilities ?? []).map(name => ({
|
|
305
|
+
capabilities: (metadata.capabilities ?? []).map((name) => ({
|
|
306
|
+
name,
|
|
307
|
+
requiresAdminApproval: name === "rootOps" || name === "delegate" || name === "webapi",
|
|
308
|
+
})),
|
|
181
309
|
};
|
|
182
310
|
}
|
|
183
311
|
finally {
|
|
@@ -190,7 +318,11 @@ const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
|
|
|
190
318
|
async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
|
|
191
319
|
const target = join(dir, "launch-inspection");
|
|
192
320
|
mkdirSync(target);
|
|
193
|
-
await tar.x({
|
|
321
|
+
await tar.x({
|
|
322
|
+
file: join(dir, "package.tgz"),
|
|
323
|
+
cwd: target,
|
|
324
|
+
filter: (path) => ["ui/config", "synology-app.yaml"].includes(path.replace(/^\.\//, "")),
|
|
325
|
+
});
|
|
194
326
|
let launch = declared ?? "dsm-window";
|
|
195
327
|
let url;
|
|
196
328
|
if (!native) {
|
|
@@ -213,24 +345,42 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
|
|
|
213
345
|
try {
|
|
214
346
|
const resource = JSON.parse(readFileSync(join(dir, "conf/resource"), "utf8"));
|
|
215
347
|
const portals = resource.webservice?.portals ?? [];
|
|
216
|
-
if (usesAuthGateway(contract)
|
|
348
|
+
if (usesAuthGateway(contract)
|
|
349
|
+
? portals.length > 0
|
|
350
|
+
: portals.some((p) => p.app !== (launch === "dsm-window" ? info.package : undefined))) {
|
|
217
351
|
findings.push(`${LAUNCHER_PORTAL_MISMATCH} Web Station entries differ from the declared access and launch mode; rebuild with synopkgland build`);
|
|
218
352
|
}
|
|
219
353
|
}
|
|
220
|
-
catch {
|
|
354
|
+
catch {
|
|
355
|
+
/* checkConf reports invalid resources */
|
|
356
|
+
}
|
|
221
357
|
}
|
|
222
358
|
const appId = native ? `SYNO.SDS.${info.package}.Instance` : info.package;
|
|
223
359
|
let launcher;
|
|
224
360
|
try {
|
|
225
361
|
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
226
|
-
launcher = Object.values(config)
|
|
362
|
+
launcher = Object.values(config)
|
|
363
|
+
.map((entries) => entries[appId])
|
|
364
|
+
.find(Boolean);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
/* report the missing or malformed launcher below */
|
|
227
368
|
}
|
|
228
|
-
catch { /* report the missing or malformed launcher below */ }
|
|
229
369
|
const type = dsmLauncherType(launch, native);
|
|
230
|
-
if (!launcher ||
|
|
370
|
+
if (!launcher ||
|
|
371
|
+
launcher.type !== type ||
|
|
372
|
+
info.dsmuidir !== "ui" ||
|
|
373
|
+
info.dsmappname !== appId ||
|
|
374
|
+
(native
|
|
375
|
+
? launcher.appWindow !== `SYNO.SDS.${info.package}.MainWindow`
|
|
376
|
+
: launcher.url !== url || info.adminurl !== url)) {
|
|
231
377
|
findings.push(`${LAUNCHER_TYPE_MISMATCH} ${launch} requires a ${type} launcher at ${appId} and matching INFO entry; rebuild with synopkgland build`);
|
|
232
378
|
}
|
|
233
|
-
if (launcher &&
|
|
379
|
+
if (launcher &&
|
|
380
|
+
(launcher.defaultAlias !== alias ||
|
|
381
|
+
(!alias &&
|
|
382
|
+
(launcher.loginStyle !== undefined ||
|
|
383
|
+
launcher.loginIcon !== undefined)))) {
|
|
234
384
|
findings.push(`${LAUNCHER_PORTAL_MISMATCH} defaultAlias and login fields require an explicit matching portal declaration; remove them or opt in to portal and rebuild`);
|
|
235
385
|
}
|
|
236
386
|
return launch;
|
|
@@ -239,20 +389,32 @@ async function checkLaunchConfiguration(dir, info, native, declared, alias, find
|
|
|
239
389
|
async function checkEntryLauncher(dir, pkg, entry, findings) {
|
|
240
390
|
const target = join(dir, "entry-inspection");
|
|
241
391
|
mkdirSync(target);
|
|
242
|
-
await tar.x({
|
|
392
|
+
await tar.x({
|
|
393
|
+
file: join(dir, "package.tgz"),
|
|
394
|
+
cwd: target,
|
|
395
|
+
filter: (path) => path.replace(/^\.\//, "") === "ui/config",
|
|
396
|
+
});
|
|
243
397
|
let launcher;
|
|
244
398
|
try {
|
|
245
399
|
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
246
400
|
launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
|
|
247
401
|
}
|
|
248
|
-
catch {
|
|
402
|
+
catch {
|
|
403
|
+
/* reported below */
|
|
404
|
+
}
|
|
249
405
|
const expected = entryLauncherAccess(entry);
|
|
250
406
|
const fields = ["allUsers", "grantPrivilege", "advanceGrantPrivilege"];
|
|
251
|
-
if (!launcher ||
|
|
407
|
+
if (!launcher ||
|
|
408
|
+
fields.some((field) => launcher[field] !== expected[field])) {
|
|
252
409
|
findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json declares entry: ${entry}, but the ui/config launcher is not ${JSON.stringify(expected)}; regenerate it with synopkgland build`);
|
|
253
410
|
}
|
|
254
411
|
}
|
|
255
|
-
const LOGIN_STYLE_FILES = [
|
|
412
|
+
const LOGIN_STYLE_FILES = [
|
|
413
|
+
"defaultLoginWallpaper",
|
|
414
|
+
"defaultLoginWallpaperThumbnail",
|
|
415
|
+
"loginPreviewThumbnailMaskPath",
|
|
416
|
+
"titleImgPath",
|
|
417
|
+
];
|
|
256
418
|
/**
|
|
257
419
|
* The launcher's loginStyle and the ui/ files it names, at 1x and 2x. A portal
|
|
258
420
|
* App without one shows a blank Control Panel > Login Portal thumbnail.
|
|
@@ -260,13 +422,19 @@ const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbn
|
|
|
260
422
|
async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
261
423
|
const target = join(dir, "login-style-inspection");
|
|
262
424
|
mkdirSync(target);
|
|
263
|
-
await tar.x({
|
|
425
|
+
await tar.x({
|
|
426
|
+
file: join(dir, "package.tgz"),
|
|
427
|
+
cwd: target,
|
|
428
|
+
filter: (path) => path.replace(/^\.\//, "").startsWith("ui/"),
|
|
429
|
+
});
|
|
264
430
|
let launcher;
|
|
265
431
|
try {
|
|
266
432
|
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
267
433
|
launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
|
|
268
434
|
}
|
|
269
|
-
catch {
|
|
435
|
+
catch {
|
|
436
|
+
/* reported by the other checks */
|
|
437
|
+
}
|
|
270
438
|
const style = launcher?.loginStyle;
|
|
271
439
|
if (typeof style !== "object" || style === null) {
|
|
272
440
|
if (portal)
|
|
@@ -278,9 +446,12 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
|
278
446
|
const path = fields[field];
|
|
279
447
|
if (path === undefined)
|
|
280
448
|
return undefined;
|
|
281
|
-
const files = typeof path === "string" &&
|
|
282
|
-
|
|
283
|
-
|
|
449
|
+
const files = typeof path === "string" &&
|
|
450
|
+
!path.split("/").includes("..") &&
|
|
451
|
+
!path.startsWith("/")
|
|
452
|
+
? ["1x", "2x"].map((scale) => join(target, "ui", path.replace("{0}", scale)))
|
|
453
|
+
: [];
|
|
454
|
+
if (!files.length || !files.every((file) => existsSync(file))) {
|
|
284
455
|
findings.push(`LOGIN_STYLE_FILE_MISSING ui/config loginStyle.${field} names ui/${String(path)}, which the payload does not carry`);
|
|
285
456
|
return undefined;
|
|
286
457
|
}
|
|
@@ -289,13 +460,22 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
|
289
460
|
const [wallpaper] = LOGIN_STYLE_FILES.map(read);
|
|
290
461
|
return {
|
|
291
462
|
color: String(fields.mainColor ?? ""),
|
|
292
|
-
wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper)
|
|
293
|
-
|
|
463
|
+
wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper)
|
|
464
|
+
? "default"
|
|
465
|
+
: "custom",
|
|
466
|
+
title: fields.titleImgPath !== undefined
|
|
467
|
+
? "custom"
|
|
468
|
+
: launcher.loginIcon !== undefined
|
|
469
|
+
? "app-icon"
|
|
470
|
+
: "none",
|
|
294
471
|
};
|
|
295
472
|
}
|
|
296
473
|
/** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
|
|
297
474
|
function inspectPackageIcons(dir, findings) {
|
|
298
|
-
for (const [name, size] of [
|
|
475
|
+
for (const [name, size] of [
|
|
476
|
+
["PACKAGE_ICON.PNG", 64],
|
|
477
|
+
["PACKAGE_ICON_256.PNG", 256],
|
|
478
|
+
]) {
|
|
299
479
|
const bytes = readOptionalBytes(join(dir, name));
|
|
300
480
|
if (!bytes)
|
|
301
481
|
continue;
|
|
@@ -317,7 +497,10 @@ function inspectPackageIcons(dir, findings) {
|
|
|
317
497
|
return { source: "none" };
|
|
318
498
|
return big.equals(defaultPackageIcons().packageIcon256)
|
|
319
499
|
? { source: "default" }
|
|
320
|
-
: {
|
|
500
|
+
: {
|
|
501
|
+
source: "custom",
|
|
502
|
+
sha256: createHash("sha256").update(big).digest("hex"),
|
|
503
|
+
};
|
|
321
504
|
}
|
|
322
505
|
function readOptionalBytes(path) {
|
|
323
506
|
return existsSync(path) ? readFileSync(path) : undefined;
|
|
@@ -328,16 +511,18 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
|
328
511
|
if (info.os_min_ver && !OS_MIN_VER.test(info.os_min_ver)) {
|
|
329
512
|
findings.push(`INFO os_min_ver "${info.os_min_ver}" is not the DSM 7 <version>-<build> (for example 7.1-42661)`);
|
|
330
513
|
}
|
|
331
|
-
else if (kind === "container" &&
|
|
514
|
+
else if (kind === "container" &&
|
|
515
|
+
info.os_min_ver &&
|
|
516
|
+
!isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)) {
|
|
332
517
|
findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${CONTAINER_MIN_DSM} a container App needs; docker-project/Container Manager starts at DSM 7.2.1, so on an earlier DSM it installs but cannot start`);
|
|
333
518
|
}
|
|
334
519
|
const tokens = (info.arch ?? "").split(" ").filter(Boolean);
|
|
335
520
|
if (tokens.length === 0 || tokens.includes("noarch"))
|
|
336
521
|
return;
|
|
337
|
-
for (const token of tokens.filter(token => !familyOfArchToken(token))) {
|
|
522
|
+
for (const token of tokens.filter((token) => !familyOfArchToken(token))) {
|
|
338
523
|
findings.push(`INFO arch contains an unknown platform "${token}"`);
|
|
339
524
|
}
|
|
340
|
-
const families = new Set(tokens.map(familyOfArchToken).filter(family => family !== undefined));
|
|
525
|
+
const families = new Set(tokens.map(familyOfArchToken).filter((family) => family !== undefined));
|
|
341
526
|
if (families.size > 1) {
|
|
342
527
|
findings.push(`INFO arch mixes several architectures (${[...families].join(", ")}); an SPK contains binaries for one architecture only`);
|
|
343
528
|
}
|
|
@@ -347,31 +532,224 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
|
347
532
|
const cgi = join(dir, "cgi-inspection");
|
|
348
533
|
mkdirSync(cgi);
|
|
349
534
|
// Only the header is read; the CGI is never run.
|
|
350
|
-
await tar.x({
|
|
535
|
+
await tar.x({
|
|
536
|
+
file: join(dir, "package.tgz"),
|
|
537
|
+
cwd: cgi,
|
|
538
|
+
filter: (path) => path.replace(/^\.\//, "") === CGI_PAYLOAD_PATH,
|
|
539
|
+
});
|
|
351
540
|
const header = readFileSync(join(cgi, CGI_PAYLOAD_PATH)).subarray(0, 20);
|
|
352
|
-
const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF"
|
|
353
|
-
|
|
541
|
+
const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF"
|
|
542
|
+
? header.readUInt16LE(18)
|
|
543
|
+
: undefined;
|
|
544
|
+
const actual = NATIVE_ARCH_FAMILIES.find((family) => ELF_MACHINE[family] === machine);
|
|
354
545
|
if (actual && actual !== declared) {
|
|
355
546
|
findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
|
|
356
547
|
}
|
|
357
548
|
}
|
|
549
|
+
/** Replay the SDK planner over archived source bytes, not merely a mutable lock. */
|
|
550
|
+
async function inspectUnifiedCompose(dir, payload, metadata, findings) {
|
|
551
|
+
const info = readInfo(join(dir, "INFO"), findings, "binary", true);
|
|
552
|
+
const base = {
|
|
553
|
+
kind: "container",
|
|
554
|
+
package: info.package,
|
|
555
|
+
version: info.version,
|
|
556
|
+
arch: info.arch,
|
|
557
|
+
signed: existsSync(join(dir, "syno_signature.asc")),
|
|
558
|
+
payload,
|
|
559
|
+
findings,
|
|
560
|
+
launch: metadata.launch,
|
|
561
|
+
capabilities: (metadata.capabilities ?? []).map((name) => ({
|
|
562
|
+
name,
|
|
563
|
+
requiresAdminApproval: true,
|
|
564
|
+
})),
|
|
565
|
+
};
|
|
566
|
+
const root = join(dir, "unified-compose-inspection");
|
|
567
|
+
mkdirSync(root);
|
|
568
|
+
await tar.x({ file: join(dir, "package.tgz"), cwd: root });
|
|
569
|
+
try {
|
|
570
|
+
if (metadata.capabilities?.some((c) => c !== "composeMounts"))
|
|
571
|
+
throw new Error("Compose cannot claim an undeclared executor capability");
|
|
572
|
+
const declaration = metadata.compose;
|
|
573
|
+
const saved = JSON.parse(readFileSync(join(root, "compose/request.json"), "utf8"));
|
|
574
|
+
if (!isDeepStrictEqual(saved, declaration))
|
|
575
|
+
throw new Error("Compose request inventory differs from the payload");
|
|
576
|
+
const gateway = readOptional(join(root, "sso/gateway.json"));
|
|
577
|
+
const cfg = gateway ? JSON.parse(gateway) : undefined;
|
|
578
|
+
const arch = info.arch.split(/\s+/).map(familyOfArchToken).find(Boolean) ?? "x86_64";
|
|
579
|
+
const { helpers: _, ...raw } = saved.declaration;
|
|
580
|
+
const c = readComposeApp(raw, join(root, "compose/upstream"), [arch], (e) => {
|
|
581
|
+
throw new Error(`[${e.code}] ${e.message}`);
|
|
582
|
+
}, {
|
|
583
|
+
package: info.package,
|
|
584
|
+
sdk: saved.sdk,
|
|
585
|
+
mode: cfg?.mode ?? "none",
|
|
586
|
+
composeMounts: metadata.capabilities?.includes("composeMounts"),
|
|
587
|
+
architecture: arch,
|
|
588
|
+
imageProofs: JSON.parse(readFileSync(join(root, "compose/images.json"), "utf8")),
|
|
589
|
+
});
|
|
590
|
+
if (!c)
|
|
591
|
+
throw new Error("missing Compose plan");
|
|
592
|
+
if (c.plan.images.some((i) => !i.pinned || !c.plan.image_proofs?.[i.source]))
|
|
593
|
+
throw new Error("COMPOSE_IMAGE_LOCK_INVALID every service needs verified immutable image metadata");
|
|
594
|
+
if (composeSource(c) !==
|
|
595
|
+
readFileSync(join(root, "compose/docker-compose.yml"), "utf8"))
|
|
596
|
+
throw new Error("worker Compose differs from the SDK plan");
|
|
597
|
+
if (c.plan.override !==
|
|
598
|
+
readFileSync(join(root, "compose/generated.override.yaml"), "utf8"))
|
|
599
|
+
throw new Error("generated override differs from the SDK plan");
|
|
600
|
+
if (JSON.stringify(c.plan.lock) !==
|
|
601
|
+
JSON.stringify(JSON.parse(readFileSync(join(root, "compose/compose.lock.json"), "utf8"))))
|
|
602
|
+
throw new Error("source/image/policy lock differs from the SDK plan");
|
|
603
|
+
c.targets = {};
|
|
604
|
+
const g = saved.gateway
|
|
605
|
+
? readIdentityGateway(saved.gateway, { targets: { [arch]: join(root, "daemon/payload") } }, (e) => {
|
|
606
|
+
throw new Error(e.message);
|
|
607
|
+
}, true)
|
|
608
|
+
: undefined;
|
|
609
|
+
// The complete gateway declaration is retained separately so native argv
|
|
610
|
+
// adapters are inspected against their actual packaged payload.
|
|
611
|
+
const manifest = {
|
|
612
|
+
root,
|
|
613
|
+
package: info.package,
|
|
614
|
+
version: info.version,
|
|
615
|
+
spkVersion: info.version,
|
|
616
|
+
displayname: info.displayname,
|
|
617
|
+
description: info.description,
|
|
618
|
+
maintainer: info.maintainer,
|
|
619
|
+
os_min_ver: info.os_min_ver,
|
|
620
|
+
build: Number(info.version?.split("-").pop()),
|
|
621
|
+
arches: [arch],
|
|
622
|
+
backend: { kind: "binary", files: {} },
|
|
623
|
+
ui: { dir: root, launch: metadata.launch },
|
|
624
|
+
capabilities: {
|
|
625
|
+
...(metadata.capabilities?.includes("composeMounts")
|
|
626
|
+
? { composeMounts: true }
|
|
627
|
+
: {}),
|
|
628
|
+
},
|
|
629
|
+
compose: c,
|
|
630
|
+
...(g ? { identityGateway: g } : {}),
|
|
631
|
+
...(metadata.entry ? { entry: metadata.entry } : {}),
|
|
632
|
+
};
|
|
633
|
+
if (Boolean(c.web) !== Boolean(g))
|
|
634
|
+
throw new Error("Web ingress must have a complete identity gateway");
|
|
635
|
+
if (gateway && gateway !== identityGatewayConfig(manifest))
|
|
636
|
+
throw new Error("gateway configuration differs from the declared package contract");
|
|
637
|
+
if (readOptional(join(dir, "conf/privilege")) !== renderNativePrivilege())
|
|
638
|
+
throw new Error("Compose hooks must run as the package account");
|
|
639
|
+
const resource = renderStandaloneResource(manifest);
|
|
640
|
+
if (readOptional(join(dir, "conf/resource")) !== resource)
|
|
641
|
+
throw new Error("unexpected worker resource; unapproved Shares must never auto-acquire");
|
|
642
|
+
if (readdirSync(join(dir, "conf")).some((n) => !["privilege", ...(resource ? ["resource"] : [])].includes(n)))
|
|
643
|
+
throw new Error("unexpected Compose resource");
|
|
644
|
+
if (readOptional(join(root, "ui/config")) !== renderUiConfig(manifest))
|
|
645
|
+
throw new Error("LAUNCHER_TYPE_MISMATCH classless Compose must use the shared url launcher");
|
|
646
|
+
if (g &&
|
|
647
|
+
(cfg.package !== info.package ||
|
|
648
|
+
info.adminurl !== `/webman/3rdparty/${info.package}/sso/open.cgi`))
|
|
649
|
+
throw new Error("gateway entry mismatch");
|
|
650
|
+
const appScripts = unwrapAppScripts(join(dir, "scripts"), findings);
|
|
651
|
+
manifest.appScripts = undefined;
|
|
652
|
+
for (const hook of [
|
|
653
|
+
"preinst",
|
|
654
|
+
"postinst",
|
|
655
|
+
"preuninst",
|
|
656
|
+
"postuninst",
|
|
657
|
+
"preupgrade",
|
|
658
|
+
"postupgrade",
|
|
659
|
+
"start-stop-status",
|
|
660
|
+
])
|
|
661
|
+
if (readOptional(join(dir, "scripts", hook)) !==
|
|
662
|
+
renderStandaloneScript(hook, manifest))
|
|
663
|
+
throw new Error(`unexpected scripts/${hook}`);
|
|
664
|
+
if (appScripts.length)
|
|
665
|
+
base.appScripts = appScripts;
|
|
666
|
+
const materializer = checkCgiBinary(root, join(root, "compose/control"), arch);
|
|
667
|
+
if (materializer)
|
|
668
|
+
throw new Error(materializer.message);
|
|
669
|
+
if (payload.find((p) => p.path === "compose/control")?.mode !== "0755")
|
|
670
|
+
throw new Error("Compose materializer must be executable");
|
|
671
|
+
const binaries = [
|
|
672
|
+
"daemon/control",
|
|
673
|
+
"daemon/payload/_sso/gateway",
|
|
674
|
+
"ui/sso/authorize.cgi",
|
|
675
|
+
"ui/sso/open.cgi",
|
|
676
|
+
];
|
|
677
|
+
for (const path of g ? binaries : []) {
|
|
678
|
+
const problem = checkCgiBinary(root, join(root, path), arch);
|
|
679
|
+
if (problem)
|
|
680
|
+
throw new Error(problem.message);
|
|
681
|
+
if (payload.find((p) => p.path === path)?.mode !== "0755")
|
|
682
|
+
throw new Error(`${path} must be executable`);
|
|
683
|
+
}
|
|
684
|
+
if (g &&
|
|
685
|
+
readOptional(join(root, "daemon/config.json")) !== daemonConfig(manifest))
|
|
686
|
+
throw new Error("daemon configuration differs from the SDK profile");
|
|
687
|
+
if (!isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM))
|
|
688
|
+
throw new Error("Compose requires DSM >= 7.2.1");
|
|
689
|
+
const storageProblem = dsmComposeProblem(c);
|
|
690
|
+
if (storageProblem)
|
|
691
|
+
throw new Error(`[${storageProblem.code}] ${storageProblem.message}`);
|
|
692
|
+
base.compose = {
|
|
693
|
+
...(c.plan.ingress ? { ingress: c.plan.ingress } : {}),
|
|
694
|
+
images: c.plan.images,
|
|
695
|
+
services: c.plan.services,
|
|
696
|
+
mountsBlocked: c.plan.mounts_blocked,
|
|
697
|
+
acceptanceBlockers: c.plan.acceptance_blockers,
|
|
698
|
+
readiness: {
|
|
699
|
+
profile: c.plan.mounts_blocked
|
|
700
|
+
? "disabled"
|
|
701
|
+
: !c.web
|
|
702
|
+
? "none"
|
|
703
|
+
: c.plan.services.find((s) => s.service === c.web?.service)
|
|
704
|
+
?.network === "host"
|
|
705
|
+
? "host-uid-listener"
|
|
706
|
+
: "health-fixed-ip-lifecycle",
|
|
707
|
+
limitation: COMPOSE_READINESS_LIMITATION,
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
catch (e) {
|
|
712
|
+
findings.push(`COMPOSE_ARTIFACT_INVALID ${e.message}`);
|
|
713
|
+
}
|
|
714
|
+
return base;
|
|
715
|
+
}
|
|
358
716
|
async function recoverCompose(dir, value, findings) {
|
|
359
717
|
try {
|
|
360
|
-
if (!value ||
|
|
361
|
-
|
|
362
|
-
|
|
718
|
+
if (!value ||
|
|
719
|
+
typeof value !== "object" ||
|
|
720
|
+
Object.keys(value).join() !== "services")
|
|
721
|
+
throw new Error("invalid compose inventory");
|
|
722
|
+
const root = join(dir, "compose-inspection");
|
|
363
723
|
mkdirSync(root);
|
|
364
|
-
await tar.x({
|
|
365
|
-
|
|
724
|
+
await tar.x({
|
|
725
|
+
file: join(dir, "package.tgz"),
|
|
726
|
+
cwd: root,
|
|
727
|
+
filter: (p) => ["compose/docker-compose.yml", "sso/gateway.json"].includes(p.replace(/^\.\//, "")),
|
|
728
|
+
});
|
|
729
|
+
const compose = readComposeApp({
|
|
730
|
+
file: "compose/docker-compose.yml",
|
|
731
|
+
targets: { x86_64: "daemon/payload" },
|
|
732
|
+
services: value.services,
|
|
733
|
+
}, root, ["x86_64"], (e) => {
|
|
734
|
+
throw new Error(e.message);
|
|
735
|
+
});
|
|
366
736
|
if (!compose)
|
|
367
|
-
throw new Error(
|
|
368
|
-
const config = JSON.parse(readFileSync(join(root,
|
|
737
|
+
throw new Error("invalid compose");
|
|
738
|
+
const config = JSON.parse(readFileSync(join(root, "sso/gateway.json"), "utf8"));
|
|
369
739
|
const upstream = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(config.upstream)?.[1];
|
|
370
740
|
const listen = config.listen;
|
|
371
|
-
if (!upstream ||
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
741
|
+
if (!upstream ||
|
|
742
|
+
!Object.values(compose.services).some((s) => s.port === Number(upstream)) ||
|
|
743
|
+
!listen ||
|
|
744
|
+
!Object.keys(listen).length ||
|
|
745
|
+
Object.entries(listen).some(([key, port]) => !["http", "https"].includes(key) ||
|
|
746
|
+
!Number.isInteger(port) ||
|
|
747
|
+
Number(port) < 1024 ||
|
|
748
|
+
Number(port) > 65535 ||
|
|
749
|
+
Object.values(compose.services).some((s) => s.port === port)))
|
|
750
|
+
throw new Error("invalid gateway network");
|
|
751
|
+
if (config.package !== readInfo(join(dir, "INFO"), [], "container").package)
|
|
752
|
+
throw new Error("gateway package mismatch");
|
|
375
753
|
return { compose, identityGateway: { listen } };
|
|
376
754
|
}
|
|
377
755
|
catch (e) {
|
|
@@ -380,37 +758,80 @@ async function recoverCompose(dir, value, findings) {
|
|
|
380
758
|
}
|
|
381
759
|
}
|
|
382
760
|
async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined) {
|
|
383
|
-
if (![...paths].some(path => path.startsWith("daemon/")))
|
|
761
|
+
if (![...paths].some((path) => path.startsWith("daemon/")))
|
|
384
762
|
return;
|
|
385
763
|
if (info.precheckstartstop !== "yes")
|
|
386
|
-
findings.push(
|
|
387
|
-
if (payload.find(p => p.path === "daemon/control")?.mode !== "0755")
|
|
764
|
+
findings.push('daemon INFO must explicitly set precheckstartstop="yes"');
|
|
765
|
+
if (payload.find((p) => p.path === "daemon/control")?.mode !== "0755")
|
|
388
766
|
findings.push("daemon/control is missing or not executable");
|
|
389
767
|
const unpacked = join(dir, "daemon-inspection");
|
|
390
768
|
// Extract only the bounded config; the payload executable is never run.
|
|
391
769
|
const { mkdirSync } = await import("node:fs");
|
|
392
770
|
mkdirSync(unpacked);
|
|
393
|
-
await tar.x({
|
|
771
|
+
await tar.x({
|
|
772
|
+
file: join(dir, "package.tgz"),
|
|
773
|
+
cwd: unpacked,
|
|
774
|
+
filter: (path) => path.replace(/^\.\//, "") === "daemon/config.json",
|
|
775
|
+
});
|
|
394
776
|
try {
|
|
395
777
|
const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
|
|
396
|
-
if (config.package !== info.package ||
|
|
778
|
+
if (config.package !== info.package ||
|
|
779
|
+
Object.keys(config).some((key) => ![
|
|
780
|
+
"package",
|
|
781
|
+
"exec",
|
|
782
|
+
"args",
|
|
783
|
+
"env",
|
|
784
|
+
"timeouts",
|
|
785
|
+
"uninstall_args",
|
|
786
|
+
...(combined ? ["network"] : []),
|
|
787
|
+
].includes(key)))
|
|
397
788
|
throw new Error("package/config mismatch");
|
|
398
|
-
const daemon = readDaemon({
|
|
789
|
+
const daemon = readDaemon({
|
|
790
|
+
exec: config.exec,
|
|
791
|
+
args: config.args,
|
|
792
|
+
uninstall_args: config.uninstall_args,
|
|
793
|
+
env: config.env,
|
|
794
|
+
timeouts: config.timeouts,
|
|
795
|
+
targets: { x86_64: "payload" },
|
|
796
|
+
}, dir, ["x86_64"], () => { });
|
|
399
797
|
if (!daemon)
|
|
400
798
|
throw new Error("invalid config");
|
|
401
|
-
if (combined &&
|
|
799
|
+
if (combined &&
|
|
800
|
+
(daemon.exec !== "_sso/gateway" ||
|
|
801
|
+
JSON.stringify(daemon.args) !== '["run"]' ||
|
|
802
|
+
daemon.uninstall_args))
|
|
402
803
|
throw new Error("compose gateway must run without a child");
|
|
403
|
-
if (payload.find(p => p.path === `daemon/payload/${daemon.exec}`)?.mode !==
|
|
804
|
+
if (payload.find((p) => p.path === `daemon/payload/${daemon.exec}`)?.mode !==
|
|
805
|
+
"0755")
|
|
404
806
|
throw new Error("missing executable");
|
|
405
|
-
const manifest = {
|
|
807
|
+
const manifest = {
|
|
808
|
+
package: info.package,
|
|
809
|
+
daemon,
|
|
810
|
+
...combined,
|
|
811
|
+
capabilities: {
|
|
812
|
+
...(executor ? { rootOps: true } : {}),
|
|
813
|
+
...(background ? { jobs: { worker: "sha256-v1" } } : {}),
|
|
814
|
+
},
|
|
815
|
+
};
|
|
406
816
|
// A config from before timeouts.uninstall pairs with a control that rejects the key.
|
|
407
|
-
const legacy = typeof config.timeouts === "object" &&
|
|
817
|
+
const legacy = typeof config.timeouts === "object" &&
|
|
818
|
+
config.timeouts !== null &&
|
|
819
|
+
!("uninstall" in config.timeouts);
|
|
408
820
|
const { uninstall: _, ...legacyTimeouts } = daemon.timeouts;
|
|
409
|
-
const canonical = daemonConfig(legacy
|
|
821
|
+
const canonical = daemonConfig(legacy
|
|
822
|
+
? {
|
|
823
|
+
...manifest,
|
|
824
|
+
daemon: {
|
|
825
|
+
...daemon,
|
|
826
|
+
timeouts: legacyTimeouts,
|
|
827
|
+
},
|
|
828
|
+
}
|
|
829
|
+
: manifest);
|
|
410
830
|
if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== canonical)
|
|
411
831
|
throw new Error("non-canonical daemon config");
|
|
412
832
|
for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
|
|
413
|
-
if (readFileSync(join(dir, "scripts", hook), "utf8") !==
|
|
833
|
+
if (readFileSync(join(dir, "scripts", hook), "utf8") !==
|
|
834
|
+
renderStandaloneScript(hook, manifest))
|
|
414
835
|
findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
|
|
415
836
|
}
|
|
416
837
|
return daemon;
|
|
@@ -436,12 +857,21 @@ function readInfo(path, findings, kind, compose = false) {
|
|
|
436
857
|
findings.push(`INFO is missing ${key}`);
|
|
437
858
|
}
|
|
438
859
|
}
|
|
439
|
-
if (kind !== "container" &&
|
|
440
|
-
|
|
860
|
+
if (kind !== "container" &&
|
|
861
|
+
(info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
|
|
862
|
+
findings.push('INFO must have thirdparty="yes" and dsmuidir="ui" for DSM to serve /webman/3rdparty/<package>/');
|
|
441
863
|
}
|
|
442
864
|
// A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
|
|
443
|
-
const allowed = compose
|
|
444
|
-
|
|
865
|
+
const allowed = compose
|
|
866
|
+
? undefined
|
|
867
|
+
: kind === "container"
|
|
868
|
+
? /^(WebStation|ContainerManager)([><=].*)?$/
|
|
869
|
+
: kind === "native-node"
|
|
870
|
+
? /^Node\.js_v[0-9]{2}([><=].*)?$/
|
|
871
|
+
: undefined;
|
|
872
|
+
if (info.install_dep_packages &&
|
|
873
|
+
!(allowed &&
|
|
874
|
+
info.install_dep_packages.split(":").every((dep) => allowed.test(dep)))) {
|
|
445
875
|
findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
|
|
446
876
|
}
|
|
447
877
|
return info;
|
|
@@ -450,9 +880,15 @@ function readInfo(path, findings, kind, compose = false) {
|
|
|
450
880
|
async function checkContainerExposure(dir, packageTgz, findings) {
|
|
451
881
|
const extracted = join(dir, "payload");
|
|
452
882
|
mkdirSync(extracted);
|
|
453
|
-
await tar.x({
|
|
883
|
+
await tar.x({
|
|
884
|
+
file: packageTgz,
|
|
885
|
+
cwd: extracted,
|
|
886
|
+
filter: (path) => path.replace(/^\.\//, "") === "compose.yaml",
|
|
887
|
+
});
|
|
454
888
|
const compose = loadCompose(join(extracted, "compose.yaml"))?.compose;
|
|
455
|
-
for (const exposure of compose
|
|
889
|
+
for (const exposure of compose
|
|
890
|
+
? listPortExposures(compose).filter((e) => !e.loopbackOnly)
|
|
891
|
+
: []) {
|
|
456
892
|
findings.push(`CONTAINER_PORT_EXPOSED ${exposure.service}: ${exposure.entry} binds to every network interface, so it is reachable without DSM verification; bind it to 127.0.0.1`);
|
|
457
893
|
}
|
|
458
894
|
try {
|
|
@@ -460,7 +896,9 @@ async function checkContainerExposure(dir, packageTgz, findings) {
|
|
|
460
896
|
findings.push("CONTAINER_ANONYMOUS_PORTAL conf/resource registers a Web Station portal that anyone can open without signing in to DSM (integration.web.access: public)");
|
|
461
897
|
}
|
|
462
898
|
}
|
|
463
|
-
catch {
|
|
899
|
+
catch {
|
|
900
|
+
/* checkConf already reports an unreadable conf/resource */
|
|
901
|
+
}
|
|
464
902
|
}
|
|
465
903
|
function readOptional(path) {
|
|
466
904
|
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
@@ -468,12 +906,25 @@ function readOptional(path) {
|
|
|
468
906
|
function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose) {
|
|
469
907
|
const confDir = join(dir, "conf");
|
|
470
908
|
const entries = existsSync(confDir) ? readdirSync(confDir) : [];
|
|
471
|
-
const nativeResource = kind !== "container"
|
|
472
|
-
|
|
909
|
+
const nativeResource = kind !== "container"
|
|
910
|
+
? renderStandaloneResource({
|
|
911
|
+
package: pkg ?? "",
|
|
912
|
+
...(compose ? { compose } : {}),
|
|
913
|
+
capabilities: background
|
|
914
|
+
? { jobs: { worker: "sha256-v1" } }
|
|
915
|
+
: {},
|
|
916
|
+
...(sharedFolder ? { sharedFolder } : {}),
|
|
917
|
+
}, portal)
|
|
918
|
+
: undefined;
|
|
919
|
+
if (nativeResource !== undefined &&
|
|
920
|
+
readOptional(join(confDir, "resource")) !== nativeResource) {
|
|
473
921
|
findings.push(`conf/resource must be ${nativeResource} (only the official data-share, systemd-user-unit and web-config, and consistent with the install.sharedFolder and portal declarations)`);
|
|
474
922
|
}
|
|
475
923
|
for (const name of entries) {
|
|
476
|
-
if (name !== "privilege" &&
|
|
924
|
+
if (name !== "privilege" &&
|
|
925
|
+
!(kind === "container" && name === "resource") &&
|
|
926
|
+
!(nativeResource !== undefined && name === "resource") &&
|
|
927
|
+
!(background && name === "systemd")) {
|
|
477
928
|
findings.push(`conf/${name} registers an extra resource with DSM; a Standalone Package does not produce it`);
|
|
478
929
|
}
|
|
479
930
|
}
|
|
@@ -505,7 +956,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
|
|
|
505
956
|
}
|
|
506
957
|
const defaults = privilege.defaults;
|
|
507
958
|
if (defaults?.["run-as"] !== "package") {
|
|
508
|
-
findings.push(
|
|
959
|
+
findings.push('conf/privilege defaults.run-as must be "package"');
|
|
509
960
|
}
|
|
510
961
|
for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
|
|
511
962
|
if (key in privilege) {
|
|
@@ -518,17 +969,32 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
|
|
|
518
969
|
const pkg = info.package;
|
|
519
970
|
if (!pkg)
|
|
520
971
|
return;
|
|
521
|
-
const read = (path) => existsSync(join(dir, path))
|
|
522
|
-
|
|
972
|
+
const read = (path) => existsSync(join(dir, path))
|
|
973
|
+
? readFileSync(join(dir, path), "utf8")
|
|
974
|
+
: undefined;
|
|
975
|
+
if (payload.find((p) => p.path === BACKGROUND_WORKER_PAYLOAD)?.mode !== "0755")
|
|
523
976
|
findings.push(`${BACKGROUND_WORKER_PAYLOAD} is missing or not executable`);
|
|
524
977
|
if (info.precheckstartstop !== "yes")
|
|
525
|
-
findings.push(
|
|
526
|
-
const units = existsSync(join(dir, BACKGROUND_UNIT_DIR))
|
|
978
|
+
findings.push('background INFO must explicitly set precheckstartstop="yes" (prestart recovers the package user manager that lost its socket)');
|
|
979
|
+
const units = existsSync(join(dir, BACKGROUND_UNIT_DIR))
|
|
980
|
+
? readdirSync(join(dir, BACKGROUND_UNIT_DIR))
|
|
981
|
+
: [];
|
|
527
982
|
const unit = backgroundUnitName(pkg);
|
|
528
|
-
if (units.length !== 1 ||
|
|
983
|
+
if (units.length !== 1 ||
|
|
984
|
+
units[0] !== unit ||
|
|
985
|
+
read(`${BACKGROUND_UNIT_DIR}/${unit}`) !==
|
|
986
|
+
renderBackgroundUnit(pkg, bridge ? BRIDGE_WORKER_ARGS : [])) {
|
|
529
987
|
findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
|
|
530
988
|
}
|
|
531
|
-
if (read("scripts/start-stop-status") !==
|
|
989
|
+
if (read("scripts/start-stop-status") !==
|
|
990
|
+
renderStandaloneScript("start-stop-status", {
|
|
991
|
+
package: pkg,
|
|
992
|
+
...(daemon ? { daemon } : {}),
|
|
993
|
+
capabilities: {
|
|
994
|
+
jobs: { worker: "sha256-v1" },
|
|
995
|
+
...(executor ? { rootOps: true } : {}),
|
|
996
|
+
},
|
|
997
|
+
}))
|
|
532
998
|
findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
|
|
533
999
|
}
|
|
534
1000
|
/**
|
|
@@ -539,42 +1005,77 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
|
|
|
539
1005
|
*/
|
|
540
1006
|
function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false) {
|
|
541
1007
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
542
|
-
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
|
|
1008
|
+
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
|
|
1009
|
+
.filter((name) => /^(install|upgrade)_uifile(_|\.|$)/.test(name))
|
|
1010
|
+
.sort();
|
|
543
1011
|
const postinst = readOptional(join(dir, "scripts/postinst"));
|
|
544
1012
|
if (!folder && postinst?.includes(SHARED_FOLDER_MARKER))
|
|
545
1013
|
findings.push("the install.sharedFolder declaration in scripts/postinst is invalid");
|
|
546
1014
|
if (!pkg)
|
|
547
1015
|
return;
|
|
548
1016
|
const wizards = expectedWizardFiles(pkg, folder, selected, portal);
|
|
549
|
-
const source = selected
|
|
1017
|
+
const source = selected
|
|
1018
|
+
? "the install-selected policy"
|
|
1019
|
+
: folder
|
|
1020
|
+
? "the install.sharedFolder declaration"
|
|
1021
|
+
: portal
|
|
1022
|
+
? "the portal declaration"
|
|
1023
|
+
: "the native-go default install wizard";
|
|
550
1024
|
// The default native wizard is optional; a declared shared folder or portal needs all of its files.
|
|
551
|
-
for (const name of folder || selected || portal
|
|
1025
|
+
for (const name of folder || selected || portal
|
|
1026
|
+
? Object.keys(wizards)
|
|
1027
|
+
: present.filter((n) => n in wizards)) {
|
|
552
1028
|
if (readOptional(join(wizardDir, name)) !== wizards[name])
|
|
553
1029
|
findings.push(`WIZARD_UIFILES/${name} does not match ${source}`);
|
|
554
1030
|
}
|
|
555
|
-
for (const name of present.filter(n => !(n in wizards)))
|
|
1031
|
+
for (const name of present.filter((n) => !(n in wizards)))
|
|
556
1032
|
findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
|
|
557
1033
|
if (selected) {
|
|
558
|
-
for (const hook of [
|
|
559
|
-
if (!readOptional(join(dir,
|
|
1034
|
+
for (const hook of ["preinst", "postinst"]) {
|
|
1035
|
+
if (!readOptional(join(dir, "scripts", hook))?.includes(renderDelegateInstallDraft(pkg, hook === "preinst")))
|
|
560
1036
|
findings.push(`scripts/${hook} missing install-selected draft handling`);
|
|
561
1037
|
}
|
|
562
1038
|
}
|
|
563
1039
|
if (!folder)
|
|
564
1040
|
return;
|
|
565
1041
|
for (const hook of ["postinst", "postupgrade"]) {
|
|
566
|
-
if (readOptional(join(dir, "scripts", hook)) !==
|
|
1042
|
+
if (readOptional(join(dir, "scripts", hook)) !==
|
|
1043
|
+
renderStandaloneScript(hook, {
|
|
1044
|
+
package: pkg,
|
|
1045
|
+
sharedFolder: folder,
|
|
1046
|
+
...(selected
|
|
1047
|
+
? { capabilities: { delegate: { scope: "install-selected" } } }
|
|
1048
|
+
: executor
|
|
1049
|
+
? { capabilities: { rootOps: true } }
|
|
1050
|
+
: {}),
|
|
1051
|
+
...portalManifest(portal),
|
|
1052
|
+
})) {
|
|
567
1053
|
findings.push(`scripts/${hook} does not match the fixed script declared by install.sharedFolder`);
|
|
568
1054
|
}
|
|
569
1055
|
}
|
|
570
1056
|
}
|
|
571
1057
|
function expectedWizardFiles(pkg, folder, selected = false, portal) {
|
|
572
|
-
const steps = selected
|
|
573
|
-
|
|
574
|
-
|
|
1058
|
+
const steps = selected
|
|
1059
|
+
? (t) => [
|
|
1060
|
+
...renderDelegateScopeSteps(t),
|
|
1061
|
+
...(folder ? sharedFolderInstallSteps(pkg, folder)(t) : []),
|
|
1062
|
+
]
|
|
1063
|
+
: folder
|
|
1064
|
+
? sharedFolderInstallSteps(pkg, folder)
|
|
1065
|
+
: renderNativeInstallWizard;
|
|
1066
|
+
const install = renderInstallWizardFiles(pkg, portal
|
|
1067
|
+
? (t) => [...steps(t), renderPortalWizardStep(t, portal.alias)]
|
|
1068
|
+
: steps);
|
|
1069
|
+
return {
|
|
1070
|
+
...install.static,
|
|
1071
|
+
...install.dynamic,
|
|
1072
|
+
...(folder ? renderSharedFolderUpgradeWizardFiles(pkg, folder) : {}),
|
|
1073
|
+
};
|
|
575
1074
|
}
|
|
576
1075
|
function portalManifest(portal) {
|
|
577
|
-
return portal
|
|
1076
|
+
return portal
|
|
1077
|
+
? { displayname: portal.application, portal: { alias: portal.alias } }
|
|
1078
|
+
: {};
|
|
578
1079
|
}
|
|
579
1080
|
/**
|
|
580
1081
|
* The portal declaration is recovered from the payload's etc/portal.json. Its
|
|
@@ -583,20 +1084,26 @@ function portalManifest(portal) {
|
|
|
583
1084
|
*/
|
|
584
1085
|
async function recoverPortal(dir, info, appId, paths, payload, findings) {
|
|
585
1086
|
const files = [PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH];
|
|
586
|
-
if (!files.some(path => paths.has(path)))
|
|
1087
|
+
if (!files.some((path) => paths.has(path)))
|
|
587
1088
|
return;
|
|
588
1089
|
const unpacked = join(dir, "portal-inspection");
|
|
589
1090
|
mkdirSync(unpacked);
|
|
590
|
-
await tar.x({
|
|
1091
|
+
await tar.x({
|
|
1092
|
+
file: join(dir, "package.tgz"),
|
|
1093
|
+
cwd: unpacked,
|
|
1094
|
+
filter: (path) => files.includes(path.replace(/^\.\//, "")),
|
|
1095
|
+
});
|
|
591
1096
|
try {
|
|
592
|
-
if (files.some(path => payload.find(p => p.path === path)?.mode !== "0644"))
|
|
1097
|
+
if (files.some((path) => payload.find((p) => p.path === path)?.mode !== "0644"))
|
|
593
1098
|
throw new Error("mode");
|
|
594
1099
|
const json = readFileSync(join(unpacked, PORTAL_JSON_RELPATH), "utf8");
|
|
595
1100
|
const alias = JSON.parse(json).alias;
|
|
596
1101
|
if (!isPortalAlias(alias))
|
|
597
1102
|
throw new Error("alias");
|
|
598
1103
|
const portal = { appId, application: info.displayname ?? "", alias };
|
|
599
|
-
if (json !== renderPortalJson(portal) ||
|
|
1104
|
+
if (json !== renderPortalJson(portal) ||
|
|
1105
|
+
readFileSync(join(unpacked, PORTAL_MUSTACHE_RELPATH), "utf8") !==
|
|
1106
|
+
renderPortalMustache())
|
|
600
1107
|
throw new Error("content");
|
|
601
1108
|
for (const hook of ["preinst", "postinst", "postupgrade"]) {
|
|
602
1109
|
if (!readOptional(join(dir, "scripts", hook))?.startsWith(`#!/bin/sh\n${renderPortalLifecycle(hook, portal)}`))
|
|
@@ -632,7 +1139,10 @@ const UPGRADE_GUARD = /SYNOPKG_PKG_STATUS[^\n]*!=\s*"?UPGRADE"?/;
|
|
|
632
1139
|
function uninstallBoxes(file, keys) {
|
|
633
1140
|
try {
|
|
634
1141
|
const steps = JSON.parse(readFileSync(file, "utf8"));
|
|
635
|
-
return steps
|
|
1142
|
+
return steps
|
|
1143
|
+
.flatMap((step) => step.items ?? [])
|
|
1144
|
+
.flatMap((item) => item.subitems ?? [])
|
|
1145
|
+
.filter((box) => keys.includes(box.key));
|
|
636
1146
|
}
|
|
637
1147
|
catch {
|
|
638
1148
|
return [];
|
|
@@ -649,7 +1159,16 @@ function uninstallWizardOptions(hook) {
|
|
|
649
1159
|
if (!hook)
|
|
650
1160
|
return {};
|
|
651
1161
|
const { uninstall, catalogs } = hook;
|
|
652
|
-
return {
|
|
1162
|
+
return {
|
|
1163
|
+
rootPurge: Boolean(uninstall.purge.root),
|
|
1164
|
+
...(uninstall.state
|
|
1165
|
+
? {
|
|
1166
|
+
state: {
|
|
1167
|
+
items: (locale) => stateItemTexts(uninstall, catalogs, locale),
|
|
1168
|
+
},
|
|
1169
|
+
}
|
|
1170
|
+
: {}),
|
|
1171
|
+
};
|
|
653
1172
|
}
|
|
654
1173
|
function checkUninstall(dir, findings) {
|
|
655
1174
|
const script = join(dir, "scripts", "postuninst");
|
|
@@ -657,20 +1176,31 @@ function checkUninstall(dir, findings) {
|
|
|
657
1176
|
return;
|
|
658
1177
|
const body = readFileSync(script, "utf8");
|
|
659
1178
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
660
|
-
const wizards = existsSync(wizardDir)
|
|
661
|
-
|
|
662
|
-
|
|
1179
|
+
const wizards = existsSync(wizardDir)
|
|
1180
|
+
? readdirSync(wizardDir)
|
|
1181
|
+
.filter((name) => name === "uninstall_uifile" || name.startsWith("uninstall_uifile_"))
|
|
1182
|
+
.sort()
|
|
1183
|
+
: [];
|
|
1184
|
+
const boxes = wizards.flatMap((name) => uninstallBoxes(join(wizardDir, name), DATA_KEYS).map((box) => ({
|
|
1185
|
+
...box,
|
|
1186
|
+
name,
|
|
1187
|
+
})));
|
|
1188
|
+
checkStateBoxes(dir, wizards.flatMap((name) => uninstallBoxes(join(wizardDir, name), [DELETE_STATE_KEY]).map((box) => ({
|
|
1189
|
+
...box,
|
|
1190
|
+
name,
|
|
1191
|
+
}))), findings);
|
|
663
1192
|
const deletes = DELETE_COMMAND.test(body);
|
|
664
1193
|
if (deletes) {
|
|
665
1194
|
if (!UPGRADE_GUARD.test(body))
|
|
666
|
-
findings.push(
|
|
667
|
-
if (!/is_package_area\(\)\s*\{/.test(body) ||
|
|
1195
|
+
findings.push('UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst deletes data without the [ "$SYNOPKG_PKG_STATUS" != UPGRADE ] guard, so an upgrade deletes it by mistake');
|
|
1196
|
+
if (!/is_package_area\(\)\s*\{/.test(body) ||
|
|
1197
|
+
!/if ! is_package_area "\$real"/.test(body))
|
|
668
1198
|
findings.push("UNINSTALL_UNSAFE_DELETE scripts/postuninst does not verify the deleted paths are under /volumeN/@appdata|@apphome|@appconf|@appstore/<package>; an empty or redirected variable deletes the wrong data");
|
|
669
|
-
for (const line of body.split("\n").filter(l => DELETE_COMMAND.test(l))) {
|
|
1199
|
+
for (const line of body.split("\n").filter((l) => DELETE_COMMAND.test(l))) {
|
|
670
1200
|
for (const path of line.match(/\/volume\w*\/(?!@app(data|home|conf|store)\/)[^\s"'$;]*/g) ?? [])
|
|
671
1201
|
findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst deletes ${path}, which is not inside the package's own @app* directory`);
|
|
672
1202
|
}
|
|
673
|
-
if (!boxes.some(box => box.name === "uninstall_uifile"))
|
|
1203
|
+
if (!boxes.some((box) => box.name === "uninstall_uifile"))
|
|
674
1204
|
findings.push("UNINSTALL_WIZARD_MISSING postuninst deletes data, but WIZARD_UIFILES/uninstall_uifile has no pkgwizard_remove_additional checkbox, so the user cannot choose");
|
|
675
1205
|
}
|
|
676
1206
|
for (const box of boxes) {
|