@synopackageland/cli 0.41.12 → 0.42.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +27 -12
  3. package/dist/ctl/cli.d.ts +5 -0
  4. package/dist/ctl/cli.js +12 -5
  5. package/dist/ctl/descriptor.js +2 -1
  6. package/dist/ctl/input.d.ts +5 -1
  7. package/dist/ctl/input.js +28 -14
  8. package/dist/ctl/pattern.d.ts +12 -0
  9. package/dist/ctl/pattern.js +10 -0
  10. package/dist/ctl/redaction.js +21 -2
  11. package/dist/ctl/service.d.ts +17 -5
  12. package/dist/ctl/service.js +33 -22
  13. package/dist/e2e/app-open.js +5 -1
  14. package/dist/error-registry.js +2 -2
  15. package/dist/host/package-commands.d.ts +15 -0
  16. package/dist/host/package-commands.js +15 -4
  17. package/dist/runtime/sdk-source.d.ts +2 -0
  18. package/dist/runtime/sdk-source.js +2 -0
  19. package/dist/spk/archive-inventory.d.ts +9 -0
  20. package/dist/spk/archive-inventory.js +65 -0
  21. package/dist/spk/check-payload.js +9 -0
  22. package/dist/spk/rules.d.ts +5 -0
  23. package/dist/spk/rules.js +1 -0
  24. package/dist/spk/validate-spk.js +12 -0
  25. package/dist/standalone/compose.js +4 -4
  26. package/dist/standalone/daemon-ingress.d.ts +3 -2
  27. package/dist/standalone/daemon-ingress.js +12 -9
  28. package/dist/standalone/daemon-restart.d.ts +1 -1
  29. package/dist/standalone/daemon-restart.js +3 -3
  30. package/dist/standalone/daemon.d.ts +2 -2
  31. package/dist/standalone/daemon.js +5 -5
  32. package/dist/standalone/inspect-bridge.d.ts +3 -1
  33. package/dist/standalone/inspect-bridge.js +6 -3
  34. package/dist/standalone/inspect-spk.js +27 -7
  35. package/dist/standalone/install-settings.js +2 -2
  36. package/dist/standalone/manifest.js +1 -1
  37. package/dist/standalone/native-layout.js +18 -1
  38. package/dist/template-paths.d.ts +0 -4
  39. package/dist/template-paths.js +0 -11
  40. package/docs/ctl-external.md +16 -0
  41. package/package.json +3 -2
  42. package/skills/SKILL.md +1 -1
  43. package/skills/reference/ctl.md +24 -0
  44. package/skills/reference/daemon-mode.md +5 -0
  45. package/skills/reference/install.md +1 -1
@@ -122,6 +122,8 @@ export async function installPackage(spec, options = {}, connection) {
122
122
  if (installed.has(item.package) &&
123
123
  compareVersions(item.version, installed.get(item.package)) < 0)
124
124
  throw fail("PACKAGE_DOWNGRADE_REFUSED", `Refusing downgrade of ${item.package}`, "Choose the installed version or a newer release");
125
+ const changes = plan.filter((item) => !installed.has(item.package) ||
126
+ compareVersions(item.version, installed.get(item.package)) !== 0);
125
127
  const publicPlan = {
126
128
  host: nas.name,
127
129
  target,
@@ -130,21 +132,30 @@ export async function installPackage(spec, options = {}, connection) {
130
132
  package: item.package,
131
133
  version: item.version,
132
134
  size: item.size,
133
- action: installed.has(item.package) ? "upgrade" : "install",
135
+ action: !installed.has(item.package)
136
+ ? "install"
137
+ : compareVersions(item.version, installed.get(item.package)) === 0
138
+ ? "none"
139
+ : "upgrade",
134
140
  ...(item.package === root.package && report
135
141
  ? { signed: report.signed }
136
142
  : {}),
137
143
  })),
138
144
  parameters: Object.keys(options.param ?? {}),
145
+ ...(!changes.length
146
+ ? { message: `${root.package}@${root.version} is already up to date; no package changes required.` }
147
+ : {}),
139
148
  };
140
149
  if (options.dryRun)
141
150
  return { dryRun: true, ...publicPlan };
151
+ if (!changes.length)
152
+ return publicPlan;
142
153
  await requireAdministrator(nas);
143
- await confirm(`Install ${plan.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
154
+ await confirm(`Install ${changes.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
144
155
  // Download, verify and inspect the entire plan before the first NAS write.
145
156
  const files = new Map();
146
157
  const images = new Map();
147
- for (const entry of plan) {
158
+ for (const entry of changes) {
148
159
  let file = localFile;
149
160
  if (!local || entry.package !== root.package) {
150
161
  const response = await fetchPackageSource(entry.link);
@@ -163,7 +174,7 @@ export async function installPackage(spec, options = {}, connection) {
163
174
  files.set(entry.package, file);
164
175
  images.set(entry.package, containerImageReferences(checked));
165
176
  }
166
- for (const entry of plan)
177
+ for (const entry of changes)
167
178
  await installPackageFromLocalSpk({
168
179
  dsmOrigin: nas.session.origin,
169
180
  cookieHeader: nas.session.cookie,
@@ -1,5 +1,7 @@
1
1
  export { goPrivateCovers } from './go-env.js';
2
2
  import { type ExecutorManifest } from './sdk-executor-manifest.js';
3
+ /** Pinned SDK for CLI-owned controllers and Compose planning; App pins take precedence. */
4
+ export declare const DEFAULT_SDK_VERSION = "0.41.1";
3
5
  export declare const SDK_PACKAGE = "@synopackageland/sdk";
4
6
  export declare const SDK_MODULE = "github.com/synopackageland/syno-pkg-land-sdk";
5
7
  export declare const EXECUTOR_MODULE = "github.com/synopackageland/syno-pkg-land-sdk/executor";
@@ -4,6 +4,8 @@ import { readFileSync } from 'node:fs';
4
4
  import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { readExecutorManifest, predatesExecutorManifest } from './sdk-executor-manifest.js';
7
+ /** Pinned SDK for CLI-owned controllers and Compose planning; App pins take precedence. */
8
+ export const DEFAULT_SDK_VERSION = '0.41.1';
7
9
  export const SDK_PACKAGE = '@synopackageland/sdk';
8
10
  export const SDK_MODULE = 'github.com/synopackageland/syno-pkg-land-sdk';
9
11
  export const EXECUTOR_MODULE = `${SDK_MODULE}/executor`;
@@ -0,0 +1,9 @@
1
+ export interface ArchiveIssue {
2
+ kind: 'path' | 'type' | 'duplicate' | 'collision' | 'unexpected';
3
+ path: string;
4
+ type: string;
5
+ message: string;
6
+ }
7
+ export declare const formatArchiveIssue: (issue: ArchiveIssue) => string;
8
+ /** Scan names and types before extraction can merge or replace archive members. */
9
+ export declare function archiveInventory(file: string, outer?: boolean, allowPayloadLinks?: boolean): Promise<ArchiveIssue[]>;
@@ -0,0 +1,65 @@
1
+ import * as tar from 'tar';
2
+ import { createReadStream } from 'node:fs';
3
+ import { KNOWN_TOP_LEVEL } from './dsm-spec.js';
4
+ export const formatArchiveIssue = (issue) => `SPK_ARCHIVE_${issue.kind.toUpperCase()} ${issue.message}`;
5
+ /** Scan names and types before extraction can merge or replace archive members. */
6
+ export async function archiveInventory(file, outer = false, allowPayloadLinks = false) {
7
+ const findings = [];
8
+ const names = new Map();
9
+ const spellings = new Map();
10
+ const scan = (entry) => {
11
+ const raw = entry.path;
12
+ const add = (kind, message) => findings.push({ kind, path: raw, type: entry.type, message });
13
+ const path = raw.replace(/^\.\//, '').replace(/\/$/, '');
14
+ if (!path || path === '.') {
15
+ if (entry.type !== 'Directory')
16
+ add('path', `invalid root entry: ${raw}`);
17
+ return;
18
+ }
19
+ const parts = path.split('/');
20
+ if (raw.startsWith('/') || path.includes('\\') || parts.some(p => !p || p === '.' || p === '..') || /[\x00-\x1f\x7f]/.test(path))
21
+ add('path', `noncanonical archive path: ${raw}`);
22
+ if (!['File', 'Directory', ...(allowPayloadLinks ? ['SymbolicLink', 'Link'] : [])].includes(entry.type))
23
+ add('type', `unexpected ${entry.type} entry: ${raw}`);
24
+ const previous = names.get(path);
25
+ if (previous)
26
+ add('duplicate', `repeated archive path: ${raw}`);
27
+ names.set(path, { path, type: entry.type });
28
+ // Include implicit parent directories: Bin/a and bin/b collide too.
29
+ for (let i = 1; i <= parts.length; i++) {
30
+ const prefix = parts.slice(0, i).join('/');
31
+ const folded = prefix.normalize('NFC').toLowerCase();
32
+ const spelling = spellings.get(folded);
33
+ if (spelling !== undefined && spelling !== prefix)
34
+ add('collision', `case or Unicode alias: ${spelling} and ${prefix}`);
35
+ spellings.set(folded, prefix);
36
+ }
37
+ if (outer && !KNOWN_TOP_LEVEL.includes(parts[0]))
38
+ add('unexpected', `unknown SPK metadata entry: ${raw}`);
39
+ else if (outer) {
40
+ const directory = ['scripts', 'conf', 'WIZARD_UIFILES'].includes(parts[0]);
41
+ if (!directory && (parts.length > 1 || entry.type === 'Directory') ||
42
+ directory && parts.length === 1 && entry.type === 'File')
43
+ add('type', `SPK metadata has the wrong file or directory role: ${raw}`);
44
+ }
45
+ };
46
+ const parser = new tar.Parser({ strict: true, onReadEntry(entry) { scan(entry); entry.resume(); } });
47
+ // tar.t silently drops unknown type flags; they are still archive members.
48
+ parser.on('ignoredEntry', scan);
49
+ const input = createReadStream(file);
50
+ await new Promise((resolve, reject) => {
51
+ input.on('error', reject);
52
+ parser.on('error', error => { input.destroy(); reject(error); });
53
+ parser.on('end', resolve);
54
+ input.pipe(parser);
55
+ });
56
+ for (const [path] of names) {
57
+ const parts = path.split('/');
58
+ for (let i = 1; i < parts.length; i++) {
59
+ const parent = names.get(parts.slice(0, i).join('/'));
60
+ if (parent && parent.type !== 'Directory')
61
+ findings.push({ kind: 'collision', path, type: parent.type, message: `file used as a directory: ${parent.path}` });
62
+ }
63
+ }
64
+ return findings;
65
+ }
@@ -2,6 +2,7 @@ import { openSync, readSync, closeSync } from "node:fs";
2
2
  import { posix } from "node:path";
3
3
  import * as tar from "tar";
4
4
  import { ARCH_ELF_MACHINE } from "./dsm-spec.js";
5
+ import { archiveInventory, formatArchiveIssue } from './archive-inventory.js';
5
6
  const XZ_MAGIC = Buffer.from([0xfd, 0x37, 0x7a, 0x58, 0x5a, 0x00]);
6
7
  const ELF_MAGIC = Buffer.from("\x7fELF", "latin1");
7
8
  const MAX_TEXT = 1024 * 1024;
@@ -145,6 +146,14 @@ export async function checkPayload(payloadPath, info, findings) {
145
146
  return;
146
147
  }
147
148
  const dirs = uiDirs(info.dsmuidir);
149
+ try {
150
+ for (const issue of await archiveInventory(payloadPath, false, true)) {
151
+ if (issue.kind === 'path' && (issue.path.startsWith('/') || posix.normalize(issue.path).split('/')[0] === '..'))
152
+ continue;
153
+ findings.add('SPK_ARCHIVE_INVALID', formatArchiveIssue(issue), at(issue.path));
154
+ }
155
+ }
156
+ catch { /* scanPayload reports unreadable archives */ }
148
157
  const configs = new Set(dirs.map((dir) => posix.join(dir, "config")));
149
158
  const files = await scanPayload(payloadPath, (path) => configs.has(path) || (/\/texts\/[^/]+\/strings$/.test(path) && dirs.some((dir) => path.startsWith(`${dir}/`))), findings);
150
159
  if (!files)
@@ -11,6 +11,11 @@ export interface SpkRule {
11
11
  source: string;
12
12
  }
13
13
  export declare const SPK_RULES: {
14
+ readonly SPK_ARCHIVE_INVALID: {
15
+ readonly severity: "error";
16
+ readonly description: "An archive has noncanonical or colliding names, repeated entries, or unsupported entry types.";
17
+ readonly source: "Archive inventory";
18
+ };
14
19
  readonly SPK_NOT_TAR: {
15
20
  readonly severity: "error";
16
21
  readonly description: "The file is not a readable tar archive; an SPK is an uncompressed tar.";
package/dist/spk/rules.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const GUIDE = "DSM 7 Developer Guide";
2
2
  export const SPK_RULES = {
3
+ SPK_ARCHIVE_INVALID: { severity: "error", description: "An archive has noncanonical or colliding names, repeated entries, or unsupported entry types.", source: "Archive inventory" },
3
4
  SPK_NOT_TAR: { severity: "error", description: "The file is not a readable tar archive; an SPK is an uncompressed tar.", source: `${GUIDE}: Package Structure` },
4
5
  SPK_ENTRY_UNSAFE: { severity: "error", description: "A path inside the SPK escapes the archive root (an absolute path or ..), or is a link.", source: `${GUIDE}: Package Structure` },
5
6
  SPK_ENTRY_UNKNOWN: { severity: "warning", description: "The SPK top level has a file or directory the guide does not list (file names are case-sensitive).", source: `${GUIDE}: Package Structure` },
@@ -8,6 +8,7 @@ import { checkMetadata } from "./check-metadata.js";
8
8
  import { checkPayload } from "./check-payload.js";
9
9
  import { KNOWN_TOP_LEVEL } from "./dsm-spec.js";
10
10
  import { Findings } from "./findings.js";
11
+ import { archiveInventory, formatArchiveIssue } from './archive-inventory.js';
11
12
  /** Outer files other than package.tgz are metadata; anything larger is not read. */
12
13
  const MAX_OUTER_FILE = 16 * 1024 * 1024;
13
14
  const TOP_LEVEL = new Set(KNOWN_TOP_LEVEL);
@@ -17,6 +18,17 @@ export async function validateSpk(spkPath) {
17
18
  const dir = mkdtempSync(join(tmpdir(), "syno-validate-spk-"));
18
19
  let info = {};
19
20
  try {
21
+ try {
22
+ for (const issue of await archiveInventory(spkPath, true)) {
23
+ // The existing outer rules already report escaping names and links.
24
+ if (issue.kind === 'unexpected' || issue.kind === 'type' && ['SymbolicLink', 'Link'].includes(issue.type))
25
+ continue;
26
+ if (issue.kind === 'path' && (issue.path.startsWith('/') || issue.path.split('/').includes('..')))
27
+ continue;
28
+ findings.add('SPK_ARCHIVE_INVALID', formatArchiveIssue(issue), issue.path);
29
+ }
30
+ }
31
+ catch { /* readOuter reports unreadable archives */ }
20
32
  const outer = await readOuter(spkPath, dir, findings);
21
33
  if (outer) {
22
34
  checkOuterEntries(outer.entries, findings);
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
9
9
  import { tmpdir } from "node:os";
10
10
  import { dirname, join } from "node:path";
11
11
  import { parse } from "yaml";
12
- import { templateSdkVersion } from "../template-paths.js";
12
+ import { DEFAULT_SDK_VERSION } from "../runtime/sdk-source.js";
13
13
  import { goChildEnv } from "../runtime/go-env.js";
14
14
  import { sdkSource } from "../runtime/sdk-source.js";
15
15
  import { readDaemon } from "./daemon.js";
@@ -106,10 +106,10 @@ export function readComposeApp(value, root, arches, fail, context = {}) {
106
106
  return invalid("compose.host_udp allows at most 32 distinct ports 1024-65535 with lowercase purpose labels of 1-64 characters.", "COMPOSE_HOST_UDP_INVALID");
107
107
  hostUdp = [...value.host_udp].map(l => ({ port: l.port, purpose: l.purpose })).sort((a, b) => a.port - b.port);
108
108
  }
109
- const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? templateSdkVersion());
109
+ const sdk = typeof value.sdk === "string" ? value.sdk : (context.sdk ?? DEFAULT_SDK_VERSION);
110
110
  let targets = {};
111
111
  if (value.helpers !== undefined) {
112
- const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail);
112
+ const d = readDaemon({ exec: "_sso/gateway", targets: value.helpers }, root, arches, fail, context.package);
113
113
  if (!d)
114
114
  return;
115
115
  targets = d.targets;
@@ -309,7 +309,7 @@ export function lockComposeImages(m, arch, resolveImages, imageProofs) {
309
309
  return { ...m, compose };
310
310
  }
311
311
  export function composeInputs(root, file, envFile) {
312
- const result = spawnSync(planner(templateSdkVersion()), [
312
+ const result = spawnSync(planner(DEFAULT_SDK_VERSION), [
313
313
  "sources",
314
314
  "--root",
315
315
  root,
@@ -1,13 +1,14 @@
1
1
  import type { StandaloneError } from "./manifest.js";
2
2
  /** `daemon.ingress`: one path in DSM's own server blocks, proxied to the daemon. */
3
3
  export interface DaemonIngress {
4
- /** `/<name>/`, served on DSM's ports and custom domains, so same-origin with DSM. */
4
+ /** `/synopkgland/<package>/`, with the exact manifest package ID. */
5
5
  path: string;
6
6
  /** nginx proxy_read_timeout and proxy_send_timeout in seconds. */
7
7
  timeout: number;
8
8
  }
9
9
  export declare const DAEMON_INGRESS_CONFIG = "daemon/ingress-nginx.conf";
10
- export declare function readDaemonIngress(value: unknown, fail: (error: StandaloneError) => void): DaemonIngress | undefined;
10
+ export declare const daemonIngressPath: (packageId: string) => string;
11
+ export declare function readDaemonIngress(value: unknown, packageId: string | undefined, fail: (error: StandaloneError) => void): DaemonIngress | undefined;
11
12
  /** DSM before 7.2 rejects an unsigned web-config, the worker this path uses. */
12
13
  export declare function daemonIngressMinDsmProblem(osMinVer: string | undefined): string | undefined;
13
14
  export declare const daemonIngressSocket: (pkg: string) => string;
@@ -1,9 +1,7 @@
1
1
  import { isDsmAtLeast, PORTAL_MIN_DSM } from "../build-spk/dsm-version.js";
2
- import { RESERVED_PORTAL_ALIASES } from "../portal/alias.js";
3
2
  export const DAEMON_INGRESS_CONFIG = "daemon/ingress-nginx.conf";
4
- const PATH = /^\/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\/$/;
5
- const reserved = (path) => RESERVED_PORTAL_ALIASES.includes(path.slice(1, -1));
6
- export function readDaemonIngress(value, fail) {
3
+ export const daemonIngressPath = (packageId) => `/synopkgland/${packageId}/`;
4
+ export function readDaemonIngress(value, packageId, fail) {
7
5
  if (value === undefined)
8
6
  return undefined;
9
7
  const v = value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
@@ -11,14 +9,14 @@ export function readDaemonIngress(value, fail) {
11
9
  if (!v ||
12
10
  Object.keys(v).some((k) => k !== "path" && k !== "timeout") ||
13
11
  typeof v.path !== "string" ||
14
- !PATH.test(v.path) ||
15
- reserved(v.path) ||
12
+ !packageId || !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(packageId) ||
13
+ v.path !== daemonIngressPath(packageId) ||
16
14
  !Number.isInteger(timeout) ||
17
15
  timeout < 1 ||
18
16
  timeout > 3600) {
19
17
  fail({
20
18
  code: "STANDALONE_DAEMON_INGRESS_INVALID",
21
- message: `daemon.ingress needs path "/<name>/" (lowercase letters, digits and -, not a DSM path such as /webapi/) and an optional timeout of 1–3600 seconds.`,
19
+ message: `daemon.ingress.path must be exactly /synopkgland/<package>/ with the manifest package ID (case-sensitive), and timeout must be 1–3600 seconds`,
22
20
  });
23
21
  return undefined;
24
22
  }
@@ -31,9 +29,14 @@ export function daemonIngressMinDsmProblem(osMinVer) {
31
29
  return `daemon.ingress needs os_min_ver ${PORTAL_MIN_DSM} or later; os_min_ver is "${osMinVer ?? ""}". DSM 7.1 rejects the unsigned web-config worker that adds the path to DSM's nginx. Raise os_min_ver or remove daemon.ingress.`;
32
30
  }
33
31
  export const daemonIngressSocket = (pkg) => `/tmp/synopkgland-ingress-${pkg}.sock`;
32
+ function checkedIngressPath(pkg, ingress) {
33
+ if (!readDaemonIngress(ingress, pkg, () => { }))
34
+ throw new Error('Invalid daemon ingress: use /synopkgland/<package>/ with the exact package ID');
35
+ return daemonIngressPath(pkg);
36
+ }
34
37
  /** What the daemon learns: where to listen and which path prefix it serves. */
35
38
  export function ingressEnv(pkg, ingress) {
36
- return { SYNOPKGLAND_INGRESS_PATH: ingress.path, SYNOPKGLAND_INGRESS_SOCKET: daemonIngressSocket(pkg) };
39
+ return { SYNOPKGLAND_INGRESS_PATH: checkedIngressPath(pkg, ingress), SYNOPKGLAND_INGRESS_SOCKET: daemonIngressSocket(pkg) };
37
40
  }
38
41
  export function daemonIngressResource() {
39
42
  return { "nginx-static-config": { enable: [{ type: "dsm", relpath: DAEMON_INGRESS_CONFIG }] } };
@@ -43,7 +46,7 @@ export function daemonIngressResource() {
43
46
  * unbuffered; nginx replaces any client X-Real-IP with the peer address.
44
47
  */
45
48
  export function renderDaemonIngressConfig(pkg, ingress) {
46
- return `location ^~ ${ingress.path} {
49
+ return `location ^~ ${checkedIngressPath(pkg, ingress)} {
47
50
  proxy_pass http://unix:${daemonIngressSocket(pkg)}:;
48
51
  proxy_http_version 1.1;
49
52
  proxy_set_header Host $http_host;
@@ -7,7 +7,7 @@ export interface DaemonRestart {
7
7
  max_attempts: number;
8
8
  window_seconds: number;
9
9
  }
10
- /** Normalize exactly the SDK's zero/omitted defaults; omission itself stays off. */
10
+ /** Normalize explicit manifest policies; omission uses the SDK controller default. */
11
11
  export declare function readDaemonRestart(value: unknown, fail: (error: StandaloneError) => void): DaemonRestart | undefined;
12
12
  export declare function daemonRestartSdkSupported(sdk: string): boolean;
13
13
  export declare function daemonRestartSdkProblem(sdk: string): string | undefined;
@@ -7,7 +7,7 @@ let validator;
7
7
  function restartValidator() {
8
8
  return validator ??= new Ajv().compile(JSON.parse(readFileSync(new URL("../../schema/daemon-restart.v1.schema.json", import.meta.url), "utf8")));
9
9
  }
10
- /** Normalize exactly the SDK's zero/omitted defaults; omission itself stays off. */
10
+ /** Normalize explicit manifest policies; omission uses the SDK controller default. */
11
11
  export function readDaemonRestart(value, fail) {
12
12
  if (value === undefined)
13
13
  return undefined;
@@ -35,7 +35,7 @@ export function daemonRestartSdkSupported(sdk) {
35
35
  }
36
36
  export function daemonRestartSdkProblem(sdk) {
37
37
  if (!daemonRestartSdkSupported(sdk))
38
- return `daemon.restart requires SDK ${DAEMON_RESTART_MIN_SDK} or later; selected SDK ${sdk}. Upgrade the App's SDK pin, identity_gateway.sdk or the templates supplying the default controller SDK before rebuilding.`;
38
+ return `daemon.restart requires SDK ${DAEMON_RESTART_MIN_SDK} or later; selected SDK ${sdk}. Upgrade the App's SDK pin, identity_gateway.sdk or the CLI supplying the default controller SDK before rebuilding.`;
39
39
  return undefined;
40
40
  }
41
41
  /** Development overrides must contain the decoder as well as the restart code. */
@@ -50,6 +50,6 @@ export function requireDaemonRestartSource(dir, sdk, policy) {
50
50
  catch { /* Fail below before compiling or packaging a mismatched runtime. */ }
51
51
  const problem = daemonRestartSdkProblem(sdk);
52
52
  if (problem || !/\bRestart\s+\*restartPolicy\s+`json:"restart,omitempty"`/.test(main) ||
53
- !/^func normalizeRestart\(/m.test(restart) || !/^type restartBudget struct\s*\{/m.test(restart))
53
+ !/^func normalizeRestart\(/m.test(restart) || !/^type restartPolicy struct\s*\{/m.test(restart))
54
54
  throw new StandaloneBuildError("STANDALONE_DAEMON_RESTART_SDK_REQUIRED", problem ?? `Selected SDK ${sdk} source does not support daemon.restart; use matching SDK ${DAEMON_RESTART_MIN_SDK} or later sources, including for a local development override.`);
55
55
  }
@@ -5,7 +5,7 @@ import { type DaemonRestart } from "./daemon-restart.js";
5
5
  import type { CommandRunner } from "./build.js";
6
6
  import type { StandaloneError, StandaloneManifest } from "./manifest.js";
7
7
  export interface Daemon {
8
- /** Optional bounded recovery; omitted means off. SDK 0.26.0 or later. */
8
+ /** Optional bounded recovery; omission uses the SDK controller default. SDK 0.26.0 or later. */
9
9
  restart?: DaemonRestart;
10
10
  /** Fixed foreground executable relative to daemon/payload; empty for a backend daemon. */
11
11
  exec: string;
@@ -53,7 +53,7 @@ export declare const token: (v: unknown) => v is string;
53
53
  export declare const envKey: (k: string) => boolean;
54
54
  /** A relative path to a fixed executable, without `.` or `..` parts. */
55
55
  export declare const relativeExec: (v: unknown) => v is string;
56
- export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void): Daemon | undefined;
56
+ export declare function readDaemon(value: unknown, root: string, arches: NativeArchFamily[], fail: (error: StandaloneError) => void, packageId: string | undefined): Daemon | undefined;
57
57
  export declare function daemonSdkVersion(manifest: Pick<StandaloneManifest, "backend" | "identityGateway">): string;
58
58
  export declare function daemonConfig(manifest: StandaloneManifest): string;
59
59
  /**
@@ -5,7 +5,7 @@ import { chmodSync, existsSync, copyFileSync, lstatSync, mkdirSync, readdirSync,
5
5
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { PINNED_GO_HINT, pinnedGoBuildEnv, pinnedGoProblem, } from "../build-spk/go-toolchain.js";
7
7
  import { sdkSource, appSdkVersion } from "../runtime/sdk-source.js";
8
- import { templateSdkVersion } from "../template-paths.js";
8
+ import { DEFAULT_SDK_VERSION } from "../runtime/sdk-source.js";
9
9
  import { checkCgiBinary } from "./binary.js";
10
10
  import { NODE_PACKAGES } from "./bridge-backend.js";
11
11
  import { readDaemonIngress, ingressEnv } from "./daemon-ingress.js";
@@ -65,7 +65,7 @@ export const relativeExec = (v) => typeof v === "string" &&
65
65
  !v.split("/").some((p) => p === "." || p === "..");
66
66
  /** A Node entry such as `server.mjs` or `dist/main.js`, inside the payload. */
67
67
  const relativeEntry = (v) => typeof v === "string" && relativeExec(`_/${v}`);
68
- export function readDaemon(value, root, arches, fail) {
68
+ export function readDaemon(value, root, arches, fail, packageId) {
69
69
  if (value === undefined)
70
70
  return undefined;
71
71
  const invalid = () => {
@@ -126,10 +126,10 @@ export function readDaemon(value, root, arches, fail) {
126
126
  const node = readDaemonNode(value.node, fail);
127
127
  if (value.node !== undefined && !node)
128
128
  return undefined;
129
- const ingress = readDaemonIngress(value.ingress, fail);
129
+ const ingress = readDaemonIngress(value.ingress, packageId, fail);
130
130
  if (value.ingress !== undefined && !ingress)
131
131
  return undefined;
132
- if (ingress && Object.keys(env).some((key) => key in ingressEnv("", ingress)))
132
+ if (ingress && Object.keys(env).some((key) => key in ingressEnv(packageId, ingress)))
133
133
  return invalid();
134
134
  if (backend && node && Object.keys(env).some((key) => key in backendNodeEnv()))
135
135
  return invalid();
@@ -172,7 +172,7 @@ function readDaemonNode(value, fail) {
172
172
  export function daemonSdkVersion(manifest) {
173
173
  return manifest.identityGateway?.sdk ??
174
174
  (manifest.backend.kind === "go" ? appSdkVersion(manifest.backend) : undefined) ??
175
- templateSdkVersion();
175
+ DEFAULT_SDK_VERSION;
176
176
  }
177
177
  export function daemonConfig(manifest) {
178
178
  const { exec, args, uninstall_args, env, timeouts, restart } = manifest.compose
@@ -1,8 +1,10 @@
1
+ import type { EntryMode } from './manifest.js';
1
2
  type Payload = Array<{
2
3
  path: string;
3
4
  mode: string;
4
5
  }>;
5
6
  export interface BridgeJson {
7
+ access?: EntryMode;
6
8
  runtime?: string;
7
9
  entry?: string;
8
10
  methods?: string[];
@@ -22,7 +24,7 @@ export interface BridgeJson {
22
24
  * permissions.json by digest, the App lives in target/app (never under ui/),
23
25
  * and its entry, SDK and dependencies are in the package.
24
26
  */
25
- export declare function checkBridgeApp(dir: string, backend: string, info: Record<string, string>, payload: Payload, findings: string[]): Promise<BridgeJson | undefined>;
27
+ export declare function checkBridgeApp(dir: string, backend: string, info: Record<string, string>, payload: Payload, findings: string[], entry?: EntryMode): Promise<BridgeJson | undefined>;
26
28
  /** root and delegate reach the package's executor; delegate pins the one this SPK ships. */
27
29
  export declare function checkBridgeExecutor(bridge: BridgeJson, executor: {
28
30
  executorDigest: string;
@@ -7,6 +7,7 @@ import { ELF_MACHINE } from "./binary.js";
7
7
  import { NODE_SDK_PACKAGE } from "./bridge-app.js";
8
8
  import { BRIDGE_JSON, bridgeManifestProblems, PYTHON_VENDOR_DIR } from "./bridge-json.js";
9
9
  import { CGI_PAYLOAD_PATH } from "./dsm-window.js";
10
+ import { parseApiPermissions } from './api-permissions.js';
10
11
  /** Files that belong in target/app only: DSM serves everything under ui/ to the browser. */
11
12
  const APP_ONLY = [`/${BRIDGE_JSON}`, "/permissions.json", `/${PYTHON_VENDOR_DIR}/synopkgland_app/`, `/node_modules/${NODE_SDK_PACKAGE}/`];
12
13
  /**
@@ -14,7 +15,7 @@ const APP_ONLY = [`/${BRIDGE_JSON}`, "/permissions.json", `/${PYTHON_VENDOR_DIR}
14
15
  * permissions.json by digest, the App lives in target/app (never under ui/),
15
16
  * and its entry, SDK and dependencies are in the package.
16
17
  */
17
- export async function checkBridgeApp(dir, backend, info, payload, findings) {
18
+ export async function checkBridgeApp(dir, backend, info, payload, findings, entry) {
18
19
  const paths = new Set(payload.map(entry => entry.path));
19
20
  for (const path of payload.map(entry => `/${entry.path}`).filter(path => path.startsWith("/ui/") && APP_ONLY.some(name => path.includes(name)))) {
20
21
  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)`);
@@ -31,6 +32,8 @@ export async function checkBridgeApp(dir, backend, info, payload, findings) {
31
32
  return undefined;
32
33
  }
33
34
  const bridge = JSON.parse(text);
35
+ if (bridge.access !== entry)
36
+ findings.push('BRIDGE_ACCESS_MISMATCH app/bridge.json access differs from sdk-package.json entry');
34
37
  if (bridge.runtime !== backend)
35
38
  findings.push(`BRIDGE_MANIFEST_INVALID the runtime "${bridge.runtime}" in app/${BRIDGE_JSON} differs from the backend "${backend}" in sdk-package.json`);
36
39
  checkEntry(app, bridge, info, payload, findings);
@@ -82,10 +85,10 @@ function checkEntry(app, bridge, info, payload, findings) {
82
85
  function checkPermissions(bytes, bridge, findings) {
83
86
  let methods;
84
87
  try {
85
- methods = Object.keys(JSON.parse(bytes.toString("utf8")).methods ?? {});
88
+ methods = Object.keys(parseApiPermissions(bytes).methods);
86
89
  }
87
90
  catch {
88
- findings.push("BRIDGE_PERMISSIONS app/permissions.json is not JSON");
91
+ findings.push("BRIDGE_PERMISSIONS app/permissions.json must be a valid bounded method policy with default administrators");
89
92
  return;
90
93
  }
91
94
  const unlisted = methods.filter(method => !bridge.methods?.includes(method));
@@ -14,6 +14,7 @@ import { readComposeApp, composeSource, dsmComposeProblem, COMPOSE_READINESS_LIM
14
14
  import { createHash } from "node:crypto";
15
15
  import { inspectNativeLayout } from "./native-layout.js";
16
16
  import { checkBridgeApp, checkBridgeExecutor } from "./inspect-bridge.js";
17
+ import { archiveInventory, formatArchiveIssue } from '../spk/archive-inventory.js';
17
18
  import { inspectExecutor, inspectExecutorAnchor, } from "../runtime/executor-inspect.js";
18
19
  import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
19
20
  import { sdkScriptOf, unwrapAppScripts } from "./app-scripts.js";
@@ -65,8 +66,15 @@ export async function inspectStandaloneSpk(spkPath) {
65
66
  export async function inspectSpk(spkPath, expectedKind) {
66
67
  const dir = mkdtempSync(join(tmpdir(), "syno-inspect-spk-"));
67
68
  try {
69
+ const findings = (await archiveInventory(spkPath, true)).map(formatArchiveIssue);
70
+ if (findings.length)
71
+ return { signed: false, payload: [], findings, capabilities: [] };
68
72
  await tar.x({ file: spkPath, cwd: dir });
69
- const findings = [];
73
+ if (existsSync(join(dir, 'package.tgz'))) {
74
+ findings.push(...(await archiveInventory(join(dir, 'package.tgz'))).map(formatArchiveIssue));
75
+ if (findings.length)
76
+ return { signed: existsSync(join(dir, 'syno_signature.asc')), payload: [], findings, capabilities: [] };
77
+ }
70
78
  if (!expectedKind && existsSync(join(dir, "native-layout.json"))) {
71
79
  let dependencies;
72
80
  try {
@@ -240,7 +248,7 @@ export async function inspectSpk(spkPath, expectedKind) {
240
248
  }
241
249
  }
242
250
  const bridge = compiledKind && kind !== "native-go"
243
- ? await checkBridgeApp(dir, metadata.backend, info, payload, findings)
251
+ ? await checkBridgeApp(dir, metadata.backend, info, payload, findings, metadata.entry)
244
252
  : undefined;
245
253
  if (dependencies && bridge?.node?.package && !dependencies.packages.some(d => d.name === bridge.node.package))
246
254
  findings.push("DEPENDENCIES_INVALID build metadata dependencies omit the Node backend package.");
@@ -996,7 +1004,7 @@ async function checkDaemon(dir, info, paths, payload, findings, executor, backgr
996
1004
  timeouts: config.timeouts,
997
1005
  restart: config.restart,
998
1006
  targets: { x86_64: "payload" },
999
- }, dir, ["x86_64"], () => { });
1007
+ }, dir, ["x86_64"], () => { }, info.package);
1000
1008
  if (!daemon)
1001
1009
  throw new Error("invalid config");
1002
1010
  // The packaged config already carries the launcher argv; only the launcher itself is checked here.
@@ -1280,7 +1288,7 @@ async function recoverNativeGateway(dir, value, info, findings) {
1280
1288
  function recoverDaemonNode(value, findings) {
1281
1289
  if (value === undefined)
1282
1290
  return undefined;
1283
- const daemon = readDaemon({ exec: "x", targets: {}, node: value }, tmpdir(), [], () => { });
1291
+ const daemon = readDaemon({ exec: "x", targets: {}, node: value }, tmpdir(), [], () => { }, undefined);
1284
1292
  if (!daemon?.node || !isDeepStrictEqual(daemon.node, value)) {
1285
1293
  findings.push("DAEMON_NODE_INVALID sdk-package.json daemon_node is not a Node.js package declaration build writes");
1286
1294
  return undefined;
@@ -1291,7 +1299,7 @@ async function recoverDaemonIngress(dir, value, info, paths, findings) {
1291
1299
  if (value === undefined)
1292
1300
  return undefined;
1293
1301
  try {
1294
- const ingress = readDaemonIngress(value, () => { });
1302
+ const ingress = readDaemonIngress(value, info.package, () => { });
1295
1303
  if (!ingress || !isDeepStrictEqual(ingress, value))
1296
1304
  throw new Error("invalid ingress record");
1297
1305
  const minDsm = daemonIngressMinDsmProblem(info.os_min_ver);
@@ -1369,10 +1377,18 @@ function checkConf(dir, findings, kind, background, sharedFolder, pkg, portal, c
1369
1377
  findings.push("conf/privilege is not JSON");
1370
1378
  return;
1371
1379
  }
1380
+ if (!privilege || typeof privilege !== 'object' || Array.isArray(privilege)) {
1381
+ findings.push('conf/privilege must be a JSON object');
1382
+ return;
1383
+ }
1372
1384
  const defaults = privilege.defaults;
1373
1385
  if (defaults?.["run-as"] !== "package") {
1374
1386
  findings.push('conf/privilege defaults.run-as must be "package"');
1375
1387
  }
1388
+ if (Object.keys(privilege).some(key => key !== 'defaults') || !defaults ||
1389
+ Object.keys(defaults).some(key => key !== 'run-as')) {
1390
+ findings.push('conf/privilege must contain only defaults.run-as: package');
1391
+ }
1376
1392
  for (const key of ["tool", "ctrl-script", "executable", "join-groupname"]) {
1377
1393
  if (key in privilege) {
1378
1394
  findings.push(`conf/privilege requires ${key} (root or extra group privileges)`);
@@ -1624,8 +1640,12 @@ function checkScripts(dir, findings) {
1624
1640
  for (const name of readdirSync(path))
1625
1641
  scan(`${relative}/${name}`);
1626
1642
  }
1627
- else if (ROOT_SCRIPT_HINTS.some((pattern) => pattern.test(readFileSync(path, "utf8")))) {
1628
- findings.push(`${relative} tries to gain root (sudo, chown root or setuid)`);
1643
+ else {
1644
+ if (!(lstatSync(path).mode & 0o111))
1645
+ findings.push(`${relative} is not executable`);
1646
+ if (ROOT_SCRIPT_HINTS.some(pattern => pattern.test(readFileSync(path, "utf8")))) {
1647
+ findings.push(`${relative} tries to gain root (sudo, chown root or setuid)`);
1648
+ }
1629
1649
  }
1630
1650
  };
1631
1651
  if (existsSync(scriptsDir))
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { sdkSource } from '../runtime/sdk-source.js';
4
- import { templateSdkVersion } from '../template-paths.js';
4
+ import { DEFAULT_SDK_VERSION } from '../runtime/sdk-source.js';
5
5
  import { goChildEnv } from '../runtime/go-env.js';
6
6
  import { pinnedGoBuildEnv, pinnedGoProblem } from '../build-spk/go-toolchain.js';
7
7
  import { checkCgiBinary } from './binary.js';
@@ -49,7 +49,7 @@ export function renderSettingsHook(name, m) {
49
49
  export async function buildInstallSettings(m, arch, work, run) {
50
50
  if (!m.installSettings && !m.installPorts)
51
51
  return;
52
- const sdk = m.compose?.sdk ?? m.identityGateway?.sdk ?? templateSdkVersion(), source = sdkSource(sdk);
52
+ const sdk = m.compose?.sdk ?? m.identityGateway?.sdk ?? DEFAULT_SDK_VERSION, source = sdkSource(sdk);
53
53
  if (!existsSync(join(source.goDir, 'installsettings/cmd/control/main.go')))
54
54
  throw new StandaloneBuildError('SDK_INSTALL_SETTINGS_UNSUPPORTED', 'Use an SDK release with native install.settings support.');
55
55
  if (m.installPorts && !existsSync(join(source.goDir, 'installsettings/ports.go')))
@@ -250,7 +250,7 @@ function readManifest(raw, c) {
250
250
  const timeoutOnly = isRecord(raw.daemon) && Object.keys(raw.daemon).length === 1 && 'timeouts' in raw.daemon;
251
251
  const lifecycleTimeouts = timeoutOnly ? readLifecycleTimeouts(raw.daemon.timeouts, (e) => c.fail(e.code, e.message)) : undefined;
252
252
  const daemon = arches &&
253
- readDaemon(timeoutOnly ? undefined : raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message));
253
+ readDaemon(timeoutOnly ? undefined : raw.daemon, c.root, arches, (e) => c.fail(e.code, e.message), packageId);
254
254
  let dependencies;
255
255
  try {
256
256
  const explicit = readPackageDependencies(raw.dependencies);