@synopackageland/cli 0.13.1 → 0.15.0
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 +18 -0
- package/README.md +44 -1
- package/dist/build-spk/generator.d.ts +3 -2
- package/dist/build-spk/generator.js +6 -850
- 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/app-launch.d.ts +11 -0
- package/dist/contract/app-launch.js +18 -0
- package/dist/contract/web-launch.d.ts +3 -13
- package/dist/contract/web-launch.js +14 -81
- 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/error-registry.js +1020 -831
- package/dist/info.js +120 -28
- package/dist/init.js +188 -64
- package/dist/inspect.js +21 -6
- package/dist/runtime/executor-digests.json +1 -12
- 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 +55 -9
- package/dist/standalone/identity-gateway.d.ts +9 -2
- package/dist/standalone/identity-gateway.js +282 -66
- package/dist/standalone/inspect-spk.d.ts +15 -0
- package/dist/standalone/inspect-spk.js +714 -128
- package/dist/standalone/lifecycle.js +124 -42
- package/dist/standalone/manifest.d.ts +10 -1
- package/dist/standalone/manifest.js +308 -87
- package/dist/standalone/resource.js +6 -2
- package/dist/standalone/spk.d.ts +4 -1
- package/dist/standalone/spk.js +139 -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/synology-app.schema.json +1 -1
- package/schema/synopkgland-api-v1.schema.json +423 -0
- package/skills/SKILL.md +34 -1
- package/skills/reference/compose.md +349 -0
- package/skills/reference/create.md +26 -4
- package/skills/reference/ctl.md +41 -0
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/files.md +4 -0
- package/skills/reference/identity-gateway.md +8 -138
- package/skills/reference/portal.md +31 -0
- package/skills/reference/ui-vue.md +31 -0
- package/skills/reference/wrap.md +10 -30
- 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,37 +1,51 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { inspectApiDescription } from "./api-description-inspect.js";
|
|
3
|
+
import { dsmLauncherType } from "../contract/app-launch.js";
|
|
4
|
+
import { isWebLaunchMode, resolvedWebLaunch, trustedAppEntryPath, usesAuthGateway, } from "../contract/web-launch.js";
|
|
5
|
+
import { loadContract } from "../contract/load.js";
|
|
6
|
+
import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIMITATION, } from "./compose.js";
|
|
2
7
|
import { createHash } from "node:crypto";
|
|
3
8
|
import { inspectNativeLayout } from "./native-layout.js";
|
|
4
9
|
import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
|
|
5
|
-
import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
|
|
6
|
-
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";
|
|
7
12
|
import { unwrapAppScripts } from "./app-scripts.js";
|
|
8
13
|
import { tmpdir } from "node:os";
|
|
9
14
|
import { join } from "node:path";
|
|
10
15
|
import * as tar from "tar";
|
|
11
|
-
import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
|
|
12
|
-
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";
|
|
13
18
|
import { ELF_MACHINE } from "./binary.js";
|
|
14
19
|
import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
|
|
15
20
|
import { daemonConfig, readDaemon } from "./daemon.js";
|
|
16
21
|
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
17
22
|
import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
|
|
18
23
|
import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
|
|
19
|
-
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";
|
|
20
25
|
import { renderStandaloneResource } from "./resource.js";
|
|
21
|
-
import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
|
|
22
|
-
import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
|
|
23
|
-
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";
|
|
24
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";
|
|
25
34
|
import { renderStandaloneScript } from "./lifecycle.js";
|
|
26
35
|
import { listPortExposures, loadCompose } from "../compose/analyze.js";
|
|
27
|
-
import { uninstallCleanup } from "../runtime/uninstall-cleanup.js";
|
|
28
|
-
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";
|
|
29
38
|
import { isPortalAlias } from "../portal/alias.js";
|
|
30
39
|
import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
|
|
31
40
|
import { isDefaultLoginAsset } from "../build-spk/login-style.js";
|
|
32
41
|
import { decodePng } from "../build-spk/png.js";
|
|
33
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. */
|
|
34
|
-
const COMPILED_KIND = {
|
|
43
|
+
const COMPILED_KIND = {
|
|
44
|
+
go: "native-go",
|
|
45
|
+
python: "native-python",
|
|
46
|
+
node: "native-node",
|
|
47
|
+
rust: "native-rust",
|
|
48
|
+
};
|
|
35
49
|
/** Reads an SPK without installing it and reports how it would run on DSM. */
|
|
36
50
|
export async function inspectStandaloneSpk(spkPath) {
|
|
37
51
|
return inspectSpk(spkPath, "native-go");
|
|
@@ -49,21 +63,51 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
49
63
|
mkdirSync(target);
|
|
50
64
|
await tar.x({ file: join(dir, "package.tgz"), cwd: target });
|
|
51
65
|
findings.push(...inspectNativeLayout(dir, target, info));
|
|
52
|
-
for (const key of [
|
|
66
|
+
for (const key of [
|
|
67
|
+
"package",
|
|
68
|
+
"version",
|
|
69
|
+
"arch",
|
|
70
|
+
"os_min_ver",
|
|
71
|
+
"checksum",
|
|
72
|
+
])
|
|
53
73
|
if (!info[key])
|
|
54
74
|
findings.push(`Missing INFO ${key}`);
|
|
55
75
|
const icon = inspectPackageIcons(dir, findings);
|
|
56
|
-
return {
|
|
57
|
-
|
|
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
|
+
};
|
|
58
87
|
}
|
|
59
88
|
const payload = await listPayload(join(dir, "package.tgz"), findings);
|
|
60
|
-
const paths = new Set(payload.map(entry => entry.path));
|
|
89
|
+
const paths = new Set(payload.map((entry) => entry.path));
|
|
61
90
|
let metadata = {};
|
|
62
91
|
const metadataText = readOptional(join(dir, "sdk-package.json"));
|
|
63
92
|
if (metadataText !== undefined) {
|
|
64
93
|
try {
|
|
65
94
|
const raw = JSON.parse(metadataText);
|
|
66
|
-
if (
|
|
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)))
|
|
67
111
|
throw new Error();
|
|
68
112
|
metadata = raw;
|
|
69
113
|
}
|
|
@@ -71,27 +115,51 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
71
115
|
findings.push("sdk-package.json does not match the schema 2 capability inventory.");
|
|
72
116
|
}
|
|
73
117
|
}
|
|
74
|
-
|
|
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);
|
|
75
126
|
if (metadata.uninstall !== undefined && !uninstallHook)
|
|
76
127
|
findings.push("UNINSTALL_RECORD_INVALID the uninstall in sdk-package.json is not the uninstall setting synopkgland build writes.");
|
|
77
128
|
const binaryBackend = metadata.backend === "binary";
|
|
78
129
|
// A strict standalone inspection names the language the CGI was built from.
|
|
79
130
|
const compiled = COMPILED_KIND[metadata.backend ?? "go"] ?? "native-go";
|
|
80
|
-
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);
|
|
81
137
|
const compiledKind = Object.values(COMPILED_KIND).includes(kind);
|
|
82
138
|
const standalone = compiledKind || kind === "binary";
|
|
83
|
-
const combined = metadata.compose === undefined
|
|
139
|
+
const combined = metadata.compose === undefined
|
|
140
|
+
? undefined
|
|
141
|
+
: await recoverCompose(dir, metadata.compose, findings);
|
|
84
142
|
const info = readInfo(join(dir, "INFO"), findings, kind, Boolean(combined));
|
|
85
|
-
if (combined &&
|
|
143
|
+
if (combined &&
|
|
144
|
+
(!info.os_min_ver || !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)))
|
|
86
145
|
findings.push("compose identity_gateway requires DSM >= 7.2.1");
|
|
87
146
|
await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
|
|
88
147
|
const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
|
|
89
148
|
// Before any fixed-lifecycle comparison: those see the SDK steps alone.
|
|
90
|
-
const appScripts = standalone || kind === "container"
|
|
91
|
-
|
|
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;
|
|
92
155
|
// A Compose App has no executor, so its SPK never carries a portal.
|
|
93
|
-
const portal = standalone
|
|
94
|
-
|
|
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)) {
|
|
95
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}`);
|
|
96
164
|
}
|
|
97
165
|
checkConf(dir, findings, kind, background, sharedFolder, info.package, portal, combined?.compose);
|
|
@@ -99,15 +167,36 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
99
167
|
checkUninstall(dir, findings);
|
|
100
168
|
if (kind === "container")
|
|
101
169
|
await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
|
|
102
|
-
|
|
103
|
-
|
|
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");
|
|
104
190
|
if (standalone && portal && !hasExecutor)
|
|
105
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");
|
|
106
|
-
const daemon = standalone
|
|
192
|
+
const daemon = standalone
|
|
193
|
+
? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background, combined)
|
|
194
|
+
: undefined;
|
|
107
195
|
if (background)
|
|
108
196
|
checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
|
|
109
197
|
let executor;
|
|
110
|
-
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")) {
|
|
111
200
|
const executorDir = join(dir, "executor-inspection");
|
|
112
201
|
mkdirSync(executorDir);
|
|
113
202
|
await tar.x({ file: join(dir, "package.tgz"), cwd: executorDir });
|
|
@@ -116,37 +205,60 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
116
205
|
executor = inspected.executor;
|
|
117
206
|
if (executor)
|
|
118
207
|
findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
|
|
119
|
-
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)) {
|
|
120
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`);
|
|
121
213
|
}
|
|
122
|
-
if (executor &&
|
|
214
|
+
if (executor &&
|
|
215
|
+
executor.policy.purge !== uninstallHook?.uninstall.purge.root)
|
|
123
216
|
findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH the executor policy purge "${executor.policy.purge ?? ""}" differs from uninstall.purge.root "${uninstallHook?.uninstall.purge.root ?? ""}".`);
|
|
124
|
-
if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
|
|
217
|
+
if (payload.find((p) => p.path === "executor/executor")?.mode !== "0755")
|
|
125
218
|
findings.push("Executor must be executable.");
|
|
126
|
-
if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
|
|
219
|
+
if (payload.find((p) => p.path === "executor/lifecycle")?.mode !== "0755")
|
|
127
220
|
findings.push("Executor lifecycle helper must be executable.");
|
|
128
|
-
if (![...paths].some(path => path.startsWith("daemon/"))) {
|
|
129
|
-
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
|
+
};
|
|
130
230
|
for (const hook of ["preuninst", "preupgrade", "start-stop-status"]) {
|
|
131
|
-
if (readOptional(join(dir, "scripts", hook)) !==
|
|
231
|
+
if (readOptional(join(dir, "scripts", hook)) !==
|
|
232
|
+
renderStandaloneScript(hook, lifecycleManifest))
|
|
132
233
|
findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
|
|
133
234
|
}
|
|
134
235
|
}
|
|
135
|
-
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
|
+
}))) {
|
|
136
242
|
if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
|
|
137
243
|
findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
|
|
138
244
|
}
|
|
139
245
|
}
|
|
140
246
|
if (bridge)
|
|
141
247
|
checkBridgeExecutor(bridge, executor, findings);
|
|
142
|
-
if (background &&
|
|
143
|
-
|
|
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.");
|
|
144
253
|
}
|
|
145
254
|
if (metadata.entry)
|
|
146
255
|
await checkEntryLauncher(dir, info.package, metadata.entry, findings);
|
|
147
|
-
const
|
|
256
|
+
const launch = await checkLaunchConfiguration(dir, info, standalone, metadata.launch, portal?.alias ?? executor?.policy.portal?.alias, findings);
|
|
257
|
+
const loginStyle = standalone
|
|
258
|
+
? await inspectLoginStyle(dir, info.package, Boolean(portal || executor?.policy.portal), findings)
|
|
259
|
+
: undefined;
|
|
148
260
|
if (standalone)
|
|
149
|
-
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope ===
|
|
261
|
+
checkSharedFolder(dir, findings, info.package, sharedFolder, executor?.policy.scope === "install-selected", portal, hasExecutor);
|
|
150
262
|
const icon = inspectPackageIcons(dir, findings);
|
|
151
263
|
const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
|
|
152
264
|
if (standalone && !cgiEntry) {
|
|
@@ -157,14 +269,32 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
157
269
|
}
|
|
158
270
|
return {
|
|
159
271
|
kind,
|
|
272
|
+
...(launch ? { launch } : {}),
|
|
160
273
|
icon,
|
|
161
274
|
...(executor ? { executor } : {}),
|
|
162
|
-
...(portal
|
|
163
|
-
|
|
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
|
+
: {}),
|
|
164
286
|
...(metadata.entry ? { entry: metadata.entry } : {}),
|
|
165
287
|
...(appScripts.length ? { appScripts } : {}),
|
|
166
288
|
...(loginStyle ? { loginStyle } : {}),
|
|
167
|
-
...(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
|
+
: {}),
|
|
168
298
|
package: info.package,
|
|
169
299
|
version: info.version,
|
|
170
300
|
arch: info.arch,
|
|
@@ -172,31 +302,119 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
172
302
|
cgi: cgiEntry?.mode === "0755" ? CGI_PAYLOAD_PATH : undefined,
|
|
173
303
|
payload,
|
|
174
304
|
findings,
|
|
175
|
-
capabilities: (metadata.capabilities ?? []).map(name => ({
|
|
305
|
+
capabilities: (metadata.capabilities ?? []).map((name) => ({
|
|
306
|
+
name,
|
|
307
|
+
requiresAdminApproval: name === "rootOps" || name === "delegate" || name === "webapi",
|
|
308
|
+
})),
|
|
176
309
|
};
|
|
177
310
|
}
|
|
178
311
|
finally {
|
|
179
312
|
rmSync(dir, { recursive: true, force: true });
|
|
180
313
|
}
|
|
181
314
|
}
|
|
315
|
+
const LAUNCHER_TYPE_MISMATCH = "LAUNCHER_TYPE_MISMATCH";
|
|
316
|
+
const LAUNCHER_PORTAL_MISMATCH = "LAUNCHER_PORTAL_MISMATCH";
|
|
317
|
+
/** Compare the actual launcher and Portal fields to the packaged declaration. */
|
|
318
|
+
async function checkLaunchConfiguration(dir, info, native, declared, alias, findings) {
|
|
319
|
+
const target = join(dir, "launch-inspection");
|
|
320
|
+
mkdirSync(target);
|
|
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
|
+
});
|
|
326
|
+
let launch = declared ?? "dsm-window";
|
|
327
|
+
let url;
|
|
328
|
+
if (!native) {
|
|
329
|
+
let contract;
|
|
330
|
+
try {
|
|
331
|
+
contract = loadContract(join(target, "synology-app.yaml")).contract;
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
findings.push(`${LAUNCHER_TYPE_MISMATCH} cannot read the packaged web launch declaration; rebuild with synopkgland build`);
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
const resolved = resolvedWebLaunch(contract);
|
|
338
|
+
if (!resolved) {
|
|
339
|
+
if (contract.adminport !== undefined)
|
|
340
|
+
findings.push(`${LAUNCHER_TYPE_MISMATCH} invalid packaged web launch mode; use dsm-window or new-page and rebuild`);
|
|
341
|
+
return undefined; // A headless service has no launcher.
|
|
342
|
+
}
|
|
343
|
+
launch = resolved;
|
|
344
|
+
url = trustedAppEntryPath(contract);
|
|
345
|
+
try {
|
|
346
|
+
const resource = JSON.parse(readFileSync(join(dir, "conf/resource"), "utf8"));
|
|
347
|
+
const portals = resource.webservice?.portals ?? [];
|
|
348
|
+
if (usesAuthGateway(contract)
|
|
349
|
+
? portals.length > 0
|
|
350
|
+
: portals.some((p) => p.app !== (launch === "dsm-window" ? info.package : undefined))) {
|
|
351
|
+
findings.push(`${LAUNCHER_PORTAL_MISMATCH} Web Station entries differ from the declared access and launch mode; rebuild with synopkgland build`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
/* checkConf reports invalid resources */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const appId = native ? `SYNO.SDS.${info.package}.Instance` : info.package;
|
|
359
|
+
let launcher;
|
|
360
|
+
try {
|
|
361
|
+
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
362
|
+
launcher = Object.values(config)
|
|
363
|
+
.map((entries) => entries[appId])
|
|
364
|
+
.find(Boolean);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
/* report the missing or malformed launcher below */
|
|
368
|
+
}
|
|
369
|
+
const type = dsmLauncherType(launch, native);
|
|
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)) {
|
|
377
|
+
findings.push(`${LAUNCHER_TYPE_MISMATCH} ${launch} requires a ${type} launcher at ${appId} and matching INFO entry; rebuild with synopkgland build`);
|
|
378
|
+
}
|
|
379
|
+
if (launcher &&
|
|
380
|
+
(launcher.defaultAlias !== alias ||
|
|
381
|
+
(!alias &&
|
|
382
|
+
(launcher.loginStyle !== undefined ||
|
|
383
|
+
launcher.loginIcon !== undefined)))) {
|
|
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`);
|
|
385
|
+
}
|
|
386
|
+
return launch;
|
|
387
|
+
}
|
|
182
388
|
/** The launcher in ui/config must carry exactly the access the declared entry mode fixes. */
|
|
183
389
|
async function checkEntryLauncher(dir, pkg, entry, findings) {
|
|
184
390
|
const target = join(dir, "entry-inspection");
|
|
185
391
|
mkdirSync(target);
|
|
186
|
-
await tar.x({
|
|
392
|
+
await tar.x({
|
|
393
|
+
file: join(dir, "package.tgz"),
|
|
394
|
+
cwd: target,
|
|
395
|
+
filter: (path) => path.replace(/^\.\//, "") === "ui/config",
|
|
396
|
+
});
|
|
187
397
|
let launcher;
|
|
188
398
|
try {
|
|
189
399
|
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
190
400
|
launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
|
|
191
401
|
}
|
|
192
|
-
catch {
|
|
402
|
+
catch {
|
|
403
|
+
/* reported below */
|
|
404
|
+
}
|
|
193
405
|
const expected = entryLauncherAccess(entry);
|
|
194
406
|
const fields = ["allUsers", "grantPrivilege", "advanceGrantPrivilege"];
|
|
195
|
-
if (!launcher ||
|
|
407
|
+
if (!launcher ||
|
|
408
|
+
fields.some((field) => launcher[field] !== expected[field])) {
|
|
196
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`);
|
|
197
410
|
}
|
|
198
411
|
}
|
|
199
|
-
const LOGIN_STYLE_FILES = [
|
|
412
|
+
const LOGIN_STYLE_FILES = [
|
|
413
|
+
"defaultLoginWallpaper",
|
|
414
|
+
"defaultLoginWallpaperThumbnail",
|
|
415
|
+
"loginPreviewThumbnailMaskPath",
|
|
416
|
+
"titleImgPath",
|
|
417
|
+
];
|
|
200
418
|
/**
|
|
201
419
|
* The launcher's loginStyle and the ui/ files it names, at 1x and 2x. A portal
|
|
202
420
|
* App without one shows a blank Control Panel > Login Portal thumbnail.
|
|
@@ -204,13 +422,19 @@ const LOGIN_STYLE_FILES = ["defaultLoginWallpaper", "defaultLoginWallpaperThumbn
|
|
|
204
422
|
async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
205
423
|
const target = join(dir, "login-style-inspection");
|
|
206
424
|
mkdirSync(target);
|
|
207
|
-
await tar.x({
|
|
425
|
+
await tar.x({
|
|
426
|
+
file: join(dir, "package.tgz"),
|
|
427
|
+
cwd: target,
|
|
428
|
+
filter: (path) => path.replace(/^\.\//, "").startsWith("ui/"),
|
|
429
|
+
});
|
|
208
430
|
let launcher;
|
|
209
431
|
try {
|
|
210
432
|
const config = JSON.parse(readFileSync(join(target, "ui/config"), "utf8"));
|
|
211
433
|
launcher = Object.values(config)[0]?.[`SYNO.SDS.${pkg}.Instance`];
|
|
212
434
|
}
|
|
213
|
-
catch {
|
|
435
|
+
catch {
|
|
436
|
+
/* reported by the other checks */
|
|
437
|
+
}
|
|
214
438
|
const style = launcher?.loginStyle;
|
|
215
439
|
if (typeof style !== "object" || style === null) {
|
|
216
440
|
if (portal)
|
|
@@ -222,9 +446,12 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
|
222
446
|
const path = fields[field];
|
|
223
447
|
if (path === undefined)
|
|
224
448
|
return undefined;
|
|
225
|
-
const files = typeof path === "string" &&
|
|
226
|
-
|
|
227
|
-
|
|
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))) {
|
|
228
455
|
findings.push(`LOGIN_STYLE_FILE_MISSING ui/config loginStyle.${field} names ui/${String(path)}, which the payload does not carry`);
|
|
229
456
|
return undefined;
|
|
230
457
|
}
|
|
@@ -233,13 +460,22 @@ async function inspectLoginStyle(dir, pkg, portal, findings) {
|
|
|
233
460
|
const [wallpaper] = LOGIN_STYLE_FILES.map(read);
|
|
234
461
|
return {
|
|
235
462
|
color: String(fields.mainColor ?? ""),
|
|
236
|
-
wallpaper: wallpaper && isDefaultLoginAsset("wallpaper.jpg", wallpaper)
|
|
237
|
-
|
|
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",
|
|
238
471
|
};
|
|
239
472
|
}
|
|
240
473
|
/** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
|
|
241
474
|
function inspectPackageIcons(dir, findings) {
|
|
242
|
-
for (const [name, size] of [
|
|
475
|
+
for (const [name, size] of [
|
|
476
|
+
["PACKAGE_ICON.PNG", 64],
|
|
477
|
+
["PACKAGE_ICON_256.PNG", 256],
|
|
478
|
+
]) {
|
|
243
479
|
const bytes = readOptionalBytes(join(dir, name));
|
|
244
480
|
if (!bytes)
|
|
245
481
|
continue;
|
|
@@ -261,7 +497,10 @@ function inspectPackageIcons(dir, findings) {
|
|
|
261
497
|
return { source: "none" };
|
|
262
498
|
return big.equals(defaultPackageIcons().packageIcon256)
|
|
263
499
|
? { source: "default" }
|
|
264
|
-
: {
|
|
500
|
+
: {
|
|
501
|
+
source: "custom",
|
|
502
|
+
sha256: createHash("sha256").update(big).digest("hex"),
|
|
503
|
+
};
|
|
265
504
|
}
|
|
266
505
|
function readOptionalBytes(path) {
|
|
267
506
|
return existsSync(path) ? readFileSync(path) : undefined;
|
|
@@ -272,16 +511,18 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
|
272
511
|
if (info.os_min_ver && !OS_MIN_VER.test(info.os_min_ver)) {
|
|
273
512
|
findings.push(`INFO os_min_ver "${info.os_min_ver}" is not the DSM 7 <version>-<build> (for example 7.1-42661)`);
|
|
274
513
|
}
|
|
275
|
-
else if (kind === "container" &&
|
|
514
|
+
else if (kind === "container" &&
|
|
515
|
+
info.os_min_ver &&
|
|
516
|
+
!isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)) {
|
|
276
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`);
|
|
277
518
|
}
|
|
278
519
|
const tokens = (info.arch ?? "").split(" ").filter(Boolean);
|
|
279
520
|
if (tokens.length === 0 || tokens.includes("noarch"))
|
|
280
521
|
return;
|
|
281
|
-
for (const token of tokens.filter(token => !familyOfArchToken(token))) {
|
|
522
|
+
for (const token of tokens.filter((token) => !familyOfArchToken(token))) {
|
|
282
523
|
findings.push(`INFO arch contains an unknown platform "${token}"`);
|
|
283
524
|
}
|
|
284
|
-
const families = new Set(tokens.map(familyOfArchToken).filter(family => family !== undefined));
|
|
525
|
+
const families = new Set(tokens.map(familyOfArchToken).filter((family) => family !== undefined));
|
|
285
526
|
if (families.size > 1) {
|
|
286
527
|
findings.push(`INFO arch mixes several architectures (${[...families].join(", ")}); an SPK contains binaries for one architecture only`);
|
|
287
528
|
}
|
|
@@ -291,31 +532,224 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
|
291
532
|
const cgi = join(dir, "cgi-inspection");
|
|
292
533
|
mkdirSync(cgi);
|
|
293
534
|
// Only the header is read; the CGI is never run.
|
|
294
|
-
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
|
+
});
|
|
295
540
|
const header = readFileSync(join(cgi, CGI_PAYLOAD_PATH)).subarray(0, 20);
|
|
296
|
-
const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF"
|
|
297
|
-
|
|
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);
|
|
298
545
|
if (actual && actual !== declared) {
|
|
299
546
|
findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
|
|
300
547
|
}
|
|
301
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
|
+
}
|
|
302
716
|
async function recoverCompose(dir, value, findings) {
|
|
303
717
|
try {
|
|
304
|
-
if (!value ||
|
|
305
|
-
|
|
306
|
-
|
|
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");
|
|
307
723
|
mkdirSync(root);
|
|
308
|
-
await tar.x({
|
|
309
|
-
|
|
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
|
+
});
|
|
310
736
|
if (!compose)
|
|
311
|
-
throw new Error(
|
|
312
|
-
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"));
|
|
313
739
|
const upstream = /^http:\/\/127\.0\.0\.1:(\d+)$/.exec(config.upstream)?.[1];
|
|
314
740
|
const listen = config.listen;
|
|
315
|
-
if (!upstream ||
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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");
|
|
319
753
|
return { compose, identityGateway: { listen } };
|
|
320
754
|
}
|
|
321
755
|
catch (e) {
|
|
@@ -324,37 +758,80 @@ async function recoverCompose(dir, value, findings) {
|
|
|
324
758
|
}
|
|
325
759
|
}
|
|
326
760
|
async function checkDaemon(dir, info, paths, payload, findings, executor, background, combined) {
|
|
327
|
-
if (![...paths].some(path => path.startsWith("daemon/")))
|
|
761
|
+
if (![...paths].some((path) => path.startsWith("daemon/")))
|
|
328
762
|
return;
|
|
329
763
|
if (info.precheckstartstop !== "yes")
|
|
330
|
-
findings.push(
|
|
331
|
-
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")
|
|
332
766
|
findings.push("daemon/control is missing or not executable");
|
|
333
767
|
const unpacked = join(dir, "daemon-inspection");
|
|
334
768
|
// Extract only the bounded config; the payload executable is never run.
|
|
335
769
|
const { mkdirSync } = await import("node:fs");
|
|
336
770
|
mkdirSync(unpacked);
|
|
337
|
-
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
|
+
});
|
|
338
776
|
try {
|
|
339
777
|
const config = JSON.parse(readFileSync(join(unpacked, "daemon/config.json"), "utf8"));
|
|
340
|
-
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)))
|
|
341
788
|
throw new Error("package/config mismatch");
|
|
342
|
-
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"], () => { });
|
|
343
797
|
if (!daemon)
|
|
344
798
|
throw new Error("invalid config");
|
|
345
|
-
if (combined &&
|
|
799
|
+
if (combined &&
|
|
800
|
+
(daemon.exec !== "_sso/gateway" ||
|
|
801
|
+
JSON.stringify(daemon.args) !== '["run"]' ||
|
|
802
|
+
daemon.uninstall_args))
|
|
346
803
|
throw new Error("compose gateway must run without a child");
|
|
347
|
-
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")
|
|
348
806
|
throw new Error("missing executable");
|
|
349
|
-
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
|
+
};
|
|
350
816
|
// A config from before timeouts.uninstall pairs with a control that rejects the key.
|
|
351
|
-
const legacy = typeof config.timeouts === "object" &&
|
|
817
|
+
const legacy = typeof config.timeouts === "object" &&
|
|
818
|
+
config.timeouts !== null &&
|
|
819
|
+
!("uninstall" in config.timeouts);
|
|
352
820
|
const { uninstall: _, ...legacyTimeouts } = daemon.timeouts;
|
|
353
|
-
const canonical = daemonConfig(legacy
|
|
821
|
+
const canonical = daemonConfig(legacy
|
|
822
|
+
? {
|
|
823
|
+
...manifest,
|
|
824
|
+
daemon: {
|
|
825
|
+
...daemon,
|
|
826
|
+
timeouts: legacyTimeouts,
|
|
827
|
+
},
|
|
828
|
+
}
|
|
829
|
+
: manifest);
|
|
354
830
|
if (readFileSync(join(unpacked, "daemon/config.json"), "utf8") !== canonical)
|
|
355
831
|
throw new Error("non-canonical daemon config");
|
|
356
832
|
for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
|
|
357
|
-
if (readFileSync(join(dir, "scripts", hook), "utf8") !==
|
|
833
|
+
if (readFileSync(join(dir, "scripts", hook), "utf8") !==
|
|
834
|
+
renderStandaloneScript(hook, manifest))
|
|
358
835
|
findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
|
|
359
836
|
}
|
|
360
837
|
return daemon;
|
|
@@ -380,12 +857,21 @@ function readInfo(path, findings, kind, compose = false) {
|
|
|
380
857
|
findings.push(`INFO is missing ${key}`);
|
|
381
858
|
}
|
|
382
859
|
}
|
|
383
|
-
if (kind !== "container" &&
|
|
384
|
-
|
|
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>/');
|
|
385
863
|
}
|
|
386
864
|
// A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
|
|
387
|
-
const allowed = compose
|
|
388
|
-
|
|
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)))) {
|
|
389
875
|
findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
|
|
390
876
|
}
|
|
391
877
|
return info;
|
|
@@ -394,9 +880,15 @@ function readInfo(path, findings, kind, compose = false) {
|
|
|
394
880
|
async function checkContainerExposure(dir, packageTgz, findings) {
|
|
395
881
|
const extracted = join(dir, "payload");
|
|
396
882
|
mkdirSync(extracted);
|
|
397
|
-
await tar.x({
|
|
883
|
+
await tar.x({
|
|
884
|
+
file: packageTgz,
|
|
885
|
+
cwd: extracted,
|
|
886
|
+
filter: (path) => path.replace(/^\.\//, "") === "compose.yaml",
|
|
887
|
+
});
|
|
398
888
|
const compose = loadCompose(join(extracted, "compose.yaml"))?.compose;
|
|
399
|
-
for (const exposure of compose
|
|
889
|
+
for (const exposure of compose
|
|
890
|
+
? listPortExposures(compose).filter((e) => !e.loopbackOnly)
|
|
891
|
+
: []) {
|
|
400
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`);
|
|
401
893
|
}
|
|
402
894
|
try {
|
|
@@ -404,7 +896,9 @@ async function checkContainerExposure(dir, packageTgz, findings) {
|
|
|
404
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)");
|
|
405
897
|
}
|
|
406
898
|
}
|
|
407
|
-
catch {
|
|
899
|
+
catch {
|
|
900
|
+
/* checkConf already reports an unreadable conf/resource */
|
|
901
|
+
}
|
|
408
902
|
}
|
|
409
903
|
function readOptional(path) {
|
|
410
904
|
return existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
@@ -412,12 +906,25 @@ function readOptional(path) {
|
|
|
412
906
|
function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, compose) {
|
|
413
907
|
const confDir = join(dir, "conf");
|
|
414
908
|
const entries = existsSync(confDir) ? readdirSync(confDir) : [];
|
|
415
|
-
const nativeResource = kind !== "container"
|
|
416
|
-
|
|
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) {
|
|
417
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)`);
|
|
418
922
|
}
|
|
419
923
|
for (const name of entries) {
|
|
420
|
-
if (name !== "privilege" &&
|
|
924
|
+
if (name !== "privilege" &&
|
|
925
|
+
!(kind === "container" && name === "resource") &&
|
|
926
|
+
!(nativeResource !== undefined && name === "resource") &&
|
|
927
|
+
!(background && name === "systemd")) {
|
|
421
928
|
findings.push(`conf/${name} registers an extra resource with DSM; a Standalone Package does not produce it`);
|
|
422
929
|
}
|
|
423
930
|
}
|
|
@@ -449,7 +956,7 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
|
|
|
449
956
|
}
|
|
450
957
|
const defaults = privilege.defaults;
|
|
451
958
|
if (defaults?.["run-as"] !== "package") {
|
|
452
|
-
findings.push(
|
|
959
|
+
findings.push('conf/privilege defaults.run-as must be "package"');
|
|
453
960
|
}
|
|
454
961
|
for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
|
|
455
962
|
if (key in privilege) {
|
|
@@ -462,17 +969,32 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
|
|
|
462
969
|
const pkg = info.package;
|
|
463
970
|
if (!pkg)
|
|
464
971
|
return;
|
|
465
|
-
const read = (path) => existsSync(join(dir, path))
|
|
466
|
-
|
|
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")
|
|
467
976
|
findings.push(`${BACKGROUND_WORKER_PAYLOAD} is missing or not executable`);
|
|
468
977
|
if (info.precheckstartstop !== "yes")
|
|
469
|
-
findings.push(
|
|
470
|
-
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
|
+
: [];
|
|
471
982
|
const unit = backgroundUnitName(pkg);
|
|
472
|
-
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 : [])) {
|
|
473
987
|
findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
|
|
474
988
|
}
|
|
475
|
-
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
|
+
}))
|
|
476
998
|
findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
|
|
477
999
|
}
|
|
478
1000
|
/**
|
|
@@ -483,42 +1005,77 @@ function checkBackground(dir, info, payload, findings, executor, daemon, bridge
|
|
|
483
1005
|
*/
|
|
484
1006
|
function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal, executor = false) {
|
|
485
1007
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
486
|
-
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
|
|
1008
|
+
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : [])
|
|
1009
|
+
.filter((name) => /^(install|upgrade)_uifile(_|\.|$)/.test(name))
|
|
1010
|
+
.sort();
|
|
487
1011
|
const postinst = readOptional(join(dir, "scripts/postinst"));
|
|
488
1012
|
if (!folder && postinst?.includes(SHARED_FOLDER_MARKER))
|
|
489
1013
|
findings.push("the install.sharedFolder declaration in scripts/postinst is invalid");
|
|
490
1014
|
if (!pkg)
|
|
491
1015
|
return;
|
|
492
1016
|
const wizards = expectedWizardFiles(pkg, folder, selected, portal);
|
|
493
|
-
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";
|
|
494
1024
|
// The default native wizard is optional; a declared shared folder or portal needs all of its files.
|
|
495
|
-
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)) {
|
|
496
1028
|
if (readOptional(join(wizardDir, name)) !== wizards[name])
|
|
497
1029
|
findings.push(`WIZARD_UIFILES/${name} does not match ${source}`);
|
|
498
1030
|
}
|
|
499
|
-
for (const name of present.filter(n => !(n in wizards)))
|
|
1031
|
+
for (const name of present.filter((n) => !(n in wizards)))
|
|
500
1032
|
findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
|
|
501
1033
|
if (selected) {
|
|
502
|
-
for (const hook of [
|
|
503
|
-
if (!readOptional(join(dir,
|
|
1034
|
+
for (const hook of ["preinst", "postinst"]) {
|
|
1035
|
+
if (!readOptional(join(dir, "scripts", hook))?.includes(renderDelegateInstallDraft(pkg, hook === "preinst")))
|
|
504
1036
|
findings.push(`scripts/${hook} missing install-selected draft handling`);
|
|
505
1037
|
}
|
|
506
1038
|
}
|
|
507
1039
|
if (!folder)
|
|
508
1040
|
return;
|
|
509
1041
|
for (const hook of ["postinst", "postupgrade"]) {
|
|
510
|
-
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
|
+
})) {
|
|
511
1053
|
findings.push(`scripts/${hook} does not match the fixed script declared by install.sharedFolder`);
|
|
512
1054
|
}
|
|
513
1055
|
}
|
|
514
1056
|
}
|
|
515
1057
|
function expectedWizardFiles(pkg, folder, selected = false, portal) {
|
|
516
|
-
const steps = selected
|
|
517
|
-
|
|
518
|
-
|
|
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
|
+
};
|
|
519
1074
|
}
|
|
520
1075
|
function portalManifest(portal) {
|
|
521
|
-
return portal
|
|
1076
|
+
return portal
|
|
1077
|
+
? { displayname: portal.application, portal: { alias: portal.alias } }
|
|
1078
|
+
: {};
|
|
522
1079
|
}
|
|
523
1080
|
/**
|
|
524
1081
|
* The portal declaration is recovered from the payload's etc/portal.json. Its
|
|
@@ -527,20 +1084,26 @@ function portalManifest(portal) {
|
|
|
527
1084
|
*/
|
|
528
1085
|
async function recoverPortal(dir, info, appId, paths, payload, findings) {
|
|
529
1086
|
const files = [PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH];
|
|
530
|
-
if (!files.some(path => paths.has(path)))
|
|
1087
|
+
if (!files.some((path) => paths.has(path)))
|
|
531
1088
|
return;
|
|
532
1089
|
const unpacked = join(dir, "portal-inspection");
|
|
533
1090
|
mkdirSync(unpacked);
|
|
534
|
-
await tar.x({
|
|
1091
|
+
await tar.x({
|
|
1092
|
+
file: join(dir, "package.tgz"),
|
|
1093
|
+
cwd: unpacked,
|
|
1094
|
+
filter: (path) => files.includes(path.replace(/^\.\//, "")),
|
|
1095
|
+
});
|
|
535
1096
|
try {
|
|
536
|
-
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"))
|
|
537
1098
|
throw new Error("mode");
|
|
538
1099
|
const json = readFileSync(join(unpacked, PORTAL_JSON_RELPATH), "utf8");
|
|
539
1100
|
const alias = JSON.parse(json).alias;
|
|
540
1101
|
if (!isPortalAlias(alias))
|
|
541
1102
|
throw new Error("alias");
|
|
542
1103
|
const portal = { appId, application: info.displayname ?? "", alias };
|
|
543
|
-
if (json !== renderPortalJson(portal) ||
|
|
1104
|
+
if (json !== renderPortalJson(portal) ||
|
|
1105
|
+
readFileSync(join(unpacked, PORTAL_MUSTACHE_RELPATH), "utf8") !==
|
|
1106
|
+
renderPortalMustache())
|
|
544
1107
|
throw new Error("content");
|
|
545
1108
|
for (const hook of ["preinst", "postinst", "postupgrade"]) {
|
|
546
1109
|
if (!readOptional(join(dir, "scripts", hook))?.startsWith(`#!/bin/sh\n${renderPortalLifecycle(hook, portal)}`))
|
|
@@ -576,7 +1139,10 @@ const UPGRADE_GUARD = /SYNOPKG_PKG_STATUS[^\n]*!=\s*"?UPGRADE"?/;
|
|
|
576
1139
|
function uninstallBoxes(file, keys) {
|
|
577
1140
|
try {
|
|
578
1141
|
const steps = JSON.parse(readFileSync(file, "utf8"));
|
|
579
|
-
return steps
|
|
1142
|
+
return steps
|
|
1143
|
+
.flatMap((step) => step.items ?? [])
|
|
1144
|
+
.flatMap((item) => item.subitems ?? [])
|
|
1145
|
+
.filter((box) => keys.includes(box.key));
|
|
580
1146
|
}
|
|
581
1147
|
catch {
|
|
582
1148
|
return [];
|
|
@@ -593,7 +1159,16 @@ function uninstallWizardOptions(hook) {
|
|
|
593
1159
|
if (!hook)
|
|
594
1160
|
return {};
|
|
595
1161
|
const { uninstall, catalogs } = hook;
|
|
596
|
-
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
|
+
};
|
|
597
1172
|
}
|
|
598
1173
|
function checkUninstall(dir, findings) {
|
|
599
1174
|
const script = join(dir, "scripts", "postuninst");
|
|
@@ -601,20 +1176,31 @@ function checkUninstall(dir, findings) {
|
|
|
601
1176
|
return;
|
|
602
1177
|
const body = readFileSync(script, "utf8");
|
|
603
1178
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
604
|
-
const wizards = existsSync(wizardDir)
|
|
605
|
-
|
|
606
|
-
|
|
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);
|
|
607
1192
|
const deletes = DELETE_COMMAND.test(body);
|
|
608
1193
|
if (deletes) {
|
|
609
1194
|
if (!UPGRADE_GUARD.test(body))
|
|
610
|
-
findings.push(
|
|
611
|
-
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))
|
|
612
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");
|
|
613
|
-
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))) {
|
|
614
1200
|
for (const path of line.match(/\/volume\w*\/(?!@app(data|home|conf|store)\/)[^\s"'$;]*/g) ?? [])
|
|
615
1201
|
findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst deletes ${path}, which is not inside the package's own @app* directory`);
|
|
616
1202
|
}
|
|
617
|
-
if (!boxes.some(box => box.name === "uninstall_uifile"))
|
|
1203
|
+
if (!boxes.some((box) => box.name === "uninstall_uifile"))
|
|
618
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");
|
|
619
1205
|
}
|
|
620
1206
|
for (const box of boxes) {
|