@synopackageland/cli 0.9.7 → 0.10.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 +146 -0
- package/README.md +5 -0
- package/dist/add.js +7 -8
- package/dist/build-spk/dsm-arch.js +1 -1
- package/dist/build-spk/dsm-icons.d.ts +8 -1
- package/dist/build-spk/dsm-icons.js +26 -12
- package/dist/build-spk/dsm-version.d.ts +1 -1
- package/dist/build-spk/dsm-version.js +4 -4
- package/dist/build-spk/gateway.js +2 -2
- package/dist/build-spk/generator.js +1 -1
- package/dist/build-spk/png.js +16 -16
- package/dist/capabilities.js +1 -1
- package/dist/capability-overlays.d.ts +16 -5
- package/dist/capability-overlays.js +30 -16
- package/dist/cli.js +107 -107
- package/dist/compose/analyze.js +23 -23
- package/dist/compose/privilege.js +12 -12
- package/dist/contract/i18n.js +3 -3
- package/dist/contract/icon.js +2 -2
- package/dist/contract/posix-all-shares.js +2 -2
- package/dist/contract/schema-validate.js +3 -3
- package/dist/contract/validate.js +16 -16
- package/dist/contract/web-launch.d.ts +2 -1
- package/dist/contract/web-launch.js +3 -2
- package/dist/deploy/deploy.js +8 -8
- package/dist/deploy/homes-values.js +1 -1
- package/dist/deploy/identity-shared.js +1 -1
- package/dist/deploy/native-install-values.js +1 -1
- package/dist/deploy/param-values.js +4 -4
- package/dist/deploy/plan.d.ts +1 -1
- package/dist/deploy/port-preflight.js +3 -3
- package/dist/deploy/run-as-values.js +2 -2
- package/dist/deploy/share-permission-preflight.js +6 -6
- package/dist/deploy/share-values.js +3 -3
- package/dist/error-registry.js +369 -346
- package/dist/errors.js +1 -1
- package/dist/host/dsm-port-profile.d.ts +1 -1
- package/dist/host/dsm-port-profile.js +1 -1
- package/dist/info.js +11 -11
- package/dist/init.d.ts +2 -0
- package/dist/init.js +85 -38
- package/dist/inspect.js +5 -5
- package/dist/portal/alias.d.ts +1 -1
- package/dist/portal/alias.js +4 -4
- package/dist/runtime/executor-build.js +12 -1
- package/dist/runtime/executor-digests.json +14 -91
- package/dist/runtime/executor-inspect.js +11 -4
- package/dist/runtime/executor-policy.d.ts +9 -2
- package/dist/runtime/executor-policy.js +27 -8
- package/dist/runtime/root-programs.d.ts +47 -0
- package/dist/runtime/root-programs.js +153 -0
- package/dist/runtime/sdk-source.d.ts +5 -4
- package/dist/runtime/sdk-source.js +8 -4
- package/dist/skill.js +14 -14
- package/dist/spk/check-info.js +17 -17
- package/dist/spk/check-metadata.js +18 -18
- package/dist/spk/check-payload.js +10 -10
- package/dist/spk/rules.d.ts +57 -57
- package/dist/spk/rules.js +50 -50
- package/dist/spk/validate-spk.js +5 -5
- package/dist/standalone/api-permissions.js +1 -1
- package/dist/standalone/background.d.ts +3 -1
- package/dist/standalone/background.js +8 -4
- package/dist/standalone/binary.js +7 -7
- package/dist/standalone/bridge-app.d.ts +23 -0
- package/dist/standalone/bridge-app.js +141 -0
- package/dist/standalone/bridge-backend.d.ts +40 -0
- package/dist/standalone/bridge-backend.js +95 -0
- package/dist/standalone/bridge-build.d.ts +21 -0
- package/dist/standalone/bridge-build.js +62 -0
- package/dist/standalone/bridge-json.d.ts +16 -0
- package/dist/standalone/bridge-json.js +59 -0
- package/dist/standalone/bridge-runtime.d.ts +16 -0
- package/dist/standalone/bridge-runtime.js +46 -0
- package/dist/standalone/bridge-rust.d.ts +16 -0
- package/dist/standalone/bridge-rust.js +89 -0
- package/dist/standalone/build.js +18 -14
- package/dist/standalone/daemon.js +6 -6
- package/dist/standalone/dsm-window.d.ts +10 -5
- package/dist/standalone/dsm-window.js +69 -18
- package/dist/standalone/i18n.js +18 -18
- package/dist/standalone/inspect-bridge.d.ts +31 -0
- package/dist/standalone/inspect-bridge.js +111 -0
- package/dist/standalone/inspect-spk.d.ts +1 -1
- package/dist/standalone/inspect-spk.js +77 -69
- package/dist/standalone/lifecycle.js +22 -3
- package/dist/standalone/manifest.d.ts +17 -9
- package/dist/standalone/manifest.js +143 -65
- package/dist/standalone/portal-window.js +14 -7
- package/dist/standalone/request-timeout.d.ts +9 -0
- package/dist/standalone/request-timeout.js +13 -0
- package/dist/standalone/root-ops.d.ts +23 -2
- package/dist/standalone/root-ops.js +118 -13
- package/dist/standalone/sdk-check.js +4 -4
- package/dist/standalone/shared-folder.js +7 -7
- package/dist/standalone/spk.d.ts +2 -0
- package/dist/standalone/spk.js +30 -11
- package/dist/standalone/uninstall-hook.js +22 -4
- package/dist/standalone/uninstall.js +7 -7
- package/dist/standalone/window-size.d.ts +17 -0
- package/dist/standalone/window-size.js +31 -0
- package/dist/types.d.ts +1 -1
- package/dist/validate.js +13 -13
- package/package.json +2 -2
- package/schema/bridge-manifest.v1.schema.json +86 -0
- package/skills/reference/create.md +76 -2
- package/skills/reference/ui-json.md +2 -1
- package/skills/reference/ui-vue.md +9 -3
- package/skills/reference/wrap.md +1 -1
- package/dist/standalone/rust-build.d.ts +0 -35
- package/dist/standalone/rust-build.js +0 -127
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import * as tar from "tar";
|
|
5
|
+
import { familyOfArchToken, NATIVE_ARCH_FAMILIES } from "../build-spk/dsm-arch.js";
|
|
6
|
+
import { ELF_MACHINE } from "./binary.js";
|
|
7
|
+
import { NODE_SDK_PACKAGE } from "./bridge-app.js";
|
|
8
|
+
import { BRIDGE_JSON, bridgeManifestProblems, PYTHON_VENDOR_DIR } from "./bridge-json.js";
|
|
9
|
+
import { CGI_PAYLOAD_PATH } from "./dsm-window.js";
|
|
10
|
+
/** Files that belong in target/app only: DSM serves everything under ui/ to the browser. */
|
|
11
|
+
const APP_ONLY = [`/${BRIDGE_JSON}`, "/permissions.json", `/${PYTHON_VENDOR_DIR}/synopkgland_app/`, `/node_modules/${NODE_SDK_PACKAGE}/`];
|
|
12
|
+
/**
|
|
13
|
+
* A Python, Node or Rust App: the runtime CGI binds bridge.json and
|
|
14
|
+
* permissions.json by digest, the App lives in target/app (never under ui/),
|
|
15
|
+
* and its entry, SDK and dependencies are in the package.
|
|
16
|
+
*/
|
|
17
|
+
export async function checkBridgeApp(dir, backend, info, payload, findings) {
|
|
18
|
+
const paths = new Set(payload.map(entry => entry.path));
|
|
19
|
+
for (const path of payload.map(entry => `/${entry.path}`).filter(path => path.startsWith("/ui/") && APP_ONLY.some(name => path.includes(name)))) {
|
|
20
|
+
findings.push(`BRIDGE_APP_IN_UI ${path.slice(1)} is an App file and must not be placed in ui/ (DSM serves it directly to the browser)`);
|
|
21
|
+
}
|
|
22
|
+
const target = join(dir, "bridge-inspection");
|
|
23
|
+
mkdirSync(target);
|
|
24
|
+
// Only read, never run.
|
|
25
|
+
await tar.x({ file: join(dir, "package.tgz"), cwd: target, filter: path => { const p = path.replace(/^\.\//, ""); return p.startsWith("app/") || p === CGI_PAYLOAD_PATH; } });
|
|
26
|
+
const app = join(target, "app");
|
|
27
|
+
const text = existsSync(join(app, BRIDGE_JSON)) ? readFileSync(join(app, BRIDGE_JSON), "utf8") : undefined;
|
|
28
|
+
const problems = text === undefined ? [`app/${BRIDGE_JSON} is missing`] : bridgeManifestProblems(text);
|
|
29
|
+
if (problems.length) {
|
|
30
|
+
findings.push(...problems.map(problem => `BRIDGE_MANIFEST_INVALID ${problem}`));
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const bridge = JSON.parse(text);
|
|
34
|
+
if (bridge.runtime !== backend)
|
|
35
|
+
findings.push(`BRIDGE_MANIFEST_INVALID the runtime "${bridge.runtime}" in app/${BRIDGE_JSON} differs from the backend "${backend}" in sdk-package.json`);
|
|
36
|
+
checkEntry(app, bridge, info, payload, findings);
|
|
37
|
+
const permissions = existsSync(join(app, "permissions.json")) ? readFileSync(join(app, "permissions.json")) : undefined;
|
|
38
|
+
if (!permissions)
|
|
39
|
+
findings.push("BRIDGE_PERMISSIONS app/permissions.json is missing");
|
|
40
|
+
else
|
|
41
|
+
checkPermissions(permissions, bridge, findings);
|
|
42
|
+
const cgi = existsSync(join(target, CGI_PAYLOAD_PATH)) ? readFileSync(join(target, CGI_PAYLOAD_PATH)) : Buffer.alloc(0);
|
|
43
|
+
for (const [name, bytes] of [[BRIDGE_JSON, Buffer.from(text)], ["permissions.json", permissions]]) {
|
|
44
|
+
if (bytes && !cgi.includes(createHash("sha256").update(bytes).digest("hex"))) {
|
|
45
|
+
findings.push(`BRIDGE_DIGEST_MISMATCH the sha256 bound to ${CGI_PAYLOAD_PATH} is not that of app/${name}; the runtime refuses to run it, rebuild it with synopkgland build`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (backend === "python" && !paths.has(`app/${PYTHON_VENDOR_DIR}/synopkgland_app/__init__.py`))
|
|
49
|
+
findings.push(`BRIDGE_PYTHON_SDK app/${PYTHON_VENDOR_DIR}/synopkgland_app/ is missing`);
|
|
50
|
+
if (backend === "node")
|
|
51
|
+
checkNode(app, bridge, info, findings);
|
|
52
|
+
return bridge;
|
|
53
|
+
}
|
|
54
|
+
/** root and delegate reach the package's executor; delegate pins the one this SPK ships. */
|
|
55
|
+
export function checkBridgeExecutor(bridge, executor, findings) {
|
|
56
|
+
const { root, delegate } = bridge.capabilities ?? {};
|
|
57
|
+
if ((root || delegate) && !executor)
|
|
58
|
+
findings.push("BRIDGE_EXECUTOR_MISSING app/bridge.json declares root or delegate, but the SPK has no executor");
|
|
59
|
+
if (delegate && executor && (delegate.executor_digest !== executor.executorDigest || delegate.policy_digest !== executor.policyDigest)) {
|
|
60
|
+
findings.push("BRIDGE_EXECUTOR_MISMATCH the delegate executor_digest/policy_digest in app/bridge.json differs from the SPK executor");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The entry exists; a Rust entry is an executable of the SPK's architecture. */
|
|
64
|
+
function checkEntry(app, bridge, info, payload, findings) {
|
|
65
|
+
const entry = payload.find(file => file.path === `app/${bridge.entry}`);
|
|
66
|
+
if (!entry) {
|
|
67
|
+
findings.push(`BRIDGE_ENTRY_MISSING app/${bridge.entry} is missing`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (bridge.runtime !== "rust")
|
|
71
|
+
return;
|
|
72
|
+
if (entry.mode !== "0755")
|
|
73
|
+
findings.push(`BRIDGE_ENTRY_MISSING app/${bridge.entry} has mode ${entry.mode}; it should be 0755`);
|
|
74
|
+
const header = readFileSync(join(app, bridge.entry)).subarray(0, 20);
|
|
75
|
+
const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF" ? header.readUInt16LE(18) : undefined;
|
|
76
|
+
const actual = NATIVE_ARCH_FAMILIES.find(family => ELF_MACHINE[family] === machine);
|
|
77
|
+
const declared = familyOfArchToken((info.arch ?? "").split(" ")[0] ?? "");
|
|
78
|
+
if (!actual || (declared && actual !== declared))
|
|
79
|
+
findings.push(`BRIDGE_ENTRY_ARCH app/${bridge.entry} is not an executable for INFO arch (${declared ?? info.arch})`);
|
|
80
|
+
}
|
|
81
|
+
/** The runtime refuses to start when permissions.json sets a method the manifest does not register. */
|
|
82
|
+
function checkPermissions(bytes, bridge, findings) {
|
|
83
|
+
let methods;
|
|
84
|
+
try {
|
|
85
|
+
methods = Object.keys(JSON.parse(bytes.toString("utf8")).methods ?? {});
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
findings.push("BRIDGE_PERMISSIONS app/permissions.json is not JSON");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const unlisted = methods.filter(method => !bridge.methods?.includes(method));
|
|
92
|
+
if (unlisted.length)
|
|
93
|
+
findings.push(`BRIDGE_PERMISSIONS app/permissions.json sets methods bridge.json does not have: ${unlisted.join(", ")}`);
|
|
94
|
+
}
|
|
95
|
+
/** The Node.js package is an install dependency, and every dependency is in node_modules. */
|
|
96
|
+
function checkNode(app, bridge, info, findings) {
|
|
97
|
+
const node = bridge.node?.package;
|
|
98
|
+
const deps = (info.install_dep_packages ?? "").split(":").map(dep => dep.replace(/[><=].*$/, ""));
|
|
99
|
+
if (!node || !deps.includes(node))
|
|
100
|
+
findings.push(`BRIDGE_NODE_DEPENDENCY INFO install_dep_packages must contain the node.package from bridge.json "${node ?? ""}"`);
|
|
101
|
+
const missing = [NODE_SDK_PACKAGE];
|
|
102
|
+
if (existsSync(join(app, "package.json"))) {
|
|
103
|
+
try {
|
|
104
|
+
missing.push(...Object.keys(JSON.parse(readFileSync(join(app, "package.json"), "utf8")).dependencies ?? {}));
|
|
105
|
+
}
|
|
106
|
+
catch { /* reported as missing below */ }
|
|
107
|
+
}
|
|
108
|
+
const absent = [...new Set(missing)].filter(name => !existsSync(join(app, "node_modules", name, "package.json")));
|
|
109
|
+
if (absent.length)
|
|
110
|
+
findings.push(`BRIDGE_NODE_MODULES app/node_modules is missing ${absent.join(", ")}`);
|
|
111
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ExecutorInspection } from "../runtime/executor-inspect.js";
|
|
2
2
|
import type { EntryMode } from "./manifest.js";
|
|
3
3
|
import { type UninstallCleanupItem } from "../runtime/uninstall-cleanup.js";
|
|
4
|
-
export type SpkKind = "native-go" | "native-rust" | "binary" | "container" | "native-layout";
|
|
4
|
+
export type SpkKind = "native-go" | "native-python" | "native-node" | "native-rust" | "binary" | "container" | "native-layout";
|
|
5
5
|
export interface SpkInspection {
|
|
6
6
|
kind?: SpkKind;
|
|
7
7
|
package?: string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { inspectNativeLayout } from "./native-layout.js";
|
|
3
|
+
import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
|
|
3
4
|
import { inspectExecutor, inspectExecutorAnchor } from "../runtime/executor-inspect.js";
|
|
4
5
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
5
6
|
import { tmpdir } from "node:os";
|
|
@@ -13,7 +14,7 @@ import { daemonConfig, readDaemon } from "./daemon.js";
|
|
|
13
14
|
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
14
15
|
import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
|
|
15
16
|
import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
|
|
16
|
-
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
|
|
17
|
+
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, BRIDGE_WORKER_ARGS, backgroundUnitName, renderBackgroundUnit } from "./background.js";
|
|
17
18
|
import { renderStandaloneResource } from "./resource.js";
|
|
18
19
|
import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
|
|
19
20
|
import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
|
|
@@ -26,6 +27,8 @@ import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderP
|
|
|
26
27
|
import { isPortalAlias } from "../portal/alias.js";
|
|
27
28
|
import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
|
|
28
29
|
import { decodePng } from "../build-spk/png.js";
|
|
30
|
+
/** 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. */
|
|
31
|
+
const COMPILED_KIND = { go: "native-go", python: "native-python", node: "native-node", rust: "native-rust" };
|
|
29
32
|
/** Reads an SPK without installing it and reports how it would run on DSM. */
|
|
30
33
|
export async function inspectStandaloneSpk(spkPath) {
|
|
31
34
|
return inspectSpk(spkPath, "native-go");
|
|
@@ -57,22 +60,22 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
57
60
|
if (metadataText !== undefined) {
|
|
58
61
|
try {
|
|
59
62
|
const raw = JSON.parse(metadataText);
|
|
60
|
-
if (raw.schema !== 2 || !["go", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
|
|
63
|
+
if (raw.schema !== 2 || !["go", "python", "node", "rust", "binary"].includes(raw.backend) || !Array.isArray(raw.capabilities) || raw.capabilities.some((name) => !["files", "jobs", "identity", "rootOps", "delegate"].includes(String(name))) || new Set(raw.capabilities).size !== raw.capabilities.length || (raw.entry !== undefined && !["admin-only", "app-privilege"].includes(raw.entry)))
|
|
61
64
|
throw new Error();
|
|
62
65
|
metadata = raw;
|
|
63
66
|
}
|
|
64
67
|
catch {
|
|
65
|
-
findings.push("sdk-package.json
|
|
68
|
+
findings.push("sdk-package.json does not match the schema 2 capability inventory.");
|
|
66
69
|
}
|
|
67
70
|
}
|
|
68
71
|
const uninstallHook = metadata.uninstall === undefined ? undefined : readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
|
|
69
72
|
if (metadata.uninstall !== undefined && !uninstallHook)
|
|
70
|
-
findings.push("UNINSTALL_RECORD_INVALID sdk-package.json
|
|
73
|
+
findings.push("UNINSTALL_RECORD_INVALID the uninstall in sdk-package.json is not the uninstall setting synopkgland build writes.");
|
|
71
74
|
const binaryBackend = metadata.backend === "binary";
|
|
72
75
|
// A strict standalone inspection names the language the CGI was built from.
|
|
73
|
-
const compiled = metadata.backend
|
|
76
|
+
const compiled = COMPILED_KIND[metadata.backend ?? "go"] ?? "native-go";
|
|
74
77
|
const kind = (expectedKind === "native-go" ? compiled : expectedKind) ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : compiled);
|
|
75
|
-
const compiledKind = kind
|
|
78
|
+
const compiledKind = Object.values(COMPILED_KIND).includes(kind);
|
|
76
79
|
const standalone = compiledKind || kind === "binary";
|
|
77
80
|
const info = readInfo(join(dir, "INFO"), findings, kind);
|
|
78
81
|
await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
|
|
@@ -81,19 +84,20 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
81
84
|
// A Compose App has no executor, so its SPK never carries a portal.
|
|
82
85
|
const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
|
|
83
86
|
if (portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_MIN_DSM)) {
|
|
84
|
-
findings.push(`INFO os_min_ver
|
|
87
|
+
findings.push(`INFO os_min_ver "${info.os_min_ver}" is below the DSM ${PORTAL_MIN_DSM} a portal needs; ${PORTAL_MIN_DSM_REASON}`);
|
|
85
88
|
}
|
|
86
89
|
checkConf(dir, findings, kind, background, sharedFolder, info.package, portal);
|
|
87
90
|
checkScripts(dir, findings);
|
|
88
91
|
checkUninstall(dir, findings);
|
|
89
92
|
if (kind === "container")
|
|
90
93
|
await checkContainerExposure(dir, join(dir, "package.tgz"), findings);
|
|
94
|
+
const bridge = compiledKind && kind !== "native-go" ? await checkBridgeApp(dir, metadata.backend, info, payload, findings) : undefined;
|
|
91
95
|
const hasExecutor = (metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate") || paths.has("executor/executor");
|
|
92
96
|
if (standalone && portal && !hasExecutor)
|
|
93
|
-
findings.push("PORTAL_REQUIRES_EXECUTOR portal
|
|
97
|
+
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");
|
|
94
98
|
const daemon = standalone ? await checkDaemon(dir, info, paths, payload, findings, hasExecutor, background) : undefined;
|
|
95
99
|
if (background)
|
|
96
|
-
checkBackground(dir, info, payload, findings, hasExecutor, daemon);
|
|
100
|
+
checkBackground(dir, info, payload, findings, hasExecutor, daemon, kind !== "native-go");
|
|
97
101
|
let executor;
|
|
98
102
|
if ((metadata.capabilities ?? []).some(name => name === "rootOps" || name === "delegate") || paths.has("executor/executor")) {
|
|
99
103
|
const executorDir = join(dir, "executor-inspection");
|
|
@@ -105,10 +109,10 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
105
109
|
if (executor)
|
|
106
110
|
findings.push(...inspectExecutorAnchor(readFileSync(join(dir, "INFO"), "utf8"), executor.executorDigest));
|
|
107
111
|
if (executor?.policy.portal && info.os_min_ver && OS_MIN_VER.test(info.os_min_ver) && !isDsmAtLeast(info.os_min_ver, PORTAL_EXECUTOR_MIN_DSM)) {
|
|
108
|
-
findings.push(`INFO os_min_ver
|
|
112
|
+
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`);
|
|
109
113
|
}
|
|
110
114
|
if (executor && executor.policy.purge !== uninstallHook?.uninstall.purge.root)
|
|
111
|
-
findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH executor policy
|
|
115
|
+
findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH the executor policy purge "${executor.policy.purge ?? ""}" differs from uninstall.purge.root "${uninstallHook?.uninstall.purge.root ?? ""}".`);
|
|
112
116
|
if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
|
|
113
117
|
findings.push("Executor must be executable.");
|
|
114
118
|
if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
|
|
@@ -125,6 +129,8 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
125
129
|
findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
|
|
126
130
|
}
|
|
127
131
|
}
|
|
132
|
+
if (bridge)
|
|
133
|
+
checkBridgeExecutor(bridge, executor, findings);
|
|
128
134
|
if (background && daemon && (executor?.policy.lifetime !== 'until-revoked' || !metadata.capabilities?.includes('delegate'))) {
|
|
129
135
|
findings.push('daemon/background combination requires an explicitly pinned until-revoked delegate policy.');
|
|
130
136
|
}
|
|
@@ -135,10 +141,10 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
135
141
|
const icon = inspectPackageIcons(dir, findings);
|
|
136
142
|
const cgiEntry = payload.find((entry) => entry.path === CGI_PAYLOAD_PATH);
|
|
137
143
|
if (standalone && !cgiEntry) {
|
|
138
|
-
findings.push(`payload
|
|
144
|
+
findings.push(`the payload has no ${CGI_PAYLOAD_PATH}`);
|
|
139
145
|
}
|
|
140
146
|
else if (cgiEntry && cgiEntry.mode !== "0755") {
|
|
141
|
-
findings.push(`${CGI_PAYLOAD_PATH}
|
|
147
|
+
findings.push(`${CGI_PAYLOAD_PATH} has mode ${cgiEntry.mode}; it should be 0755`);
|
|
142
148
|
}
|
|
143
149
|
return {
|
|
144
150
|
kind,
|
|
@@ -176,7 +182,7 @@ async function checkEntryLauncher(dir, pkg, entry, findings) {
|
|
|
176
182
|
const expected = entryLauncherAccess(entry);
|
|
177
183
|
const fields = ["allUsers", "grantPrivilege", "advanceGrantPrivilege"];
|
|
178
184
|
if (!launcher || fields.some(field => launcher[field] !== expected[field])) {
|
|
179
|
-
findings.push(`ENTRY_LAUNCHER_MISMATCH sdk-package.json
|
|
185
|
+
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`);
|
|
180
186
|
}
|
|
181
187
|
}
|
|
182
188
|
/** Package Center shows PACKAGE_ICON at 64 and 256 px; the package source refuses a release whose icon is over 256 KiB. */
|
|
@@ -186,16 +192,16 @@ function inspectPackageIcons(dir, findings) {
|
|
|
186
192
|
if (!bytes)
|
|
187
193
|
continue;
|
|
188
194
|
if (bytes.length > MAX_ICON_BYTES) {
|
|
189
|
-
findings.push(`${name}
|
|
195
|
+
findings.push(`${name} is ${bytes.length} bytes, over the package source limit of 256 KiB; publishing rejects the whole version`);
|
|
190
196
|
continue;
|
|
191
197
|
}
|
|
192
198
|
try {
|
|
193
199
|
const image = decodePng(bytes);
|
|
194
200
|
if (image.width !== size || image.height !== size)
|
|
195
|
-
findings.push(`${name}
|
|
201
|
+
findings.push(`${name} is ${image.width}x${image.height}; Package Center needs ${size}x${size}`);
|
|
196
202
|
}
|
|
197
203
|
catch (error) {
|
|
198
|
-
findings.push(`${name}
|
|
204
|
+
findings.push(`${name}: ${error.message}`);
|
|
199
205
|
}
|
|
200
206
|
}
|
|
201
207
|
const big = readOptionalBytes(join(dir, "PACKAGE_ICON_256.PNG"));
|
|
@@ -212,20 +218,20 @@ const OS_MIN_VER = /^7\.\d+(\.\d+)?-\d+$/;
|
|
|
212
218
|
/** INFO os_min_ver and arch must be values DSM accepts, and arch must match the shipped CGI. */
|
|
213
219
|
async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
214
220
|
if (info.os_min_ver && !OS_MIN_VER.test(info.os_min_ver)) {
|
|
215
|
-
findings.push(`INFO os_min_ver
|
|
221
|
+
findings.push(`INFO os_min_ver "${info.os_min_ver}" is not the DSM 7 <version>-<build> (for example 7.1-42661)`);
|
|
216
222
|
}
|
|
217
223
|
else if (kind === "container" && info.os_min_ver && !isDsmAtLeast(info.os_min_ver, CONTAINER_MIN_DSM)) {
|
|
218
|
-
findings.push(`INFO os_min_ver
|
|
224
|
+
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`);
|
|
219
225
|
}
|
|
220
226
|
const tokens = (info.arch ?? "").split(" ").filter(Boolean);
|
|
221
227
|
if (tokens.length === 0 || tokens.includes("noarch"))
|
|
222
228
|
return;
|
|
223
229
|
for (const token of tokens.filter(token => !familyOfArchToken(token))) {
|
|
224
|
-
findings.push(`INFO arch
|
|
230
|
+
findings.push(`INFO arch contains an unknown platform "${token}"`);
|
|
225
231
|
}
|
|
226
232
|
const families = new Set(tokens.map(familyOfArchToken).filter(family => family !== undefined));
|
|
227
233
|
if (families.size > 1) {
|
|
228
|
-
findings.push(`INFO arch
|
|
234
|
+
findings.push(`INFO arch mixes several architectures (${[...families].join(", ")}); an SPK contains binaries for one architecture only`);
|
|
229
235
|
}
|
|
230
236
|
if (kind === "container" || !hasCgi || families.size !== 1)
|
|
231
237
|
return;
|
|
@@ -238,16 +244,16 @@ async function checkArch(dir, info, kind, hasCgi, findings) {
|
|
|
238
244
|
const machine = header.length === 20 && header.toString("latin1", 0, 4) === "\x7fELF" ? header.readUInt16LE(18) : undefined;
|
|
239
245
|
const actual = NATIVE_ARCH_FAMILIES.find(family => ELF_MACHINE[family] === machine);
|
|
240
246
|
if (actual && actual !== declared) {
|
|
241
|
-
findings.push(`INFO arch
|
|
247
|
+
findings.push(`INFO arch declares ${declared}, but ${CGI_PAYLOAD_PATH} is a ${actual} executable`);
|
|
242
248
|
}
|
|
243
249
|
}
|
|
244
250
|
async function checkDaemon(dir, info, paths, payload, findings, executor, background) {
|
|
245
251
|
if (![...paths].some(path => path.startsWith("daemon/")))
|
|
246
252
|
return;
|
|
247
253
|
if (info.precheckstartstop !== "yes")
|
|
248
|
-
findings.push(
|
|
254
|
+
findings.push("daemon INFO must explicitly set precheckstartstop=\"yes\"");
|
|
249
255
|
if (payload.find(p => p.path === "daemon/control")?.mode !== "0755")
|
|
250
|
-
findings.push("daemon/control
|
|
256
|
+
findings.push("daemon/control is missing or not executable");
|
|
251
257
|
const unpacked = join(dir, "daemon-inspection");
|
|
252
258
|
// Extract only the bounded config; the payload executable is never run.
|
|
253
259
|
const { mkdirSync } = await import("node:fs");
|
|
@@ -271,17 +277,17 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
|
|
|
271
277
|
throw new Error("non-canonical daemon config");
|
|
272
278
|
for (const hook of ["start-stop-status", "preupgrade", "preuninst"]) {
|
|
273
279
|
if (readFileSync(join(dir, "scripts", hook), "utf8") !== renderStandaloneScript(hook, manifest))
|
|
274
|
-
findings.push(`daemon scripts/${hook}
|
|
280
|
+
findings.push(`daemon scripts/${hook} does not match the fixed lifecycle`);
|
|
275
281
|
}
|
|
276
282
|
return daemon;
|
|
277
283
|
}
|
|
278
284
|
catch {
|
|
279
|
-
findings.push("daemon/config.json
|
|
285
|
+
findings.push("daemon/config.json or the payload does not match the fixed daemon contract");
|
|
280
286
|
}
|
|
281
287
|
}
|
|
282
288
|
function readInfo(path, findings, kind) {
|
|
283
289
|
if (!existsSync(path)) {
|
|
284
|
-
findings.push("
|
|
290
|
+
findings.push("INFO is missing");
|
|
285
291
|
return {};
|
|
286
292
|
}
|
|
287
293
|
const info = {};
|
|
@@ -293,14 +299,16 @@ function readInfo(path, findings, kind) {
|
|
|
293
299
|
}
|
|
294
300
|
for (const key of ["package", "version", "arch", "os_min_ver"]) {
|
|
295
301
|
if (!info[key]) {
|
|
296
|
-
findings.push(`INFO
|
|
302
|
+
findings.push(`INFO is missing ${key}`);
|
|
297
303
|
}
|
|
298
304
|
}
|
|
299
305
|
if (kind !== "container" && (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
|
|
300
|
-
findings.push(
|
|
306
|
+
findings.push("INFO must have thirdparty=\"yes\" and dsmuidir=\"ui\" for DSM to serve /webman/3rdparty/<package>/");
|
|
301
307
|
}
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
// A container needs Container Manager (and Web Station for a portal); a Node App the Node.js it runs on.
|
|
309
|
+
const allowed = kind === "container" ? /^(WebStation|ContainerManager)([><=].*)?$/ : kind === "native-node" ? /^Node\.js_v[0-9]{2}([><=].*)?$/ : undefined;
|
|
310
|
+
if (info.install_dep_packages && !(allowed && info.install_dep_packages.split(":").every(dep => allowed.test(dep)))) {
|
|
311
|
+
findings.push(`INFO depends on other packages: ${info.install_dep_packages}`);
|
|
304
312
|
}
|
|
305
313
|
return info;
|
|
306
314
|
}
|
|
@@ -311,11 +319,11 @@ async function checkContainerExposure(dir, packageTgz, findings) {
|
|
|
311
319
|
await tar.x({ file: packageTgz, cwd: extracted, filter: path => path.replace(/^\.\//, "") === "compose.yaml" });
|
|
312
320
|
const compose = loadCompose(join(extracted, "compose.yaml"))?.compose;
|
|
313
321
|
for (const exposure of compose ? listPortExposures(compose).filter(e => !e.loopbackOnly) : []) {
|
|
314
|
-
findings.push(`CONTAINER_PORT_EXPOSED ${exposure.service}: ${exposure.entry}
|
|
322
|
+
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`);
|
|
315
323
|
}
|
|
316
324
|
try {
|
|
317
325
|
if (JSON.parse(readFileSync(join(dir, "conf", "resource"), "utf8")).webservice) {
|
|
318
|
-
findings.push("CONTAINER_ANONYMOUS_PORTAL conf/resource
|
|
326
|
+
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)");
|
|
319
327
|
}
|
|
320
328
|
}
|
|
321
329
|
catch { /* checkConf already reports an unreadable conf/resource */ }
|
|
@@ -328,29 +336,29 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal) {
|
|
|
328
336
|
const entries = existsSync(confDir) ? readdirSync(confDir) : [];
|
|
329
337
|
const nativeResource = kind !== "container" ? renderStandaloneResource({ package: pkg ?? "", capabilities: background ? { jobs: { worker: "sha256-v1" } } : {}, ...(sharedFolder ? { sharedFolder } : {}) }, portal) : undefined;
|
|
330
338
|
if (nativeResource !== undefined && readOptional(join(confDir, "resource")) !== nativeResource) {
|
|
331
|
-
findings.push(`conf/resource
|
|
339
|
+
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)`);
|
|
332
340
|
}
|
|
333
341
|
for (const name of entries) {
|
|
334
342
|
if (name !== "privilege" && !(kind === "container" && name === "resource") && !(nativeResource !== undefined && name === "resource") && !(background && name === "systemd")) {
|
|
335
|
-
findings.push(`conf/${name}
|
|
343
|
+
findings.push(`conf/${name} registers an extra resource with DSM; a Standalone Package does not produce it`);
|
|
336
344
|
}
|
|
337
345
|
}
|
|
338
346
|
if (kind === "container") {
|
|
339
347
|
try {
|
|
340
348
|
const resource = JSON.parse(readFileSync(join(confDir, "resource"), "utf8"));
|
|
341
349
|
if (!resource["docker-project"])
|
|
342
|
-
findings.push("container
|
|
350
|
+
findings.push("the container is missing the docker-project resource");
|
|
343
351
|
for (const key of Object.keys(resource)) {
|
|
344
352
|
if (!["docker-project", "webservice"].includes(key))
|
|
345
|
-
findings.push(
|
|
353
|
+
findings.push(`Unknown container resource: ${key}`);
|
|
346
354
|
}
|
|
347
355
|
}
|
|
348
356
|
catch {
|
|
349
|
-
findings.push("container
|
|
357
|
+
findings.push("the container is missing a valid conf/resource");
|
|
350
358
|
}
|
|
351
359
|
}
|
|
352
360
|
if (!entries.includes("privilege")) {
|
|
353
|
-
findings.push("
|
|
361
|
+
findings.push("conf/privilege is missing (DSM 7 must run as the package account)");
|
|
354
362
|
return;
|
|
355
363
|
}
|
|
356
364
|
let privilege;
|
|
@@ -358,36 +366,36 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal) {
|
|
|
358
366
|
privilege = JSON.parse(readFileSync(join(confDir, "privilege"), "utf8"));
|
|
359
367
|
}
|
|
360
368
|
catch {
|
|
361
|
-
findings.push("conf/privilege
|
|
369
|
+
findings.push("conf/privilege is not JSON");
|
|
362
370
|
return;
|
|
363
371
|
}
|
|
364
372
|
const defaults = privilege.defaults;
|
|
365
373
|
if (defaults?.["run-as"] !== "package") {
|
|
366
|
-
findings.push(
|
|
374
|
+
findings.push("conf/privilege defaults.run-as must be \"package\"");
|
|
367
375
|
}
|
|
368
376
|
for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
|
|
369
377
|
if (key in privilege) {
|
|
370
|
-
findings.push(`conf/privilege
|
|
378
|
+
findings.push(`conf/privilege requires ${key} (root or extra group privileges)`);
|
|
371
379
|
}
|
|
372
380
|
}
|
|
373
381
|
}
|
|
374
382
|
/** The opt-in package user unit must be exactly what the generator writes. */
|
|
375
|
-
function checkBackground(dir, info, payload, findings, executor, daemon) {
|
|
383
|
+
function checkBackground(dir, info, payload, findings, executor, daemon, bridge = false) {
|
|
376
384
|
const pkg = info.package;
|
|
377
385
|
if (!pkg)
|
|
378
386
|
return;
|
|
379
387
|
const read = (path) => existsSync(join(dir, path)) ? readFileSync(join(dir, path), "utf8") : undefined;
|
|
380
388
|
if (payload.find(p => p.path === BACKGROUND_WORKER_PAYLOAD)?.mode !== "0755")
|
|
381
|
-
findings.push(`${BACKGROUND_WORKER_PAYLOAD}
|
|
389
|
+
findings.push(`${BACKGROUND_WORKER_PAYLOAD} is missing or not executable`);
|
|
382
390
|
if (info.precheckstartstop !== "yes")
|
|
383
|
-
findings.push(
|
|
391
|
+
findings.push("background INFO must explicitly set precheckstartstop=\"yes\" (prestart recovers the package user manager that lost its socket)");
|
|
384
392
|
const units = existsSync(join(dir, BACKGROUND_UNIT_DIR)) ? readdirSync(join(dir, BACKGROUND_UNIT_DIR)) : [];
|
|
385
393
|
const unit = backgroundUnitName(pkg);
|
|
386
|
-
if (units.length !== 1 || units[0] !== unit || read(`${BACKGROUND_UNIT_DIR}/${unit}`) !== renderBackgroundUnit(pkg)) {
|
|
387
|
-
findings.push(`${BACKGROUND_UNIT_DIR}
|
|
394
|
+
if (units.length !== 1 || units[0] !== unit || read(`${BACKGROUND_UNIT_DIR}/${unit}`) !== renderBackgroundUnit(pkg, bridge ? BRIDGE_WORKER_ARGS : [])) {
|
|
395
|
+
findings.push(`${BACKGROUND_UNIT_DIR} must contain only the fixed ${unit} (runs as the package account, no User=, no root)`);
|
|
388
396
|
}
|
|
389
397
|
if (read("scripts/start-stop-status") !== renderStandaloneScript("start-stop-status", { package: pkg, ...(daemon ? { daemon } : {}), capabilities: { jobs: { worker: "sha256-v1" }, ...(executor ? { rootOps: true } : {}) } }))
|
|
390
|
-
findings.push("background scripts/start-stop-status
|
|
398
|
+
findings.push("background scripts/start-stop-status does not match the fixed synosystemctl lifecycle");
|
|
391
399
|
}
|
|
392
400
|
/**
|
|
393
401
|
* `install.sharedFolder` is recovered from the postinst marker. The install
|
|
@@ -400,18 +408,18 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
|
|
|
400
408
|
const present = (existsSync(wizardDir) ? readdirSync(wizardDir) : []).filter(name => /^(install|upgrade)_uifile(_|\.|$)/.test(name)).sort();
|
|
401
409
|
const postinst = readOptional(join(dir, "scripts/postinst"));
|
|
402
410
|
if (!folder && postinst?.includes(SHARED_FOLDER_MARKER))
|
|
403
|
-
findings.push("scripts/postinst
|
|
411
|
+
findings.push("the install.sharedFolder declaration in scripts/postinst is invalid");
|
|
404
412
|
if (!pkg)
|
|
405
413
|
return;
|
|
406
414
|
const wizards = expectedWizardFiles(pkg, folder, selected, portal);
|
|
407
|
-
const source = selected ? "install-selected policy" : folder ? "install.sharedFolder
|
|
415
|
+
const source = selected ? "the install-selected policy" : folder ? "the install.sharedFolder declaration" : portal ? "the portal declaration" : "the native-go default install wizard";
|
|
408
416
|
// The default native wizard is optional; a declared shared folder or portal needs all of its files.
|
|
409
417
|
for (const name of folder || selected || portal ? Object.keys(wizards) : present.filter(n => n in wizards)) {
|
|
410
418
|
if (readOptional(join(wizardDir, name)) !== wizards[name])
|
|
411
|
-
findings.push(`WIZARD_UIFILES/${name}
|
|
419
|
+
findings.push(`WIZARD_UIFILES/${name} does not match ${source}`);
|
|
412
420
|
}
|
|
413
421
|
for (const name of present.filter(n => !(n in wizards)))
|
|
414
|
-
findings.push(`WIZARD_UIFILES/${name}
|
|
422
|
+
findings.push(`WIZARD_UIFILES/${name} is not a wizard file produced by ${source}`);
|
|
415
423
|
if (selected) {
|
|
416
424
|
for (const hook of ['preinst', 'postinst']) {
|
|
417
425
|
if (!readOptional(join(dir, 'scripts', hook))?.includes(renderDelegateInstallDraft(pkg, hook === 'preinst')))
|
|
@@ -422,7 +430,7 @@ function checkSharedFolder(dir, findings, pkg, folder, selected = false, portal,
|
|
|
422
430
|
return;
|
|
423
431
|
for (const hook of ["postinst", "postupgrade"]) {
|
|
424
432
|
if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, { package: pkg, sharedFolder: folder, ...(selected ? { capabilities: { delegate: { scope: "install-selected" } } } : executor ? { capabilities: { rootOps: true } } : {}), ...portalManifest(portal) })) {
|
|
425
|
-
findings.push(`scripts/${hook}
|
|
433
|
+
findings.push(`scripts/${hook} does not match the fixed script declared by install.sharedFolder`);
|
|
426
434
|
}
|
|
427
435
|
}
|
|
428
436
|
}
|
|
@@ -463,18 +471,18 @@ async function recoverPortal(dir, info, appId, paths, payload, findings) {
|
|
|
463
471
|
return portal;
|
|
464
472
|
}
|
|
465
473
|
catch {
|
|
466
|
-
findings.push("PORTAL_INVALID etc/portal.json
|
|
474
|
+
findings.push("PORTAL_INVALID etc/portal.json, etc/portal.mustache or the lifecycle does not match the fixed Application Portal contract (it should open the package's own DSM window)");
|
|
467
475
|
}
|
|
468
476
|
}
|
|
469
477
|
function checkScripts(dir, findings) {
|
|
470
478
|
const scriptsDir = join(dir, "scripts");
|
|
471
479
|
if (!existsSync(join(scriptsDir, "start-stop-status"))) {
|
|
472
|
-
findings.push("
|
|
480
|
+
findings.push("scripts/start-stop-status is missing");
|
|
473
481
|
}
|
|
474
482
|
for (const name of existsSync(scriptsDir) ? readdirSync(scriptsDir) : []) {
|
|
475
483
|
const body = readFileSync(join(scriptsDir, name), "utf8");
|
|
476
484
|
if (ROOT_SCRIPT_HINTS.some((pattern) => pattern.test(body))) {
|
|
477
|
-
findings.push(`scripts/${name}
|
|
485
|
+
findings.push(`scripts/${name} tries to gain root (sudo, chown root or setuid)`);
|
|
478
486
|
}
|
|
479
487
|
}
|
|
480
488
|
}
|
|
@@ -515,21 +523,21 @@ function checkUninstall(dir, findings) {
|
|
|
515
523
|
const deletes = DELETE_COMMAND.test(body);
|
|
516
524
|
if (deletes) {
|
|
517
525
|
if (!UPGRADE_GUARD.test(body))
|
|
518
|
-
findings.push(
|
|
526
|
+
findings.push("UNINSTALL_NO_UPGRADE_GUARD scripts/postuninst deletes data without the [ \"$SYNOPKG_PKG_STATUS\" != UPGRADE ] guard, so an upgrade deletes it by mistake");
|
|
519
527
|
if (!/is_package_area\(\)\s*\{/.test(body) || !/if ! is_package_area "\$real"/.test(body))
|
|
520
|
-
findings.push("UNINSTALL_UNSAFE_DELETE scripts/postuninst
|
|
528
|
+
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");
|
|
521
529
|
for (const line of body.split("\n").filter(l => DELETE_COMMAND.test(l))) {
|
|
522
530
|
for (const path of line.match(/\/volume\w*\/(?!@app(data|home|conf|store)\/)[^\s"'$;]*/g) ?? [])
|
|
523
|
-
findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst
|
|
531
|
+
findings.push(`UNINSTALL_UNSAFE_DELETE scripts/postuninst deletes ${path}, which is not inside the package's own @app* directory`);
|
|
524
532
|
}
|
|
525
533
|
if (!boxes.some(box => box.name === "uninstall_uifile"))
|
|
526
|
-
findings.push("UNINSTALL_WIZARD_MISSING postuninst
|
|
534
|
+
findings.push("UNINSTALL_WIZARD_MISSING postuninst deletes data, but WIZARD_UIFILES/uninstall_uifile has no pkgwizard_remove_additional checkbox, so the user cannot choose");
|
|
527
535
|
}
|
|
528
536
|
for (const box of boxes) {
|
|
529
537
|
if (box.defaultValue !== false)
|
|
530
|
-
findings.push(`UNINSTALL_WIZARD_DEFAULT_CHECKED
|
|
538
|
+
findings.push(`UNINSTALL_WIZARD_DEFAULT_CHECKED ${box.key} in WIZARD_UIFILES/${box.name} must be unchecked by default (keep the data)`);
|
|
531
539
|
if (!body.includes(box.key))
|
|
532
|
-
findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name}
|
|
540
|
+
findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name} provides the ${box.key} checkbox, but scripts/postuninst does not read it`);
|
|
533
541
|
}
|
|
534
542
|
}
|
|
535
543
|
/** The App's delete-state box keeps the state by default, and preuninst hands its answer to the App hook. */
|
|
@@ -538,14 +546,14 @@ function checkStateBoxes(dir, boxes, findings) {
|
|
|
538
546
|
const body = existsSync(script) ? readFileSync(script, "utf8") : "";
|
|
539
547
|
for (const box of boxes) {
|
|
540
548
|
if (box.defaultValue !== false)
|
|
541
|
-
findings.push(`UNINSTALL_STATE_DEFAULT_CHECKED
|
|
549
|
+
findings.push(`UNINSTALL_STATE_DEFAULT_CHECKED ${box.key} in WIZARD_UIFILES/${box.name} must be unchecked by default (keep the App state)`);
|
|
542
550
|
if (!body.includes(box.key))
|
|
543
|
-
findings.push(`UNINSTALL_STATE_NOT_HONOURED WIZARD_UIFILES/${box.name}
|
|
551
|
+
findings.push(`UNINSTALL_STATE_NOT_HONOURED WIZARD_UIFILES/${box.name} provides the ${box.key} checkbox, but scripts/preuninst does not read it`);
|
|
544
552
|
}
|
|
545
553
|
}
|
|
546
554
|
export async function listPayload(packageTgz, findings) {
|
|
547
555
|
if (!existsSync(packageTgz)) {
|
|
548
|
-
findings.push("
|
|
556
|
+
findings.push("package.tgz is missing");
|
|
549
557
|
return [];
|
|
550
558
|
}
|
|
551
559
|
const payload = [];
|
|
@@ -555,16 +563,16 @@ export async function listPayload(packageTgz, findings) {
|
|
|
555
563
|
const path = entry.path.replace(/^\.\//, "");
|
|
556
564
|
const mode = (entry.mode ?? 0) & 0o7777;
|
|
557
565
|
if (entry.type === "SymbolicLink" || entry.type === "Link") {
|
|
558
|
-
findings.push(`payload
|
|
566
|
+
findings.push(`payload contains a link: ${path}`);
|
|
559
567
|
}
|
|
560
568
|
if (path.startsWith("/") || path.split("/").includes("..")) {
|
|
561
|
-
findings.push(`payload
|
|
569
|
+
findings.push(`payload path escapes the package directory: ${path}`);
|
|
562
570
|
}
|
|
563
571
|
if (mode & 0o6000) {
|
|
564
|
-
findings.push(`payload
|
|
572
|
+
findings.push(`payload contains a setuid/setgid file: ${path}`);
|
|
565
573
|
}
|
|
566
574
|
if (mode & 0o002) {
|
|
567
|
-
findings.push(`payload
|
|
575
|
+
findings.push(`payload contains a world-writable file: ${path}`);
|
|
568
576
|
}
|
|
569
577
|
if (entry.type === "File") {
|
|
570
578
|
payload.push({ path, mode: mode.toString(8).padStart(4, "0") });
|
|
@@ -47,7 +47,7 @@ function renderStandaloneScriptBody(name, manifest) {
|
|
|
47
47
|
}
|
|
48
48
|
if (name === "preuninst")
|
|
49
49
|
return renderExecutorPreuninst(manifest, workerStop, stopWorker, workerResult, request);
|
|
50
|
-
return `#!/bin/sh\n${workerStop}${stopWorker}${
|
|
50
|
+
return `#!/bin/sh\n${renderExecutorUpgradeStop(manifest.package)}${workerStop}${stopWorker}${UPGRADE_STOP} || exit $?\n${manifest.daemon ? renderDaemonScript(name, manifest).replace("#!/bin/sh\n", "") : workerResult}`;
|
|
51
51
|
}
|
|
52
52
|
if (name === "postuninst")
|
|
53
53
|
return renderPackagePostuninst(manifest.package);
|
|
@@ -85,11 +85,11 @@ function renderDelegatedDaemonScript(name, manifest) {
|
|
|
85
85
|
const worker = (action) => `/usr/syno/bin/synosystemctl ${action} ${backgroundUnitName(pkg)}`;
|
|
86
86
|
// Try every owner even on failure; never leave protected ingress open merely
|
|
87
87
|
// because a producer could not stop. A partial teardown remains a failure.
|
|
88
|
-
const stop = (daemonAction, executorAction) => `result=0\nstop_background_worker || result=1\n${daemon} ${daemonAction} ${paths} || result=1\n${
|
|
88
|
+
const stop = (daemonAction, executorAction, executorStop = executor(executorAction)) => `result=0\nstop_background_worker || result=1\n${daemon} ${daemonAction} ${paths} || result=1\n${executorStop} || result=1\nexit "$result"\n`;
|
|
89
89
|
const workerStop = renderBackgroundWorkerStop(pkg);
|
|
90
90
|
if (name !== 'start-stop-status') {
|
|
91
91
|
if (name === 'preupgrade')
|
|
92
|
-
return `#!/bin/sh\n${workerStop}${stop('stop', 'stop')}`;
|
|
92
|
+
return `#!/bin/sh\n${renderExecutorUpgradeStop(pkg)}${workerStop}${stop('stop', 'stop', UPGRADE_STOP)}`;
|
|
93
93
|
const purge = manifest.uninstall
|
|
94
94
|
? stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge $synopkgland_purge_args')
|
|
95
95
|
.replace(executor('purge $synopkgland_purge_args'), () => `synopkgland_uninstall_hook\n${executor('purge $synopkgland_purge_args')}`)
|
|
@@ -128,6 +128,25 @@ ${stop('stop', 'stop')} ;;
|
|
|
128
128
|
esac
|
|
129
129
|
`;
|
|
130
130
|
}
|
|
131
|
+
const UPGRADE_STOP = "synopkgland_executor_upgrade_stop";
|
|
132
|
+
/**
|
|
133
|
+
* preupgrade runs the outgoing package's helper: `stop --upgrade` lets the
|
|
134
|
+
* policy's stop step prepare the upgrade, and a helper from an SDK without
|
|
135
|
+
* the flag gets the plain stop it knows. Any other failure fails the hook.
|
|
136
|
+
*/
|
|
137
|
+
function renderExecutorUpgradeStop(pkg) {
|
|
138
|
+
const helper = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}/executor/lifecycle"`;
|
|
139
|
+
return `${UPGRADE_STOP}() {
|
|
140
|
+
synopkgland_stop_error=$(${helper} stop --package ${pkg} --upgrade 2>&1 >/dev/null) && return 0
|
|
141
|
+
synopkgland_stop_status=$?
|
|
142
|
+
case "$synopkgland_stop_error" in
|
|
143
|
+
*"flag provided but not defined: -upgrade"*) ${helper} stop --package ${pkg}; return $? ;;
|
|
144
|
+
esac
|
|
145
|
+
printf '%s\\n' "$synopkgland_stop_error" >&2
|
|
146
|
+
return "$synopkgland_stop_status"
|
|
147
|
+
}
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
131
150
|
function renderDaemonScript(name, manifest) {
|
|
132
151
|
const pkg = manifest.package;
|
|
133
152
|
const control = `"/var/packages/${pkg}/target/daemon/control"`;
|