@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 +22 -0
- package/README.md +8 -6
- package/dist/ctl/descriptor.js +2 -1
- package/dist/ctl/pattern.d.ts +12 -0
- package/dist/ctl/pattern.js +10 -0
- package/dist/ctl/redaction.js +21 -2
- package/dist/e2e/app-open.js +5 -1
- package/dist/error-registry.js +2 -2
- package/dist/runtime/sdk-source.d.ts +2 -0
- package/dist/runtime/sdk-source.js +2 -0
- package/dist/spk/archive-inventory.d.ts +9 -0
- package/dist/spk/archive-inventory.js +65 -0
- package/dist/spk/check-payload.js +9 -0
- package/dist/spk/rules.d.ts +5 -0
- package/dist/spk/rules.js +1 -0
- package/dist/spk/validate-spk.js +12 -0
- package/dist/standalone/compose.js +4 -4
- package/dist/standalone/daemon-ingress.d.ts +3 -2
- package/dist/standalone/daemon-ingress.js +12 -9
- package/dist/standalone/daemon-restart.d.ts +1 -1
- package/dist/standalone/daemon-restart.js +3 -3
- package/dist/standalone/daemon.d.ts +2 -2
- package/dist/standalone/daemon.js +5 -5
- package/dist/standalone/inspect-bridge.d.ts +3 -1
- package/dist/standalone/inspect-bridge.js +6 -3
- package/dist/standalone/inspect-spk.js +27 -7
- package/dist/standalone/install-settings.js +2 -2
- package/dist/standalone/manifest.js +1 -1
- package/dist/standalone/native-layout.js +18 -1
- package/dist/template-paths.d.ts +0 -4
- package/dist/template-paths.js +0 -11
- package/package.json +2 -1
- package/skills/reference/ctl.md +2 -0
- package/skills/reference/daemon-mode.md +5 -0
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.
|
|
92
|
-
|
|
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.
|
|
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
|
-
|
|
113
|
-
release
|
|
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
|
package/dist/ctl/descriptor.js
CHANGED
|
@@ -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' });
|
package/dist/ctl/redaction.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/e2e/app-open.js
CHANGED
|
@@ -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
|
|
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));
|
package/dist/error-registry.js
CHANGED
|
@@ -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
|
|
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 /<
|
|
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)
|
package/dist/spk/rules.d.ts
CHANGED
|
@@ -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` },
|
package/dist/spk/validate-spk.js
CHANGED
|
@@ -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 {
|
|
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 ??
|
|
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(
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
5
|
-
|
|
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
|
-
!
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
88
|
+
methods = Object.keys(parseApiPermissions(bytes).methods);
|
|
86
89
|
}
|
|
87
90
|
catch {
|
|
88
|
-
findings.push("BRIDGE_PERMISSIONS app/permissions.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
|
-
|
|
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
|
|
1628
|
-
|
|
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 {
|
|
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 ??
|
|
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
|
}
|
package/dist/template-paths.d.ts
CHANGED
|
@@ -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;
|
package/dist/template-paths.js
CHANGED
|
@@ -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.
|
|
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
|
},
|
package/skills/reference/ctl.md
CHANGED
|
@@ -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.
|