@synopackageland/cli 0.9.5 → 0.9.7
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 +54 -0
- package/assets/licenses/MUSL-COPYRIGHT +193 -0
- package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
- package/dist/add.js +27 -27
- package/dist/build-spk/notices.d.ts +8 -1
- package/dist/build-spk/notices.js +13 -4
- package/dist/build-spk/uninstall-wizard.d.ts +9 -0
- package/dist/build-spk/uninstall-wizard.js +10 -3
- package/dist/build-spk/wizard-i18n.d.ts +1 -1
- package/dist/build-spk/wizard-i18n.js +1 -1
- package/dist/build-spk/wizard-messages.d.ts +33 -0
- package/dist/build-spk/wizard-messages.js +33 -0
- package/dist/capability-overlays.d.ts +10 -5
- package/dist/capability-overlays.js +32 -10
- package/dist/cli.js +5 -5
- package/dist/deploy/plan.d.ts +1 -1
- package/dist/deploy/plan.js +1 -1
- package/dist/error-registry.js +44 -3
- package/dist/info.js +2 -2
- package/dist/init.js +53 -28
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +2 -1
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/runtime/executor-policy.d.ts +7 -0
- package/dist/runtime/executor-policy.js +5 -3
- package/dist/runtime/sdk-source.d.ts +14 -2
- package/dist/runtime/sdk-source.js +39 -4
- package/dist/standalone/api-permissions.d.ts +4 -2
- package/dist/standalone/api-permissions.js +6 -5
- package/dist/standalone/binary.d.ts +3 -2
- package/dist/standalone/binary.js +17 -5
- package/dist/standalone/build.js +13 -9
- package/dist/standalone/daemon.d.ts +6 -0
- package/dist/standalone/daemon.js +8 -4
- package/dist/standalone/inspect-spk.d.ts +1 -1
- package/dist/standalone/inspect-spk.js +39 -10
- package/dist/standalone/lifecycle.js +38 -4
- package/dist/standalone/manifest.d.ts +12 -0
- package/dist/standalone/manifest.js +55 -17
- package/dist/standalone/native-layout.d.ts +1 -7
- package/dist/standalone/native-layout.js +4 -97
- package/dist/standalone/root-ops.d.ts +2 -0
- package/dist/standalone/root-ops.js +2 -0
- package/dist/standalone/rust-build.d.ts +35 -0
- package/dist/standalone/rust-build.js +127 -0
- package/dist/standalone/sdk-check.d.ts +5 -3
- package/dist/standalone/sdk-check.js +21 -8
- package/dist/standalone/spk.js +14 -3
- package/dist/standalone/toolkit.d.ts +10 -0
- package/dist/standalone/toolkit.js +101 -0
- package/dist/standalone/uninstall-hook.d.ts +10 -0
- package/dist/standalone/uninstall-hook.js +71 -0
- package/dist/standalone/uninstall.d.ts +53 -0
- package/dist/standalone/uninstall.js +136 -0
- package/dist/types.d.ts +1 -1
- package/dist/yaml-edit.d.ts +6 -0
- package/dist/yaml-edit.js +31 -0
- package/package.json +2 -2
|
@@ -10,6 +10,9 @@ import { CONTAINER_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, PORTAL_MIN_DS
|
|
|
10
10
|
import { ELF_MACHINE } from "./binary.js";
|
|
11
11
|
import { CGI_PAYLOAD_PATH, entryLauncherAccess } from "./dsm-window.js";
|
|
12
12
|
import { daemonConfig, readDaemon } from "./daemon.js";
|
|
13
|
+
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
14
|
+
import { DELETE_STATE_KEY } from "../build-spk/uninstall-wizard.js";
|
|
15
|
+
import { readUninstallRecord, stateItemTexts } from "./uninstall.js";
|
|
13
16
|
import { BACKGROUND_UNIT_DIR, BACKGROUND_WORKER_PAYLOAD, backgroundUnitName, renderBackgroundUnit } from "./background.js";
|
|
14
17
|
import { renderStandaloneResource } from "./resource.js";
|
|
15
18
|
import { recoverSharedFolder, renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps, SHARED_FOLDER_MARKER } from "./shared-folder.js";
|
|
@@ -23,7 +26,6 @@ import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderP
|
|
|
23
26
|
import { isPortalAlias } from "../portal/alias.js";
|
|
24
27
|
import { defaultPackageIcons, MAX_ICON_BYTES } from "../build-spk/dsm-icons.js";
|
|
25
28
|
import { decodePng } from "../build-spk/png.js";
|
|
26
|
-
const ROOT_SCRIPT_HINTS = [/\bsudo\b/, /\bchown\s+(-\w+\s+)*root\b/, /chmod\s+[ugo]*\+s\b/, /chmod\s+[2467][0-7]{3}\b/];
|
|
27
29
|
/** Reads an SPK without installing it and reports how it would run on DSM. */
|
|
28
30
|
export async function inspectStandaloneSpk(spkPath) {
|
|
29
31
|
return inspectSpk(spkPath, "native-go");
|
|
@@ -55,7 +57,7 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
55
57
|
if (metadataText !== undefined) {
|
|
56
58
|
try {
|
|
57
59
|
const raw = JSON.parse(metadataText);
|
|
58
|
-
if (raw.schema !== 2 || !["go", "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)))
|
|
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)))
|
|
59
61
|
throw new Error();
|
|
60
62
|
metadata = raw;
|
|
61
63
|
}
|
|
@@ -63,12 +65,18 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
63
65
|
findings.push("sdk-package.json 不符合 schema 2 capability inventory。");
|
|
64
66
|
}
|
|
65
67
|
}
|
|
68
|
+
const uninstallHook = metadata.uninstall === undefined ? undefined : readUninstallRecord(metadata.uninstall, metadata.capabilities?.includes("rootOps") === true);
|
|
69
|
+
if (metadata.uninstall !== undefined && !uninstallHook)
|
|
70
|
+
findings.push("UNINSTALL_RECORD_INVALID sdk-package.json 的 uninstall 不是 synopkgland build 寫出的 uninstall 設定。");
|
|
66
71
|
const binaryBackend = metadata.backend === "binary";
|
|
67
|
-
|
|
68
|
-
const
|
|
72
|
+
// A strict standalone inspection names the language the CGI was built from.
|
|
73
|
+
const compiled = metadata.backend === "rust" ? "native-rust" : "native-go";
|
|
74
|
+
const kind = (expectedKind === "native-go" ? compiled : expectedKind) ?? (paths.has("compose.yaml") ? "container" : binaryBackend ? "binary" : compiled);
|
|
75
|
+
const compiledKind = kind === "native-go" || kind === "native-rust";
|
|
76
|
+
const standalone = compiledKind || kind === "binary";
|
|
69
77
|
const info = readInfo(join(dir, "INFO"), findings, kind);
|
|
70
78
|
await checkArch(dir, info, kind, paths.has(CGI_PAYLOAD_PATH), findings);
|
|
71
|
-
const background =
|
|
79
|
+
const background = compiledKind && paths.has(BACKGROUND_WORKER_PAYLOAD);
|
|
72
80
|
const sharedFolder = standalone ? recoverSharedFolder(readOptional(join(dir, "scripts/postinst"))) : undefined;
|
|
73
81
|
// A Compose App has no executor, so its SPK never carries a portal.
|
|
74
82
|
const portal = standalone ? await recoverPortal(dir, info, `SYNO.SDS.${info.package}.Instance`, paths, payload, findings) : undefined;
|
|
@@ -99,18 +107,20 @@ export async function inspectSpk(spkPath, expectedKind) {
|
|
|
99
107
|
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)) {
|
|
100
108
|
findings.push(`INFO os_min_ver「${info.os_min_ver}」低於 executor 註冊 portal 需要的 DSM ${PORTAL_EXECUTOR_MIN_DSM}`);
|
|
101
109
|
}
|
|
110
|
+
if (executor && executor.policy.purge !== uninstallHook?.uninstall.purge.root)
|
|
111
|
+
findings.push(`UNINSTALL_ROOT_POLICY_MISMATCH executor policy 的 purge「${executor.policy.purge ?? ""}」與 uninstall.purge.root「${uninstallHook?.uninstall.purge.root ?? ""}」不同。`);
|
|
102
112
|
if (payload.find(p => p.path === "executor/executor")?.mode !== "0755")
|
|
103
113
|
findings.push("Executor must be executable.");
|
|
104
114
|
if (payload.find(p => p.path === "executor/lifecycle")?.mode !== "0755")
|
|
105
115
|
findings.push("Executor lifecycle helper must be executable.");
|
|
106
116
|
if (![...paths].some(path => path.startsWith("daemon/"))) {
|
|
107
|
-
const lifecycleManifest = { package: info.package, capabilities: { rootOps: true, ...(background ? { jobs: { worker: "sha256-v1" } } : {}) } };
|
|
117
|
+
const lifecycleManifest = { package: info.package, capabilities: { rootOps: true, ...(background ? { jobs: { worker: "sha256-v1" } } : {}) }, ...(uninstallHook ? { uninstall: uninstallHook.uninstall } : {}) };
|
|
108
118
|
for (const hook of ["preuninst", "preupgrade", "start-stop-status"]) {
|
|
109
119
|
if (readOptional(join(dir, "scripts", hook)) !== renderStandaloneScript(hook, lifecycleManifest))
|
|
110
120
|
findings.push(`Executor scripts/${hook} differs from fixed lifecycle.`);
|
|
111
121
|
}
|
|
112
122
|
}
|
|
113
|
-
for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal) }))) {
|
|
123
|
+
for (const [name, text] of Object.entries(renderUninstallWizardFiles(info.package, "native", { executor: true, portal: Boolean(portal), portalEntry: Boolean(executor?.policy.portal), ...uninstallWizardOptions(uninstallHook) }))) {
|
|
114
124
|
if (readOptional(join(dir, "WIZARD_UIFILES", name)) !== text)
|
|
115
125
|
findings.push(`Executor WIZARD_UIFILES/${name} is missing cleanup guidance.`);
|
|
116
126
|
}
|
|
@@ -471,10 +481,10 @@ function checkScripts(dir, findings) {
|
|
|
471
481
|
const DATA_KEYS = ["pkgwizard_remove_additional", "delete_app_data"];
|
|
472
482
|
const DELETE_COMMAND = /\brm\s+-[a-zA-Z]*[rR]|\bfind\b[^\n]*(-delete|-exec\s+rm)/;
|
|
473
483
|
const UPGRADE_GUARD = /SYNOPKG_PKG_STATUS[^\n]*!=\s*"?UPGRADE"?/;
|
|
474
|
-
function uninstallBoxes(file) {
|
|
484
|
+
function uninstallBoxes(file, keys) {
|
|
475
485
|
try {
|
|
476
486
|
const steps = JSON.parse(readFileSync(file, "utf8"));
|
|
477
|
-
return steps.flatMap(step => step.items ?? []).flatMap(item => item.subitems ?? []).filter(box =>
|
|
487
|
+
return steps.flatMap(step => step.items ?? []).flatMap(item => item.subitems ?? []).filter(box => keys.includes(box.key));
|
|
478
488
|
}
|
|
479
489
|
catch {
|
|
480
490
|
return [];
|
|
@@ -486,6 +496,13 @@ function uninstallBoxes(file) {
|
|
|
486
496
|
* package's own `/volumeN/@app*` directories; the wizard and the script must
|
|
487
497
|
* agree on the box that asks for it.
|
|
488
498
|
*/
|
|
499
|
+
/** The wizard texts of the App's uninstall hook, as build rendered them. */
|
|
500
|
+
function uninstallWizardOptions(hook) {
|
|
501
|
+
if (!hook)
|
|
502
|
+
return {};
|
|
503
|
+
const { uninstall, catalogs } = hook;
|
|
504
|
+
return { rootPurge: Boolean(uninstall.purge.root), ...(uninstall.state ? { state: { items: (locale) => stateItemTexts(uninstall, catalogs, locale) } } : {}) };
|
|
505
|
+
}
|
|
489
506
|
function checkUninstall(dir, findings) {
|
|
490
507
|
const script = join(dir, "scripts", "postuninst");
|
|
491
508
|
if (!existsSync(script))
|
|
@@ -493,7 +510,8 @@ function checkUninstall(dir, findings) {
|
|
|
493
510
|
const body = readFileSync(script, "utf8");
|
|
494
511
|
const wizardDir = join(dir, "WIZARD_UIFILES");
|
|
495
512
|
const wizards = existsSync(wizardDir) ? readdirSync(wizardDir).filter(name => name === "uninstall_uifile" || name.startsWith("uninstall_uifile_")).sort() : [];
|
|
496
|
-
const boxes = wizards.flatMap(name => uninstallBoxes(join(wizardDir, name)).map(box => ({ ...box, name })));
|
|
513
|
+
const boxes = wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), DATA_KEYS).map(box => ({ ...box, name })));
|
|
514
|
+
checkStateBoxes(dir, wizards.flatMap(name => uninstallBoxes(join(wizardDir, name), [DELETE_STATE_KEY]).map(box => ({ ...box, name }))), findings);
|
|
497
515
|
const deletes = DELETE_COMMAND.test(body);
|
|
498
516
|
if (deletes) {
|
|
499
517
|
if (!UPGRADE_GUARD.test(body))
|
|
@@ -514,6 +532,17 @@ function checkUninstall(dir, findings) {
|
|
|
514
532
|
findings.push(`UNINSTALL_WIZARD_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/postuninst 沒有讀它`);
|
|
515
533
|
}
|
|
516
534
|
}
|
|
535
|
+
/** The App's delete-state box keeps the state by default, and preuninst hands its answer to the App hook. */
|
|
536
|
+
function checkStateBoxes(dir, boxes, findings) {
|
|
537
|
+
const script = join(dir, "scripts", "preuninst");
|
|
538
|
+
const body = existsSync(script) ? readFileSync(script, "utf8") : "";
|
|
539
|
+
for (const box of boxes) {
|
|
540
|
+
if (box.defaultValue !== false)
|
|
541
|
+
findings.push(`UNINSTALL_STATE_DEFAULT_CHECKED WIZARD_UIFILES/${box.name} 的 ${box.key} 預設必須不勾(保留 App 狀態)`);
|
|
542
|
+
if (!body.includes(box.key))
|
|
543
|
+
findings.push(`UNINSTALL_STATE_NOT_HONOURED WIZARD_UIFILES/${box.name} 提供 ${box.key} 勾選框,但 scripts/preuninst 沒有讀它`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
517
546
|
export async function listPayload(packageTgz, findings) {
|
|
518
547
|
if (!existsSync(packageTgz)) {
|
|
519
548
|
findings.push("缺少 package.tgz");
|
|
@@ -4,6 +4,8 @@ import { renderSharedFolderScript } from "./shared-folder.js";
|
|
|
4
4
|
import { renderDelegateInstallDraft } from '../build-spk/delegate-scope.js';
|
|
5
5
|
import { renderPortalLifecycle } from "../portal/package.js";
|
|
6
6
|
import { nativePortal } from "./portal.js";
|
|
7
|
+
import { renderUninstallHook } from "./uninstall-hook.js";
|
|
8
|
+
const UNINSTALL_ONLY = '[ "${SYNOPKG_PKG_STATUS:-}" = UPGRADE ] || synopkgland_uninstall_hook\n';
|
|
7
9
|
/** Package-account lifecycle hooks for CGI, daemon and background packages. */
|
|
8
10
|
export function renderStandaloneScript(name, manifest) {
|
|
9
11
|
const portal = nativePortal(manifest);
|
|
@@ -43,12 +45,16 @@ function renderStandaloneScriptBody(name, manifest) {
|
|
|
43
45
|
const stopDaemon = manifest.daemon ? `"/var/packages/${manifest.package}/target/daemon/control" stop "\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${manifest.package}/var}" || exit $?\n` : "";
|
|
44
46
|
return normal.replace("#!/bin/sh\n", `#!/bin/sh\n${workerStop}if [ "$1" = start ]; then\n${request("start")}fi\nif [ "$1" = stop ]; then\n${stopWorker}${stopDaemon}${request("stop")}${workerResult}fi\n`);
|
|
45
47
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return `#!/bin/sh\n${workerStop}${stopWorker}${
|
|
48
|
+
if (name === "preuninst")
|
|
49
|
+
return renderExecutorPreuninst(manifest, workerStop, stopWorker, workerResult, request);
|
|
50
|
+
return `#!/bin/sh\n${workerStop}${stopWorker}${request("stop")}${manifest.daemon ? renderDaemonScript(name, manifest).replace("#!/bin/sh\n", "") : workerResult}`;
|
|
49
51
|
}
|
|
50
52
|
if (name === "postuninst")
|
|
51
53
|
return renderPackagePostuninst(manifest.package);
|
|
54
|
+
if (name === "preuninst" && manifest.uninstall) {
|
|
55
|
+
const body = manifest.daemon ? renderDaemonPreuninst(manifest) : `${UNINSTALL_ONLY}exit 0\n`;
|
|
56
|
+
return `#!/bin/sh\n${renderUninstallHook(manifest)}${body}`;
|
|
57
|
+
}
|
|
52
58
|
if ((manifest.capabilities?.rootOps || manifest.capabilities?.delegate) && ["postinst", "postupgrade"].includes(name)) {
|
|
53
59
|
const normal = renderCapabilityScript(name, { ...manifest, capabilities: { ...manifest.capabilities, rootOps: undefined, delegate: undefined } });
|
|
54
60
|
return normal.replace("#!/bin/sh\n", `#!/bin/sh\n"\${SYNOPKG_PKGDEST:-/var/packages/${manifest.package}/target}/executor/lifecycle" start --package ${manifest.package} || exit $?\n`);
|
|
@@ -84,7 +90,11 @@ function renderDelegatedDaemonScript(name, manifest) {
|
|
|
84
90
|
if (name !== 'start-stop-status') {
|
|
85
91
|
if (name === 'preupgrade')
|
|
86
92
|
return `#!/bin/sh\n${workerStop}${stop('stop', 'stop')}`;
|
|
87
|
-
|
|
93
|
+
const purge = manifest.uninstall
|
|
94
|
+
? stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge $synopkgland_purge_args')
|
|
95
|
+
.replace(executor('purge $synopkgland_purge_args'), () => `synopkgland_uninstall_hook\n${executor('purge $synopkgland_purge_args')}`)
|
|
96
|
+
: stop(manifest.daemon?.uninstall_args ? 'uninstall' : 'stop', 'purge');
|
|
97
|
+
return `#!/bin/sh\n${workerStop}${renderUninstallHook(manifest)}if [ "\${SYNOPKG_PKG_STATUS:-}" = UPGRADE ]; then\n${stop('stop', 'stop')}fi\n${purge}`;
|
|
88
98
|
}
|
|
89
99
|
return `#!/bin/sh
|
|
90
100
|
${workerStop}${renderUserManagerRecovery()}${renderBackgroundWorkerStart(pkg)}case "$1" in
|
|
@@ -143,3 +153,27 @@ case "$1" in
|
|
|
143
153
|
esac
|
|
144
154
|
`;
|
|
145
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* A real uninstall stops the daemon, runs the App hook, then asks the
|
|
158
|
+
* executor to purge; an upgrade only stops both.
|
|
159
|
+
*/
|
|
160
|
+
function renderExecutorPreuninst(manifest, workerStop, stopWorker, workerResult, request) {
|
|
161
|
+
const pkg = manifest.package;
|
|
162
|
+
const choose = 'action=purge\n[ "${SYNOPKG_PKG_STATUS:-}" != UPGRADE ] || action=stop\n';
|
|
163
|
+
const hook = manifest.uninstall ? '[ "$action" = stop ] || synopkgland_uninstall_hook\n' : "";
|
|
164
|
+
const purge = request(manifest.uninstall ? '"$action" $synopkgland_purge_args' : '"$action"');
|
|
165
|
+
if (!manifest.daemon)
|
|
166
|
+
return `#!/bin/sh\n${workerStop}${renderUninstallHook(manifest)}${stopWorker}${choose}${hook}${purge}${workerResult}`;
|
|
167
|
+
const control = `"/var/packages/${pkg}/target/daemon/control"`;
|
|
168
|
+
const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"`;
|
|
169
|
+
const daemonAction = manifest.daemon.uninstall_args ? 'daemon_action=stop\n[ "$action" = stop ] || daemon_action=uninstall\n' : "daemon_action=stop\n";
|
|
170
|
+
return `#!/bin/sh\n${workerStop}${renderUninstallHook(manifest)}${stopWorker}${choose}${daemonAction}daemon_result=0\n${control} "$daemon_action" ${paths} || daemon_result=$?\n${hook}${purge}exit "$daemon_result"\n`;
|
|
171
|
+
}
|
|
172
|
+
/** The daemon stops (or uninstalls) first; the App hook follows on a real uninstall. */
|
|
173
|
+
function renderDaemonPreuninst(manifest) {
|
|
174
|
+
const pkg = manifest.package;
|
|
175
|
+
const control = `"/var/packages/${pkg}/target/daemon/control"`;
|
|
176
|
+
const paths = `"\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}" "\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"`;
|
|
177
|
+
return `if [ "\${SYNOPKG_PKG_STATUS:-}" = UPGRADE ]; then\n exec ${control} stop ${paths}\nfi\n`
|
|
178
|
+
+ `${control} ${manifest.daemon?.uninstall_args ? "uninstall" : "stop"} ${paths}\ndaemon_result=$?\nsynopkgland_uninstall_hook\nexit "$daemon_result"\n`;
|
|
179
|
+
}
|
|
@@ -6,10 +6,12 @@ import { type Catalogs, type InfoTranslations } from "./i18n.js";
|
|
|
6
6
|
export type { InfoText } from "./i18n.js";
|
|
7
7
|
import { type RootOp } from "./root-ops.js";
|
|
8
8
|
import { type Daemon } from "./daemon.js";
|
|
9
|
+
import { type Uninstall } from "./uninstall.js";
|
|
9
10
|
import { type Background } from "./background.js";
|
|
10
11
|
import { type SharedFolder } from "./shared-folder.js";
|
|
11
12
|
import { type ApiPermissions } from "./api-permissions.js";
|
|
12
13
|
import { type PortalDeclaration } from "../portal/alias.js";
|
|
14
|
+
import { type SdkBackend } from "../runtime/sdk-source.js";
|
|
13
15
|
/**
|
|
14
16
|
* `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
|
|
15
17
|
* runs as its own DSM package account with a CGI backend and a DSM window UI.
|
|
@@ -19,6 +21,12 @@ export type StandaloneBackend = {
|
|
|
19
21
|
kind: "go";
|
|
20
22
|
dir: string;
|
|
21
23
|
main: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "rust";
|
|
26
|
+
dir: string;
|
|
27
|
+
package: string;
|
|
28
|
+
bin: string;
|
|
29
|
+
worker: string;
|
|
22
30
|
} | {
|
|
23
31
|
kind: "binary";
|
|
24
32
|
files: Partial<Record<NativeArchFamily, string>>;
|
|
@@ -63,6 +71,8 @@ export interface StandaloneManifest {
|
|
|
63
71
|
tasks: ExecutorTask[];
|
|
64
72
|
};
|
|
65
73
|
daemon?: Daemon;
|
|
74
|
+
/** `uninstall`: the App's own purge hook and the state the wizard may delete. */
|
|
75
|
+
uninstall?: Uninstall;
|
|
66
76
|
/** `install.sharedFolder`: the Shared Folder the install wizard selects or `data-share` creates. */
|
|
67
77
|
sharedFolder?: SharedFolder;
|
|
68
78
|
/** Who may use the app at all; absent keeps the route policy alone. */
|
|
@@ -97,6 +107,8 @@ export type ManifestResult = {
|
|
|
97
107
|
};
|
|
98
108
|
export declare const KNOWN_FIELDS: Set<string>;
|
|
99
109
|
export declare function loadStandaloneManifest(root: string): ManifestResult;
|
|
110
|
+
/** Where an App's SDK version is declared: its Go or Rust backend; a prebuilt binary declares it in the project's go.mod. */
|
|
111
|
+
export declare function sdkBackend(m: Pick<StandaloneManifest, "backend" | "root">): SdkBackend;
|
|
100
112
|
/**
|
|
101
113
|
* The nearest directory at or above `start` that holds dsm-package.yaml,
|
|
102
114
|
* unless a Managed Profile project (synology-app.yaml) is found first.
|
|
@@ -7,14 +7,15 @@ import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
|
7
7
|
import { checkGlossary, loadCatalogs, readInfoTranslations } from "./i18n.js";
|
|
8
8
|
import { loadRootOps, ROOT_OPS_DIR } from "./root-ops.js";
|
|
9
9
|
import { readDaemon } from "./daemon.js";
|
|
10
|
+
import { readUninstall } from "./uninstall.js";
|
|
10
11
|
import { readBackground } from "./background.js";
|
|
11
12
|
import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
|
|
12
13
|
import { readSharedFolder } from "./shared-folder.js";
|
|
13
|
-
import { floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
|
|
14
|
+
import { API_PERMISSIONS_FILE, floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
|
|
14
15
|
import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration } from "../portal/alias.js";
|
|
15
16
|
import { renderPackageIcons } from "../build-spk/dsm-icons.js";
|
|
16
17
|
import { validateReproducible } from "../reproducible-config.js";
|
|
17
|
-
import {
|
|
18
|
+
import { appSdkVersion, SDK_CRATE, SDK_MODULE } from "../runtime/sdk-source.js";
|
|
18
19
|
import { sdkProblems } from "./sdk-check.js";
|
|
19
20
|
/**
|
|
20
21
|
* `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
|
|
@@ -23,13 +24,15 @@ import { sdkProblems } from "./sdk-check.js";
|
|
|
23
24
|
export const STANDALONE_MANIFEST = "dsm-package.yaml";
|
|
24
25
|
export const KNOWN_FIELDS = new Set([
|
|
25
26
|
"schema", "package", "version", "build", "displayname", "description",
|
|
26
|
-
"metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "entry", "portal", "icon", "reproducible",
|
|
27
|
+
"metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "uninstall", "entry", "portal", "icon", "reproducible",
|
|
27
28
|
]);
|
|
28
29
|
const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
|
|
29
30
|
const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
|
|
30
31
|
const VERSION = /^\d+(\.\d+){0,3}$/;
|
|
31
32
|
const OS_MIN_VER = /^\d+\.\d+(\.\d+)?-\d+$/;
|
|
32
33
|
const GO_MAIN = /^\.\/[A-Za-z0-9_./-]*$/;
|
|
34
|
+
/** A Cargo package or binary name. */
|
|
35
|
+
const CARGO_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
33
36
|
function isRecord(value) {
|
|
34
37
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
38
|
}
|
|
@@ -132,17 +135,19 @@ function readManifest(raw, c) {
|
|
|
132
135
|
const arches = readArches(raw.arches, c);
|
|
133
136
|
const backend = arches && readBackend(raw.backend, arches, c);
|
|
134
137
|
let permissions;
|
|
135
|
-
if (backend
|
|
138
|
+
if (backend && backend.kind !== "binary") {
|
|
136
139
|
try {
|
|
137
|
-
permissions = readApiPermissions(backend
|
|
140
|
+
permissions = readApiPermissions(backend, capabilities);
|
|
138
141
|
}
|
|
139
142
|
catch (error) {
|
|
140
|
-
// The SDK's own routes come from its module; a failed download
|
|
141
|
-
|
|
143
|
+
// The SDK's own routes come from its module; a failed download or an
|
|
144
|
+
// untagged SDK (reported by sdkProblems) is not a policy problem.
|
|
145
|
+
const message = error.message;
|
|
146
|
+
const download = /^\[SDK_DOWNLOAD_FAILED\] ([\s\S]*)$/.exec(message);
|
|
142
147
|
if (download)
|
|
143
148
|
c.fail("SDK_DOWNLOAD_FAILED", download[1]);
|
|
144
|
-
else
|
|
145
|
-
c.fail("STANDALONE_API_PERMISSIONS_INVALID", `backend
|
|
149
|
+
else if (!message.startsWith("[SDK_TAG_REQUIRED]"))
|
|
150
|
+
c.fail("STANDALONE_API_PERMISSIONS_INVALID", `backend/${API_PERMISSIONS_FILE[backend.kind]} must define the centralized API policy: ${String(error)}`);
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
const daemon = arches && readDaemon(raw.daemon, c.root, arches, e => c.fail(e.code, e.message));
|
|
@@ -169,12 +174,13 @@ function readManifest(raw, c) {
|
|
|
169
174
|
c.fail("CAPABILITY_COMBINATION_INVALID", "capabilities.identity 需要獨立登入頁;plain UI 不支援,請改用 vue UI(init --ui vue)。");
|
|
170
175
|
if (capabilities.delegate?.scope === "install-selected" && ui && !ui.build)
|
|
171
176
|
c.fail("CAPABILITY_COMBINATION_INVALID", "delegate scope install-selected 只能在 Vue 面板確認;plain UI 不支援,請改用 vue UI(init --ui vue)。");
|
|
172
|
-
if (backend
|
|
173
|
-
for (const problem of sdkProblems(backend
|
|
177
|
+
if (backend && backend.kind !== "binary") {
|
|
178
|
+
for (const problem of sdkProblems(backend, ui?.build?.cwd, Boolean(capabilities.delegate || capabilities.rootOps)))
|
|
174
179
|
c.fail(problem.code, problem.message);
|
|
175
180
|
}
|
|
176
181
|
const i18n = raw.i18n === undefined ? undefined : readI18n(raw.i18n, c);
|
|
177
182
|
const icon = raw.icon === undefined ? undefined : readIcon(raw.icon, c);
|
|
183
|
+
const uninstall = readUninstall(raw.uninstall, { rootOps: capabilities.rootOps === true, ops: rootOps, en: i18n?.catalogs?.en }, e => c.fail(e.code, e.message));
|
|
178
184
|
const portal = raw.portal === undefined || !packageId ? undefined : readPortalDeclaration(raw.portal, packageId)
|
|
179
185
|
?? c.fail("STANDALONE_PORTAL_INVALID", "portal 必須是 {} 或 { alias: <小寫英數與 -,1–64 字,不可為 DSM 保留路徑> }。");
|
|
180
186
|
if (portal && !capabilities.rootOps && !capabilities.delegate) {
|
|
@@ -210,12 +216,17 @@ function readManifest(raw, c) {
|
|
|
210
216
|
capabilities,
|
|
211
217
|
...(rootOps ? { rootOps } : {}),
|
|
212
218
|
...(daemon ? { daemon } : {}),
|
|
219
|
+
...(uninstall ? { uninstall } : {}),
|
|
213
220
|
...(sharedFolder ? { sharedFolder } : {}),
|
|
214
221
|
...(entry ? { entry } : {}),
|
|
215
222
|
...(portal ? { portal } : {}),
|
|
216
223
|
...(icon ? { icon } : {}),
|
|
217
224
|
};
|
|
218
225
|
}
|
|
226
|
+
/** Where an App's SDK version is declared: its Go or Rust backend; a prebuilt binary declares it in the project's go.mod. */
|
|
227
|
+
export function sdkBackend(m) {
|
|
228
|
+
return m.backend.kind === "binary" ? { kind: "go", dir: m.root } : m.backend;
|
|
229
|
+
}
|
|
219
230
|
function readIcon(value, c) {
|
|
220
231
|
const path = c.path(value, "icon");
|
|
221
232
|
if (!path)
|
|
@@ -228,6 +239,20 @@ function readIcon(value, c) {
|
|
|
228
239
|
return c.fail("STANDALONE_ICON_INVALID", `icon:${error.message}`);
|
|
229
240
|
}
|
|
230
241
|
}
|
|
242
|
+
/** Where each backend kind's CGI main wraps its authenticator in the SDK entry gate. */
|
|
243
|
+
const ENTRY_GATE = {
|
|
244
|
+
go: { main: b => b.kind === "go" ? join(b.dir, b.main, "main.go") : "", call: "dsm.NewEntryGate(", sdk: `backend/go.mod 要 require ${SDK_MODULE}` },
|
|
245
|
+
rust: { main: b => b.kind === "rust" ? join(b.dir, "src/bin", `${b.bin}.rs`) : "", call: "new_entry_gate(", sdk: `backend/Cargo.toml 要以 release tag 相依 ${SDK_CRATE}` },
|
|
246
|
+
};
|
|
247
|
+
/** The SDK version, or none when the backend has none or pins it wrongly (reported by sdkProblems). */
|
|
248
|
+
function sdkVersionOrUndefined(backend) {
|
|
249
|
+
try {
|
|
250
|
+
return appSdkVersion(backend);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
231
256
|
/**
|
|
232
257
|
* The CGI enforces the entry mode only when the project's backend carries the
|
|
233
258
|
* SDK gate and main wires it; `-X main.entry` on an older main would be
|
|
@@ -239,11 +264,12 @@ function readEntry(value, backend, policy, c) {
|
|
|
239
264
|
}
|
|
240
265
|
if (!backend)
|
|
241
266
|
return undefined;
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
267
|
+
const gate = ENTRY_GATE[backend.kind];
|
|
268
|
+
const main = gate?.main(backend) ?? "";
|
|
269
|
+
if (!gate || !sdkVersionOrUndefined(backend)
|
|
270
|
+
|| !existsSync(main) || !readFileSync(main, "utf8").includes(gate.call)) {
|
|
245
271
|
const shown = main ? relative(c.root, main) : "backend.main 的 main.go";
|
|
246
|
-
return c.fail("STANDALONE_ENTRY_UNSUPPORTED", `entry 需要 SDK 的 entry gate
|
|
272
|
+
return c.fail("STANDALONE_ENTRY_UNSUPPORTED", `entry 需要 SDK 的 entry gate:${gate?.sdk ?? `backend/go.mod 要 require ${SDK_MODULE}`},並在 ${shown} 以 ${gate?.call.slice(0, -1) ?? "dsm.NewEntryGate"} 包住 authenticator。`);
|
|
247
273
|
}
|
|
248
274
|
if (value === "app-privilege" && !permitsOrdinaryUsers(policy)) {
|
|
249
275
|
return c.fail("STANDALONE_ENTRY_INVALID", "entry: app-privilege 需要至少一個 permissions.json 標為 authenticated 的 route,一般使用者才有功能可用。");
|
|
@@ -274,9 +300,11 @@ function readArches(value, c) {
|
|
|
274
300
|
: c.fail("STANDALONE_ARCHES_INVALID", "arches 必須是不重複的 x86_64/aarch64/armv7 清單,至少一個。");
|
|
275
301
|
}
|
|
276
302
|
function readBackend(value, arches, c) {
|
|
277
|
-
if (!isRecord(value) || !["go", "binary"].includes(String(value.kind))) {
|
|
278
|
-
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind 必須是 go 或 binary。");
|
|
303
|
+
if (!isRecord(value) || !["go", "rust", "binary"].includes(String(value.kind))) {
|
|
304
|
+
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind 必須是 go、rust 或 binary。");
|
|
279
305
|
}
|
|
306
|
+
if (value.kind === "rust")
|
|
307
|
+
return readRustBackend(value, c);
|
|
280
308
|
if (value.kind === "go") {
|
|
281
309
|
if (Object.keys(value).some(key => !["kind", "dir", "main"].includes(key)) || (value.main !== undefined && (typeof value.main !== "string" || !GO_MAIN.test(value.main) || value.main.includes("..")))) {
|
|
282
310
|
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind: go 需要 dir,可選 main(預設 ./cmd/app)。");
|
|
@@ -303,6 +331,16 @@ function readBackend(value, arches, c) {
|
|
|
303
331
|
}
|
|
304
332
|
return Object.values(files).every(Boolean) ? { kind: "binary", files } : undefined;
|
|
305
333
|
}
|
|
334
|
+
function readRustBackend(value, c) {
|
|
335
|
+
const { package: pkg, bin = "api", worker = "background-worker" } = value;
|
|
336
|
+
const names = [pkg, bin, worker];
|
|
337
|
+
if (Object.keys(value).some(key => !["kind", "dir", "package", "bin", "worker"].includes(key))
|
|
338
|
+
|| !names.every(name => typeof name === "string" && CARGO_NAME.test(name)) || bin === worker) {
|
|
339
|
+
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind: rust 需要 dir 與 package(Cargo package),可選 bin(CGI,預設 api)與 worker(背景程序,預設 background-worker),兩者不可相同。");
|
|
340
|
+
}
|
|
341
|
+
const dir = c.path(value.dir, "backend.dir");
|
|
342
|
+
return dir ? { kind: "rust", dir, package: pkg, bin: bin, worker: worker } : undefined;
|
|
343
|
+
}
|
|
306
344
|
function readUi(value, c) {
|
|
307
345
|
if (!isRecord(value)) {
|
|
308
346
|
return c.fail("STANDALONE_UI_INVALID", "ui 必須包含 dir。");
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type PackageMetadata } from "../package-metadata.js";
|
|
2
2
|
import type { CommandRunner } from './build.js';
|
|
3
|
+
import { type Toolkit } from './toolkit.js';
|
|
3
4
|
import { type PortalDeclaration } from '../portal/alias.js';
|
|
4
5
|
export declare const LAYOUT_ARCHES: {
|
|
5
6
|
readonly x86_64: readonly [2, 62];
|
|
@@ -13,10 +14,6 @@ type Hook = {
|
|
|
13
14
|
cwd: string;
|
|
14
15
|
env: Record<string, string>;
|
|
15
16
|
};
|
|
16
|
-
type Toolkit = {
|
|
17
|
-
url: string;
|
|
18
|
-
sha256: string;
|
|
19
|
-
};
|
|
20
17
|
type Binary = {
|
|
21
18
|
path: string;
|
|
22
19
|
linkage: 'static' | 'dynamic';
|
|
@@ -65,9 +62,6 @@ export declare function loadNativeLayout(root: string): {
|
|
|
65
62
|
message: string;
|
|
66
63
|
}[];
|
|
67
64
|
};
|
|
68
|
-
export declare function verifyToolkitArchive(path: string, expected: string): void;
|
|
69
|
-
/** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
|
|
70
|
-
export declare function toolkitCacheDir(env: Record<string, string | undefined>, platform: string, home: string): string;
|
|
71
65
|
export declare function layoutFiles(root: string): Array<{
|
|
72
66
|
path: string;
|
|
73
67
|
mode: number;
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
|
|
2
2
|
/** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync,
|
|
5
|
-
import {
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { parse } from 'yaml';
|
|
8
8
|
import { createReproducibleTar } from '../build-spk/reproducible-archive.js';
|
|
9
9
|
import { runCommand, runnerFor } from './build.js';
|
|
10
10
|
import { checkJobs, commitOutputs, forEachArch } from './arch-jobs.js';
|
|
11
|
+
import { prepareToolkit } from './toolkit.js';
|
|
11
12
|
import { validateReproducible } from '../reproducible-config.js';
|
|
12
13
|
import { isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, resolveMinDsm } from '../build-spk/dsm-version.js';
|
|
13
14
|
import { portalEntryProblem, readPortalDeclaration } from '../portal/alias.js';
|
|
@@ -164,100 +165,6 @@ export function loadNativeLayout(root) {
|
|
|
164
165
|
return { ok: false, errors: [{ code: 'NATIVE_LAYOUT_INVALID', message: String(error) }] };
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
|
-
export function verifyToolkitArchive(path, expected) {
|
|
168
|
-
if (!lstatSync(path).isFile() || digest(readFileSync(path)) !== expected)
|
|
169
|
-
fail('toolkit checksum mismatch');
|
|
170
|
-
}
|
|
171
|
-
/** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
|
|
172
|
-
export function toolkitCacheDir(env, platform, home) {
|
|
173
|
-
if (env.SYNOPKGLAND_CACHE_DIR)
|
|
174
|
-
return resolve(env.SYNOPKGLAND_CACHE_DIR);
|
|
175
|
-
if (platform === 'darwin')
|
|
176
|
-
return join(home, 'Library', 'Caches', 'synopkgland');
|
|
177
|
-
const xdg = env.XDG_CACHE_HOME;
|
|
178
|
-
return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.cache'), 'synopkgland');
|
|
179
|
-
}
|
|
180
|
-
// Concurrent architectures that pin the same archive share one download.
|
|
181
|
-
const downloads = new Map();
|
|
182
|
-
async function download(m, cache, input, archive, run) {
|
|
183
|
-
const tmp = mkdtempSync(join(cache, '.download-'));
|
|
184
|
-
try {
|
|
185
|
-
const out = join(tmp, 'archive');
|
|
186
|
-
const r = await run('curl', ['--fail', '--location', '--proto', '=https', '--proto-redir', '=https', '--retry', '2', '--retry-all-errors', '--connect-timeout', '20', '--max-time', '600', '--output', out, input.url], { cwd: m.root, env: { ...process.env } });
|
|
187
|
-
if (r.status !== 0)
|
|
188
|
-
fail('toolkit download failed');
|
|
189
|
-
// Rename is atomic, so another build process sees either no archive or a verified one.
|
|
190
|
-
verifyToolkitArchive(out, input.sha256);
|
|
191
|
-
renameSync(out, archive);
|
|
192
|
-
}
|
|
193
|
-
finally {
|
|
194
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
async function toolkitFor(m, arch, run) {
|
|
198
|
-
const inputs = m.native.toolkits[arch] ?? [];
|
|
199
|
-
if (!inputs.length)
|
|
200
|
-
return '';
|
|
201
|
-
const cache = toolkitCacheDir(process.env, process.platform, homedir());
|
|
202
|
-
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
203
|
-
if (lstatSync(cache).isSymbolicLink() || (lstatSync(cache).mode & 0o022))
|
|
204
|
-
fail('toolkit cache must be owned and private');
|
|
205
|
-
const archives = [];
|
|
206
|
-
for (const input of inputs) {
|
|
207
|
-
const archive = join(cache, `${input.sha256}.txz`);
|
|
208
|
-
if (!existsSync(archive)) {
|
|
209
|
-
let pending = downloads.get(archive);
|
|
210
|
-
if (!pending) {
|
|
211
|
-
pending = download(m, cache, input, archive, run).finally(() => downloads.delete(archive));
|
|
212
|
-
downloads.set(archive, pending);
|
|
213
|
-
}
|
|
214
|
-
await pending;
|
|
215
|
-
}
|
|
216
|
-
verifyToolkitArchive(archive, input.sha256);
|
|
217
|
-
archives.push(archive);
|
|
218
|
-
}
|
|
219
|
-
// Re-extract verified bytes: a package-writable marker never authenticates an extracted tree.
|
|
220
|
-
const stage = mkdtempSync(join(cache, `toolkit-${arch}-`));
|
|
221
|
-
try {
|
|
222
|
-
const flavor = await tarFlavor(m, run);
|
|
223
|
-
for (const archive of archives) {
|
|
224
|
-
if (flavor === 'bsdtar')
|
|
225
|
-
await refuseEscapingMembers(m, archive, run);
|
|
226
|
-
const r = await run('tar', flavor === 'gnu' ? ['-xJf', archive, '-C', stage, '--keep-directory-symlink'] : ['-xPJf', archive, '-C', stage], { cwd: m.root, env: { ...process.env } });
|
|
227
|
-
if (r.status !== 0)
|
|
228
|
-
fail('toolkit extraction failed');
|
|
229
|
-
}
|
|
230
|
-
return stage;
|
|
231
|
-
}
|
|
232
|
-
catch (e) {
|
|
233
|
-
rmSync(stage, { recursive: true, force: true });
|
|
234
|
-
throw e;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* A later toolkit archive may write through a directory symlink an earlier one
|
|
239
|
-
* created. GNU tar keeps such a symlink only with --keep-directory-symlink;
|
|
240
|
-
* bsdtar only with -P, which also allows absolute and .. members, so those are
|
|
241
|
-
* refused from the listing first.
|
|
242
|
-
*/
|
|
243
|
-
async function tarFlavor(m, run) {
|
|
244
|
-
const r = await run('tar', ['--version'], { cwd: m.root, env: { ...process.env }, capture: true });
|
|
245
|
-
const version = r.status === 0 ? r.stdout ?? '' : '';
|
|
246
|
-
if (/\bGNU tar\b/.test(version))
|
|
247
|
-
return 'gnu';
|
|
248
|
-
if (/\bbsdtar\b/.test(version))
|
|
249
|
-
return 'bsdtar';
|
|
250
|
-
throw Object.assign(new Error(`NATIVE_TOOLKIT_TAR_UNSUPPORTED: extracting native.toolkits needs GNU tar or bsdtar as \`tar\`; found ${r.missing ? 'no tar' : JSON.stringify(version.split('\n')[0] ?? '')}`), { code: 'NATIVE_TOOLKIT_TAR_UNSUPPORTED' });
|
|
251
|
-
}
|
|
252
|
-
async function refuseEscapingMembers(m, archive, run) {
|
|
253
|
-
const r = await run('tar', ['-tJf', archive], { cwd: m.root, env: { ...process.env }, capture: true });
|
|
254
|
-
if (r.status !== 0)
|
|
255
|
-
fail('toolkit extraction failed');
|
|
256
|
-
for (const member of (r.stdout ?? '').split('\n').filter(Boolean)) {
|
|
257
|
-
if (member.startsWith('/') || member.split('/').includes('..'))
|
|
258
|
-
fail(`toolkit member escapes the toolkit folder: ${member}`);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
168
|
async function runHook(m, h, env, run) {
|
|
262
169
|
const [cmd, ...args] = h.command;
|
|
263
170
|
const r = await run(cmd, args, { cwd: inside(m.root, h.cwd), env: { ...process.env, ...h.env, ...env } });
|
|
@@ -396,7 +303,7 @@ export async function buildNativeLayout(options) {
|
|
|
396
303
|
await runHook(m, m.native.prepare, env, options.run ?? runCommand);
|
|
397
304
|
const outputs = await commitOutputs(options.outDir ?? join(m.root, 'dist'), (outDir) => forEachArch(arches, jobs, async (arch) => {
|
|
398
305
|
const run = runnerFor(options.run, jobs, arch);
|
|
399
|
-
const toolkit = await
|
|
306
|
+
const toolkit = await prepareToolkit(m.root, arch, m.native.toolkits[arch] ?? [], run);
|
|
400
307
|
const stage = mkdtempSync(join(tmpdir(), 'synopkgland-layout-'));
|
|
401
308
|
const work = mkdtempSync(join(tmpdir(), 'synopkgland-work-'));
|
|
402
309
|
try {
|
|
@@ -12,4 +12,6 @@ export type RootOpsResult = {
|
|
|
12
12
|
errors: StandaloneError[];
|
|
13
13
|
};
|
|
14
14
|
export declare const ROOT_OPS_DIR = "root";
|
|
15
|
+
/** Shell that tries to become root: sudo, chown root or setuid/setgid modes. */
|
|
16
|
+
export declare const ROOT_SCRIPT_HINTS: RegExp[];
|
|
15
17
|
export declare function loadRootOps(dir: string): RootOpsResult;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
export const ROOT_OPS_DIR = "root";
|
|
4
|
+
/** Shell that tries to become root: sudo, chown root or setuid/setgid modes. */
|
|
5
|
+
export const ROOT_SCRIPT_HINTS = [/\bsudo\b/, /\bchown\s+(-\w+\s+)*root\b/, /chmod\s+[ugo]*\+s\b/, /chmod\s+[2467][0-7]{3}\b/];
|
|
4
6
|
const MAX_ROOT_OP_BYTES = 16 * 1024;
|
|
5
7
|
const OP_FILE = /^([a-z][a-z0-9_]{0,31})\.sh$/;
|
|
6
8
|
export function loadRootOps(dir) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
2
|
+
import type { CommandRunner } from "./build.js";
|
|
3
|
+
import type { StandaloneManifest } from "./manifest.js";
|
|
4
|
+
import { type Toolkit } from "./toolkit.js";
|
|
5
|
+
/** The glibc loader of DSM's ARMv7 models, the one interpreter an armv7 Rust CGI may name. */
|
|
6
|
+
export declare const ARMV7_INTERPRETER = "/lib/ld-linux-armhf.so.3";
|
|
7
|
+
interface RustTarget {
|
|
8
|
+
triple: string;
|
|
9
|
+
/** The linker, given the extracted toolkit folder when the target pins one. */
|
|
10
|
+
linker: (toolkit: string) => string;
|
|
11
|
+
linkArgs: string[];
|
|
12
|
+
toolkit?: Toolkit;
|
|
13
|
+
interpreter?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* x86_64 and aarch64 link static musl binaries with rust-lld. armv7 links
|
|
17
|
+
* against the glibc of the DSM 7.0 toolkit, because musl's 64-bit time calls
|
|
18
|
+
* fail on DSM's ARMv7 kernels; that gcc runs on Linux x86_64 only.
|
|
19
|
+
*/
|
|
20
|
+
export declare const RUST_TARGETS: Record<NativeArchFamily, RustTarget>;
|
|
21
|
+
/** Go's `-X` values for a Rust App, read with `option_env!`; every one is set, empty when unused. */
|
|
22
|
+
export declare function rustBuildEnv(manifest: StandaloneManifest): Record<string, string>;
|
|
23
|
+
export interface RustBuildDeps {
|
|
24
|
+
host?: {
|
|
25
|
+
platform: string;
|
|
26
|
+
arch: string;
|
|
27
|
+
};
|
|
28
|
+
prepareToolkit?: (cwd: string, arch: string, inputs: Toolkit[], run: CommandRunner) => Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
/** Builds the CGI bin, and the worker bin with capabilities.jobs, in one `cargo build` into `work`. */
|
|
31
|
+
export declare function buildRustBackend(manifest: StandaloneManifest, arch: NativeArchFamily, work: string, run: CommandRunner, { host, prepareToolkit: prepare }?: RustBuildDeps): Promise<{
|
|
32
|
+
cgi: string;
|
|
33
|
+
worker?: string;
|
|
34
|
+
}>;
|
|
35
|
+
export {};
|