@synopackageland/cli 0.41.12 → 0.41.13

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 CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.41.13
6
+
7
+ - Pin CLI-owned runtime defaults to SDK 0.41.0 independently of templates
8
+ - Check daemon restart support through the policy decoder and normalizer
9
+ - Bind daemon ingress to the exact, case-sensitive `/synopkgland/<package>/`
10
+ namespace across manifest validation, generated proxies, daemon environment,
11
+ SPK inspection and App smoke entry selection.
12
+ - Use bounded RE2 patterns for ctl schema validation and sensitive-value
13
+ redaction; reject unsupported validation patterns and redact conservatively
14
+ when an unvalidated privacy pattern cannot be compiled.
15
+ - Restrict native layout systemd units to package-process directives; reject
16
+ manager actions, activation dependencies and unknown directives or sections.
17
+ - Inspect bridge access against the declared package entry and validate its
18
+ permissions policy even when its embedded digest matches.
19
+ - Require executable native lifecycle scripts and the exact package privilege
20
+ declaration.
21
+ - Inventory both archives before SDK package extraction; flag duplicate names,
22
+ case or Unicode collisions, noncanonical paths and unexpected entry types,
23
+ including type flags otherwise ignored by the tar reader.
24
+
3
25
  ## 0.41.12 (2026-10-31)
4
26
 
5
27
  - Remove the delegated WebAPI profile API-name and method-name word lists so
package/README.md CHANGED
@@ -47,7 +47,8 @@ See the [upstream scaffold and release checklist](skills/reference/upstream-fork
47
47
  | `compose` | Unchanged upstream Compose, generated override and SDK planner | `dsm-package.yaml` |
48
48
 
49
49
  Existing native products with their own payload layout use `backend: {kind: native}` in
50
- `dsm-package.yaml`.
50
+ `dsm-package.yaml`. Their systemd units accept only the package-process settings
51
+ listed in [native systemd settings](docs/native-layout-systemd.md).
51
52
 
52
53
  Declare language-independent DSM installation requirements with
53
54
  [`dependencies.packages`](docs/manifest.md). They merge with inferred Node
@@ -88,8 +89,9 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
88
89
  packages. Private mirrors can use explicitly configured Go authentication and
89
90
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
90
91
 
91
- CLI 0.41.12 uses templates ~0.35.5, whose default SDK pin is 0.38.2. Compose import and SDK defaults
92
- read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
92
+ The CLI pins SDK 0.41.0 for its daemon controllers, Compose planning and installer
93
+ context. Explicit App SDK declarations take precedence. Templates remain ~0.35.5
94
+ and retain their own scaffold SDK pins; none declare daemon ingress.
93
95
  Every App method must be declared in the permissions table. Dynamic names and
94
96
  custom HTTP dispatchers are rejected. With SDK 0.33.0, use fixed
95
97
  `api.HandleHTTP` prefixes for authorized raw HTTP and `ipc.ServeUnix` for private
@@ -99,7 +101,7 @@ unimported development commands are excluded. Use
99
101
  authentication. See [App entry and registration](docs/app-entry.md).
100
102
 
101
103
  `init` and `add` read their templates from the locally installed
102
- `@synopackageland/templates`. CLI 0.41.12 depends on `~0.35.5`: npm installation or
104
+ `@synopackageland/templates`. CLI 0.41.13 depends on `~0.35.5`: npm installation or
103
105
  update can select the latest templates patch in the 0.35 minor line. Templates
104
106
  patches need only a templates release; update the npm installation to receive them.
105
107
  The CLI never fetches templates at runtime, so offline use works after installation;
@@ -109,8 +111,8 @@ exact SDK Go module sum anchors the build manifest; npm integrity anchors the
109
111
  identical manifest in `@synopackageland/sdk/executor-manifest.json`. No manifest
110
112
  is fetched at NAS runtime. Missing, malformed or unsupported new manifests fail
111
113
  closed. SDK 0.26.0 and 0.27.0 retain the CLI's frozen legacy inventory.
112
- SDK defaults continue to come from the actual installed templates. An SDK minor
113
- release no longer requires a CLI release; new CLI capabilities or a new manifest
114
+ CLI runtime defaults use its pinned SDK independently of the templates. An SDK minor
115
+ release does not require a templates release; new CLI capabilities or a new manifest
114
116
  format can still require one.
115
117
 
116
118
  For development against local checkouts, set `SYNOPKGLAND_TEMPLATES_DIR` to a
@@ -3,9 +3,10 @@ import { createHash } from 'node:crypto';
3
3
  import { canonicalize } from 'json-canonicalize';
4
4
  import Ajv2020 from 'ajv/dist/2020.js';
5
5
  import { CtlError } from './errors.js';
6
+ import { schemaRegExp } from './pattern.js';
6
7
  export const DESCRIPTION_METHOD = 'synopkgland.describe';
7
8
  export const DESCRIPTION_FILE = 'synopkgland-api.json';
8
- export const ajv = new Ajv2020.default({ allErrors: true, strict: false, validateFormats: false, addUsedSchema: false });
9
+ export const ajv = new Ajv2020.default({ allErrors: true, strict: false, validateFormats: false, addUsedSchema: false, code: { regExp: schemaRegExp } });
9
10
  const descriptorSchema = ajv.compile(JSON.parse(readFileSync(new URL('../../schema/synopkgland-api-v1.schema.json', import.meta.url), 'utf8')));
10
11
  export function makeDescriptor(payload) { const value = { format: 'synopkgland.api', version: 1, payload }; return { ...value, sha256: createHash('sha256').update(canonicalize(value)).digest('hex') }; }
11
12
  export function validateDescriptor(value, id, version) {
@@ -0,0 +1,12 @@
1
+ /** Package-supplied schema patterns never run in the backtracking JS engine. */
2
+ export declare function schemaPattern(pattern: string): {
3
+ test(value: string): boolean;
4
+ toString(): string;
5
+ };
6
+ /** Ajv's pattern and patternProperties keywords must use the same engine. */
7
+ export declare const schemaRegExp: ((pattern: string, _flags: string) => {
8
+ test(value: string): boolean;
9
+ toString(): string;
10
+ }) & {
11
+ code: string;
12
+ };
@@ -0,0 +1,10 @@
1
+ import { RE2JS } from 're2js';
2
+ /** Package-supplied schema patterns never run in the backtracking JS engine. */
3
+ export function schemaPattern(pattern) {
4
+ if (pattern.length > 4096)
5
+ throw new Error('schema pattern exceeds 4096 characters');
6
+ const compiled = RE2JS.compile(RE2JS.translateRegExp(pattern));
7
+ return { test: value => compiled.test(value), toString: () => pattern };
8
+ }
9
+ /** Ajv's pattern and patternProperties keywords must use the same engine. */
10
+ export const schemaRegExp = Object.assign((pattern, _flags) => schemaPattern(pattern), { code: 'schemaRegExp' });
@@ -1,4 +1,5 @@
1
1
  import { CtlError, ctlError } from './errors.js';
2
+ import { schemaPattern } from './pattern.js';
2
3
  export const REDACTED = '<redacted>';
3
4
  function reference(root, ref) {
4
5
  if (!ref.startsWith('#'))
@@ -55,6 +56,22 @@ function branches(schemas, root) {
55
56
  return result;
56
57
  }
57
58
  export function redactValue(value, schema, root = schema, onSecret) {
59
+ const patterns = new Map();
60
+ const matches = (pattern, key) => {
61
+ let compiled = patterns.get(pattern);
62
+ if (!compiled) {
63
+ // Cached descriptors and direct callers can bypass descriptor validation.
64
+ // Unsupported syntax must conservatively apply its privacy annotations.
65
+ try {
66
+ compiled = schemaPattern(pattern);
67
+ }
68
+ catch {
69
+ compiled = { test: () => undefined };
70
+ }
71
+ patterns.set(pattern, compiled);
72
+ }
73
+ return compiled.test(key);
74
+ };
58
75
  const walk = (input, schemas) => {
59
76
  const nodes = branches(schemas, root);
60
77
  if (nodes.some(node => node.writeOnly === true)) {
@@ -76,10 +93,12 @@ export function redactValue(value, schema, root = schema, onSecret) {
76
93
  matched = true;
77
94
  }
78
95
  for (const [pattern, child] of Object.entries(node.patternProperties ?? {})) {
79
- if (new RegExp(pattern).test(key)) {
96
+ const match = matches(pattern, key);
97
+ if (match !== false)
80
98
  children.push(child);
99
+ // An unknown match must not suppress additionalProperties privacy.
100
+ if (match === true)
81
101
  matched = true;
82
- }
83
102
  }
84
103
  if (!matched)
85
104
  children.push(node.additionalProperties);
@@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import { parse } from 'yaml';
3
3
  import { resolveAppLaunch } from '../contract/app-launch.js';
4
4
  import { trustedAppEntryPath } from '../contract/web-launch.js';
5
+ import { readDaemonIngress } from '../standalone/daemon-ingress.js';
5
6
  function safeEntry(entry) {
6
7
  const url = new URL(entry, 'https://dsm.example');
7
8
  if ((!entry.startsWith('/') && !/^https?:\/\//.test(entry)) || entry.startsWith('//') ||
@@ -25,7 +26,10 @@ export function deriveSmokeEntry(raw, entry) {
25
26
  if (!launch)
26
27
  throw new Error('Invalid app launch mode in manifest.');
27
28
  const window = launch === 'dsm-window' && !raw.console && raw.ui !== false && !raw.compose && !raw.daemon?.ingress;
28
- const derived = raw.daemon?.ingress?.path ?? (gateway
29
+ const ingress = readDaemonIngress(raw.daemon?.ingress, raw.package, () => { });
30
+ if (raw.daemon?.ingress !== undefined && !ingress)
31
+ throw new Error('Invalid daemon ingress: use /synopkgland/<package>/ with the exact package ID');
32
+ const derived = ingress?.path ?? (gateway
29
33
  ? `/webman/3rdparty/${raw.package}/sso/open.cgi`
30
34
  : native ? `/?launchApp=${encodeURIComponent(appId)}`
31
35
  : trustedAppEntryPath(raw));
@@ -336,7 +336,7 @@ export const ERROR_REGISTRY = {
336
336
  STANDALONE_DAEMON_RESTART_SDK_REQUIRED: {
337
337
  code: "STANDALONE_DAEMON_RESTART_SDK_REQUIRED",
338
338
  description: "The selected controller SDK version or source does not support daemon.restart.",
339
- suggestion: "Upgrade the selected controller SDK to 0.26.0 or later and use matching sources, including a local SDK override; update the App SDK requirement, identity_gateway.sdk or the templates that supply the default.",
339
+ suggestion: "Upgrade the selected controller SDK to 0.26.0 or later and use matching sources, including a local SDK override; update the App SDK requirement, identity_gateway.sdk or the CLI that supplies the default.",
340
340
  },
341
341
  UNINSTALL_INVALID: {
342
342
  code: "UNINSTALL_INVALID",
@@ -386,7 +386,7 @@ export const ERROR_REGISTRY = {
386
386
  STANDALONE_DAEMON_INGRESS_INVALID: {
387
387
  code: "STANDALONE_DAEMON_INGRESS_INVALID",
388
388
  description: "daemon.ingress does not declare one same-origin path and a bounded proxy timeout.",
389
- suggestion: "Set daemon.ingress.path to /<name>/ with lowercase letters, digits and -, not a DSM path, and timeout (optional) to 1–3600 seconds.",
389
+ suggestion: "Set daemon.ingress.path to exactly /synopkgland/<package>/ using the case-sensitive manifest package ID, and timeout (optional) to 1–3600 seconds",
390
390
  },
391
391
  STANDALONE_DAEMON_INGRESS_MIN_DSM: {
392
392
  code: "STANDALONE_DAEMON_INGRESS_MIN_DSM",
@@ -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.0";
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.0';
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);
@@ -257,10 +257,27 @@ export function checkLayoutMetadata(root, inspection = false, packageId) {
257
257
  if (/\\\s*$/m.test(unit))
258
258
  fail('systemd unit continuations are unsupported');
259
259
  let section = '';
260
+ // These settings either describe ordering or operate on the package
261
+ // process. Manager actions/dependencies and filesystem setup are absent.
262
+ const directives = {
263
+ Unit: ['Description', 'Documentation', 'After', 'Before'],
264
+ Service: ['Type', 'User', 'Group', 'ExecStart', 'ExecStartPre', 'ExecStartPost', 'ExecStop', 'ExecStopPost', 'ExecReload',
265
+ 'Restart', 'RestartSec', 'TimeoutStartSec', 'TimeoutStopSec', 'KillMode', 'KillSignal', 'WorkingDirectory', 'Environment'],
266
+ Install: ['WantedBy'],
267
+ };
260
268
  for (const line of unit.split('\n')) {
269
+ if (!line.trim() || /^\s*[#;]/.test(line))
270
+ continue;
261
271
  const heading = line.match(/^\s*\[([^\]]+)\]\s*$/);
262
- if (heading)
272
+ if (heading) {
263
273
  section = heading[1];
274
+ if (!Object.hasOwn(directives, section))
275
+ fail(`unsupported systemd section: ${section}`);
276
+ continue;
277
+ }
278
+ const assignment = line.match(/^\s*([A-Za-z][A-Za-z0-9]*)\s*=/);
279
+ if (!assignment || !Object.hasOwn(directives, section) || !directives[section].includes(assignment[1]))
280
+ fail(`unsupported systemd unit directive in ${section || 'missing section'}`);
264
281
  if (/^\s*(?:User|Group|Exec\w*)\s*=/.test(line) && section !== 'Service')
265
282
  fail('systemd execution directives require the package Service section');
266
283
  }
@@ -3,7 +3,3 @@
3
3
  * package; SYNOPKGLAND_TEMPLATES_DIR points at a templates checkout instead.
4
4
  */
5
5
  export declare function templatesRoot(env?: NodeJS.ProcessEnv, from?: string): string;
6
- /** The installed templates' SDK pin also supplies CLI-owned runtime defaults.
7
- * CLI-only patch releases must never turn their own version into an SDK tag.
8
- */
9
- export declare function templateSdkVersion(env?: NodeJS.ProcessEnv): string;
@@ -1,6 +1,5 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { dirname, join } from "node:path";
3
- import { sdkRequirement } from "./runtime/sdk-source.js";
4
3
  /**
5
4
  * The scaffold templates come from the locally installed @synopackageland/templates
6
5
  * package; SYNOPKGLAND_TEMPLATES_DIR points at a templates checkout instead.
@@ -15,13 +14,3 @@ export function templatesRoot(env = process.env, from = import.meta.url) {
15
14
  throw new Error("[TEMPLATES_MISSING] @synopackageland/templates is not installed; reinstall @synopackageland/cli, or set SYNOPKGLAND_TEMPLATES_DIR to a templates checkout.");
16
15
  }
17
16
  }
18
- /** The installed templates' SDK pin also supplies CLI-owned runtime defaults.
19
- * CLI-only patch releases must never turn their own version into an SDK tag.
20
- */
21
- export function templateSdkVersion(env = process.env) {
22
- const version = sdkRequirement(join(templatesRoot(env), "native-go/backend"));
23
- if (!version || !/^\d+\.\d+\.\d+$/.test(version)) {
24
- throw new Error("[TEMPLATES_MISSING] The installed templates have no valid SDK pin; reinstall @synopackageland/templates.");
25
- }
26
- return version;
27
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.41.12",
3
+ "version": "0.41.13",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,7 @@
32
32
  "ajv": "^8.17.1",
33
33
  "commander": "^13.1.0",
34
34
  "json-canonicalize": "^3.0.1",
35
+ "re2js": "2.9.1",
35
36
  "tar": "^7.4.3",
36
37
  "yaml": "^2.7.0"
37
38
  },
@@ -163,6 +163,8 @@ For AI consumers: execute commands that describe a complete task. Do not manuall
163
163
 
164
164
  App authors must mark passkeys, tokens, private URLs, torrent/base64 and upload content with standard JSON Schema `writeOnly: true`, including nested objects, array items and local references. Mark sensitive response fields in resultSchema too. Do not embed real secrets in schema defaults/enums/examples or command examples.
165
165
 
166
+ Parameter and result schema `pattern` and `patternProperties` use the linear RE2 engine, with at most 4096 characters per pattern. Backreferences, lookarounds and other unsupported syntax are rejected during description validation. If an unvalidated or cached privacy pattern cannot be compiled, redaction conservatively applies that pattern's annotations to every property.
167
+
166
168
  CLI plans, dry-run, missing-yes errors, JSON/multi-host output and known input values repeated by the App use the fixed string `<redacted>`. Schema validation errors never echo instance values or paths. This changes presentation only: authorized requests still send the original values. The CLI does not log or cache business parameters. Sensitive description examples/defaults are masked in list/help and disk caches; a cache altered by redaction requires a fresh authenticated description/hash check before execution. Fill placeholders locally; never paste secrets into AI messages or logs.
167
169
 
168
170
  ## Command naming recommendations
@@ -6,6 +6,11 @@ for every request, and the process exits when the request ends. With
6
6
  the same binary runs once as the package daemon and serves the pages through
7
7
  DSM's nginx. The CGI stays for integrations, transfers and fallback.
8
8
 
9
+ Set `daemon.ingress.path` to exactly `/synopkgland/<package>/`, preserving the
10
+ manifest package ID's case. The backend mounts routes at
11
+ `SYNOPKGLAND_INGRESS_PATH`; build, inspect and smoke entry checks bind this path
12
+ to the package.
13
+
9
14
  Daemon mode is not the default recommendation. It removes work that repeats
10
15
  on every request. It does not make a single expensive request cheaper, and it
11
16
  costs memory for as long as the package runs. Measure first, then decide.