@synopackageland/cli 0.3.8 → 0.3.9
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/dist/cli.js +3 -0
- package/dist/deploy/deploy.d.ts +2 -0
- package/dist/deploy/deploy.js +20 -0
- package/dist/deploy/share-permission-preflight.d.ts +37 -0
- package/dist/deploy/share-permission-preflight.js +81 -0
- package/dist/error-registry.js +5 -0
- package/dist/host/dsm-client.js +7 -0
- package/dist/host/fake-nas.d.ts +15 -0
- package/dist/host/fake-nas.js +31 -0
- package/dist/host/share-permission-lookup.d.ts +23 -0
- package/dist/host/share-permission-lookup.js +149 -0
- package/dist/host/share-permission.d.ts +43 -0
- package/dist/host/share-permission.js +59 -0
- package/dist/host/types.d.ts +9 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -274,6 +274,9 @@ export async function runCli(argv) {
|
|
|
274
274
|
console.error(`[${result.code ?? "DEPLOY_FAILED"}] ${result.message ?? "部署失敗。"}`);
|
|
275
275
|
process.exit(1);
|
|
276
276
|
}
|
|
277
|
+
for (const note of result.notes ?? []) {
|
|
278
|
+
console.warn(`[注意] ${note}`);
|
|
279
|
+
}
|
|
277
280
|
console.log(`已部署 ${result.packageId}`);
|
|
278
281
|
if (result.openUrl) {
|
|
279
282
|
console.log(`Open URL:${result.openUrl}`);
|
package/dist/deploy/deploy.d.ts
CHANGED
|
@@ -24,5 +24,7 @@ export interface DeployProjectResult {
|
|
|
24
24
|
spkPath?: string;
|
|
25
25
|
openUrl?: string;
|
|
26
26
|
catalogVersion?: string;
|
|
27
|
+
/** Non-blocking pre-flight remarks, e.g. a read-only Shared Folder grant. */
|
|
28
|
+
notes?: string[];
|
|
27
29
|
}
|
|
28
30
|
export declare function deployProject(options: DeployProjectOptions): Promise<DeployProjectResult>;
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -9,6 +9,7 @@ import { resolveDeployIdentity, PACKAGE_IDENTITY_MAX_LENGTH } from "./identity.j
|
|
|
9
9
|
import { resolveDeploySpkVersion } from "./spk-version.js";
|
|
10
10
|
import { resolveSharedFolderExtraValues, sharedFolderParameterNames } from "./share-values.js";
|
|
11
11
|
import { resolveDsmUserExtraValues, dsmUserParameterNames } from "./run-as-values.js";
|
|
12
|
+
import { runSharePermissionPreflight } from "./share-permission-preflight.js";
|
|
12
13
|
import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js";
|
|
13
14
|
import { resolveInstallParameterExtraValues } from "./param-values.js";
|
|
14
15
|
import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
|
|
@@ -124,6 +125,24 @@ export async function deployProject(options) {
|
|
|
124
125
|
if (!runAsResolved.ok) {
|
|
125
126
|
return { ok: false, code: runAsResolved.code, message: runAsResolved.message };
|
|
126
127
|
}
|
|
128
|
+
// A42 pre-flight: preinst runs as the package user and can never read ACL, so
|
|
129
|
+
// the only place that can catch "selected user cannot reach selected Share" is
|
|
130
|
+
// here, where we still hold an administrator session. Refuses before install.
|
|
131
|
+
const sharePermission = await runSharePermissionPreflight({
|
|
132
|
+
shares: Object.fromEntries(sharedFolderParameterNames(contract)
|
|
133
|
+
.map((key) => [key, (options.shareValues ?? {})[key] ?? ""])
|
|
134
|
+
.filter(([, value]) => value.trim() !== "")),
|
|
135
|
+
runAsUsers: runAsResolved.extraValues,
|
|
136
|
+
listSharePermissions: options.host.listSharePermissions
|
|
137
|
+
? (name) => options.host.listSharePermissions(name)
|
|
138
|
+
: undefined,
|
|
139
|
+
listUserGroups: options.host.listUserGroups
|
|
140
|
+
? (name) => options.host.listUserGroups(name)
|
|
141
|
+
: undefined,
|
|
142
|
+
});
|
|
143
|
+
if (!sharePermission.ok) {
|
|
144
|
+
return { ok: false, code: sharePermission.code, message: sharePermission.message };
|
|
145
|
+
}
|
|
127
146
|
const extraValues = {
|
|
128
147
|
...paramResolved.extraValues,
|
|
129
148
|
...homesResolved.extraValues,
|
|
@@ -273,5 +292,6 @@ export async function deployProject(options) {
|
|
|
273
292
|
spkPath: build.outputPath,
|
|
274
293
|
openUrl: info.openUrl ?? appOriginFromContract(options.host.dsmOrigin, contract),
|
|
275
294
|
catalogVersion,
|
|
295
|
+
notes: sharePermission.notes.length > 0 ? sharePermission.notes : undefined,
|
|
276
296
|
};
|
|
277
297
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type SharePermissionRow } from "../host/share-permission.js";
|
|
2
|
+
export declare const SHARE_RUN_AS_NO_ACCESS = "SHARE_RUN_AS_NO_ACCESS";
|
|
3
|
+
export interface SharePermissionListing {
|
|
4
|
+
users: SharePermissionRow[];
|
|
5
|
+
groups: SharePermissionRow[];
|
|
6
|
+
}
|
|
7
|
+
export interface SharePermissionPreflightInput {
|
|
8
|
+
/** Logical Share names to check, keyed by the contract parameter name. */
|
|
9
|
+
shares: Record<string, string>;
|
|
10
|
+
/** Selected DSM user logical names, keyed by the contract parameter name. */
|
|
11
|
+
runAsUsers: Record<string, string>;
|
|
12
|
+
listSharePermissions?: (shareName: string) => Promise<SharePermissionListing | undefined>;
|
|
13
|
+
listUserGroups?: (userName: string) => Promise<string[] | undefined>;
|
|
14
|
+
}
|
|
15
|
+
export type SharePermissionPreflightResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
notes: string[];
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
code: string;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Ask the NAS, with the administrator session `deploy` already holds, whether
|
|
25
|
+
* every selected Run-as user can reach every selected Shared Folder.
|
|
26
|
+
*
|
|
27
|
+
* Deliberate boundaries:
|
|
28
|
+
* - This is a pre-flight, not enforcement. Package Center installs do not come
|
|
29
|
+
* through here, and share permission can change after the install.
|
|
30
|
+
* - "Cannot tell" never becomes a refusal: a host without the capability, an
|
|
31
|
+
* API that failed, an empty listing, or an advanced/Windows ACL (`is_custom`,
|
|
32
|
+
* readable only by root-only synoacltool) all pass through.
|
|
33
|
+
* - `posix_all_shares` is out of scope on purpose. It means "mount every Share,
|
|
34
|
+
* read-only" -- gating it on permission would refuse almost every install,
|
|
35
|
+
* and the read-only bind already limits the damage.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runSharePermissionPreflight(input: SharePermissionPreflightInput): Promise<SharePermissionPreflightResult>;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { evaluateShareAccess } from "../host/share-permission.js";
|
|
2
|
+
export const SHARE_RUN_AS_NO_ACCESS = "SHARE_RUN_AS_NO_ACCESS";
|
|
3
|
+
/**
|
|
4
|
+
* Ask the NAS, with the administrator session `deploy` already holds, whether
|
|
5
|
+
* every selected Run-as user can reach every selected Shared Folder.
|
|
6
|
+
*
|
|
7
|
+
* Deliberate boundaries:
|
|
8
|
+
* - This is a pre-flight, not enforcement. Package Center installs do not come
|
|
9
|
+
* through here, and share permission can change after the install.
|
|
10
|
+
* - "Cannot tell" never becomes a refusal: a host without the capability, an
|
|
11
|
+
* API that failed, an empty listing, or an advanced/Windows ACL (`is_custom`,
|
|
12
|
+
* readable only by root-only synoacltool) all pass through.
|
|
13
|
+
* - `posix_all_shares` is out of scope on purpose. It means "mount every Share,
|
|
14
|
+
* read-only" -- gating it on permission would refuse almost every install,
|
|
15
|
+
* and the read-only bind already limits the damage.
|
|
16
|
+
*/
|
|
17
|
+
export async function runSharePermissionPreflight(input) {
|
|
18
|
+
const shareEntries = Object.entries(input.shares).filter(([, name]) => name.trim() !== "");
|
|
19
|
+
const userEntries = Object.entries(input.runAsUsers).filter(([, name]) => name.trim() !== "");
|
|
20
|
+
if (shareEntries.length === 0 || userEntries.length === 0) {
|
|
21
|
+
return { ok: true, notes: [] };
|
|
22
|
+
}
|
|
23
|
+
if (!input.listSharePermissions || !input.listUserGroups) {
|
|
24
|
+
return { ok: true, notes: [] };
|
|
25
|
+
}
|
|
26
|
+
const notes = [];
|
|
27
|
+
const groupCache = new Map();
|
|
28
|
+
const listingCache = new Map();
|
|
29
|
+
for (const [shareKey, shareName] of shareEntries) {
|
|
30
|
+
let listing = listingCache.get(shareName);
|
|
31
|
+
if (!listingCache.has(shareName)) {
|
|
32
|
+
try {
|
|
33
|
+
listing = await input.listSharePermissions(shareName);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
listing = undefined;
|
|
37
|
+
}
|
|
38
|
+
listingCache.set(shareName, listing);
|
|
39
|
+
}
|
|
40
|
+
if (!listing) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const [userKey, userName] of userEntries) {
|
|
44
|
+
let groups = groupCache.get(userName);
|
|
45
|
+
if (!groupCache.has(userName)) {
|
|
46
|
+
try {
|
|
47
|
+
groups = await input.listUserGroups(userName);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
groups = undefined;
|
|
51
|
+
}
|
|
52
|
+
groupCache.set(userName, groups);
|
|
53
|
+
}
|
|
54
|
+
if (!groups) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const verdict = evaluateShareAccess({
|
|
58
|
+
userName,
|
|
59
|
+
userGroups: groups,
|
|
60
|
+
userRows: listing.users,
|
|
61
|
+
groupRows: listing.groups,
|
|
62
|
+
});
|
|
63
|
+
if (verdict.access === "denied" || verdict.access === "none") {
|
|
64
|
+
const cause = verdict.access === "denied"
|
|
65
|
+
? `被「${verdict.deniedBy}」明確拒絕`
|
|
66
|
+
: "在使用者本身與其所屬群組都沒有任何授權";
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
code: SHARE_RUN_AS_NO_ACCESS,
|
|
70
|
+
message: `Container Run-as「${userKey}=${userName}」對 Shared Folder「${shareKey}=${shareName}」${cause}。` +
|
|
71
|
+
"容器會以該使用者的身分掛載這個 Share,安裝雖然會成功,但讀寫會在 runtime 拿到 Permission denied。" +
|
|
72
|
+
`請先在 DSM 控制台把 ${shareName} 的權限給 ${userName},或改選另一個使用者/Share。`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (verdict.access === "read") {
|
|
76
|
+
notes.push(`Run-as「${userName}」對 Shared Folder「${shareName}」只有唯讀權限(來源 ${verdict.grantedBy})。若 App 需要寫入會在 runtime 失敗。`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { ok: true, notes };
|
|
81
|
+
}
|
package/dist/error-registry.js
CHANGED
|
@@ -499,6 +499,11 @@ export const ERROR_REGISTRY = {
|
|
|
499
499
|
description: "dsm_user 參數必須提供 DSM 使用者邏輯名稱,禁止使用數字 UID。",
|
|
500
500
|
suggestion: "請在 --run-as 指定 DSM 使用者帳號名稱(例如 --run-as RUN_AS=alice)。",
|
|
501
501
|
},
|
|
502
|
+
SHARE_RUN_AS_NO_ACCESS: {
|
|
503
|
+
code: "SHARE_RUN_AS_NO_ACCESS",
|
|
504
|
+
description: "deploy 的安裝前檢查發現選定的 Container Run-as 使用者對選定的 Shared Folder 沒有權限(或被明確拒絕)。容器會以該使用者掛載該 Share,安裝會成功但 runtime 讀寫會拿到 Permission denied。",
|
|
505
|
+
suggestion: "請在 DSM 控制台把該 Shared Folder 的權限授予該使用者(或其群組),或改選別的使用者/Share,再重新部署。此檢查只在 deploy 進行;Package Center 安裝精靈不經過這裡,ACL 仍是管理員的前提責任。",
|
|
506
|
+
},
|
|
502
507
|
CURATED_DEPLOY_REFUSED: {
|
|
503
508
|
code: "CURATED_DEPLOY_REFUSED",
|
|
504
509
|
description: "目標 NAS 已持有 Curated App Package Attestation,禁止隨意覆蓋部署。",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { IDENTITY_CGI_PATH } from "./broker-paths.js";
|
|
6
6
|
import { packageSourceOrigin } from "../deploy/catalog-publish.js";
|
|
7
7
|
import { listShareNamesWithSynoagentcli, lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
8
|
+
import { createSynoagentcliApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
|
|
8
9
|
import { dsmFetch } from "./dsm-http.js";
|
|
9
10
|
import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
|
|
10
11
|
import { resolveHostCredentials } from "./credentials.js";
|
|
@@ -234,6 +235,12 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
234
235
|
async listShareNames() {
|
|
235
236
|
return listShareNamesWithSynoagentcli(profileName);
|
|
236
237
|
},
|
|
238
|
+
async listSharePermissions(logicalName) {
|
|
239
|
+
return fetchSharePermissions(logicalName, createSynoagentcliApiCall(profileName));
|
|
240
|
+
},
|
|
241
|
+
async listUserGroups(userName) {
|
|
242
|
+
return fetchUserGroups(userName, createSynoagentcliApiCall(profileName));
|
|
243
|
+
},
|
|
237
244
|
async resolveHomesPath() {
|
|
238
245
|
try {
|
|
239
246
|
const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
|
package/dist/host/fake-nas.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import type { SharePermissionRow } from "./share-permission.js";
|
|
1
2
|
import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
|
|
3
|
+
export type SharePermissionRowInput = {
|
|
4
|
+
name: string;
|
|
5
|
+
} & Partial<Omit<SharePermissionRow, "name">>;
|
|
2
6
|
export interface FakeNasHostOptions {
|
|
3
7
|
curated?: string[];
|
|
4
8
|
dsmOrigin?: string;
|
|
@@ -6,6 +10,17 @@ export interface FakeNasHostOptions {
|
|
|
6
10
|
installationId?: string;
|
|
7
11
|
shareNames?: string[];
|
|
8
12
|
shares?: Record<string, string>;
|
|
13
|
+
/**
|
|
14
|
+
* SYNO.Core.Share.Permission rows per Share, as partials -- every flag
|
|
15
|
+
* defaults to false, matching a row DSM grants nothing on. Omit the whole
|
|
16
|
+
* option to model a host that cannot answer the permission question.
|
|
17
|
+
*/
|
|
18
|
+
sharePermissions?: Record<string, {
|
|
19
|
+
users: SharePermissionRowInput[];
|
|
20
|
+
groups: SharePermissionRowInput[];
|
|
21
|
+
}>;
|
|
22
|
+
/** Group membership per DSM user, for share-permission resolution. */
|
|
23
|
+
userGroups?: Record<string, string[]>;
|
|
9
24
|
installedEnv?: Record<string, Record<string, string>>;
|
|
10
25
|
diagnostics?: {
|
|
11
26
|
dsmResidue?: import("./types.js").DsmResidueInventory;
|
package/dist/host/fake-nas.js
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { webStationAlias } from "../contract/web-launch.js";
|
|
2
|
+
function sharePermissionRow(input) {
|
|
3
|
+
return {
|
|
4
|
+
is_admin: false,
|
|
5
|
+
is_writable: false,
|
|
6
|
+
is_readonly: false,
|
|
7
|
+
is_deny: false,
|
|
8
|
+
is_custom: false,
|
|
9
|
+
...input,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
2
12
|
export function createFakeNasHost(options = {}) {
|
|
3
13
|
const dsmOrigin = options.dsmOrigin ?? "http://nas.example:5000";
|
|
4
14
|
const installationId = options.installationId ?? "fake-installation";
|
|
@@ -32,6 +42,27 @@ export function createFakeNasHost(options = {}) {
|
|
|
32
42
|
async listShareNames() {
|
|
33
43
|
return [...shareNames];
|
|
34
44
|
},
|
|
45
|
+
...(options.sharePermissions
|
|
46
|
+
? {
|
|
47
|
+
async listSharePermissions(logicalName) {
|
|
48
|
+
const listing = options.sharePermissions?.[logicalName];
|
|
49
|
+
if (!listing) {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
users: listing.users.map(sharePermissionRow),
|
|
54
|
+
groups: listing.groups.map(sharePermissionRow),
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
...(options.userGroups
|
|
60
|
+
? {
|
|
61
|
+
async listUserGroups(userName) {
|
|
62
|
+
return options.userGroups?.[userName];
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
: {}),
|
|
35
66
|
async resolveHomesPath() {
|
|
36
67
|
return options.homesPath;
|
|
37
68
|
},
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SharePermissionListing } from "../deploy/share-permission-preflight.js";
|
|
2
|
+
import type { SharePermissionRow } from "./share-permission.js";
|
|
3
|
+
/**
|
|
4
|
+
* Reads DSM share permission with the administrator session `deploy` holds.
|
|
5
|
+
*
|
|
6
|
+
* Every parser returns `undefined` rather than an empty result when the answer
|
|
7
|
+
* is missing or malformed: downstream, `undefined` means "cannot tell" and lets
|
|
8
|
+
* the install proceed, while an empty list would read as a real denial and
|
|
9
|
+
* refuse the install on a transport error.
|
|
10
|
+
*
|
|
11
|
+
* The APIs used were confirmed present on DSM 7.4 (vt-nas):
|
|
12
|
+
* `SYNO.Core.Share.Permission`, `SYNO.Core.Group`, `SYNO.Core.Group.Member`.
|
|
13
|
+
* `SYNO.Core.User.Group` also exists on the NAS but has no generated
|
|
14
|
+
* synoagentcli operation, which is why membership is derived group-by-group.
|
|
15
|
+
*/
|
|
16
|
+
export type DsmApiCall = (operationId: string, params: Record<string, string>) => Promise<unknown>;
|
|
17
|
+
export declare function parseSharePermissionRows(payload: unknown): SharePermissionRow[] | undefined;
|
|
18
|
+
export declare function parseGroupNames(payload: unknown): string[] | undefined;
|
|
19
|
+
export declare function parseGroupMemberNames(payload: unknown): string[] | undefined;
|
|
20
|
+
export declare function fetchSharePermissions(shareName: string, call: DsmApiCall): Promise<SharePermissionListing | undefined>;
|
|
21
|
+
export declare function fetchUserGroups(userName: string, call: DsmApiCall): Promise<string[] | undefined>;
|
|
22
|
+
/** synoagentcli-backed `DsmApiCall`, matching how the rest of the host client talks to DSM. */
|
|
23
|
+
export declare function createSynoagentcliApiCall(profileName?: string): DsmApiCall;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
const SHARE_PERMISSION_OPERATION = "SYNO_Core_Share_Permission_list_v1";
|
|
3
|
+
const GROUP_LIST_OPERATION = "SYNO_Core_Group_list_v1";
|
|
4
|
+
const GROUP_MEMBER_OPERATION = "SYNO_Core_Group_Member_list_v1";
|
|
5
|
+
function asRecord(value) {
|
|
6
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
/** Unwrap the synoagentcli envelope down to the DSM `data` object. */
|
|
12
|
+
function dsmData(payload) {
|
|
13
|
+
const outer = asRecord(payload);
|
|
14
|
+
if (!outer || outer.success === false) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
const inner = asRecord(outer.data);
|
|
18
|
+
const response = asRecord(inner?.response);
|
|
19
|
+
return asRecord(response?.data) ?? asRecord(inner?.data);
|
|
20
|
+
}
|
|
21
|
+
function flag(record, key) {
|
|
22
|
+
return record[key] === true;
|
|
23
|
+
}
|
|
24
|
+
export function parseSharePermissionRows(payload) {
|
|
25
|
+
const data = dsmData(payload);
|
|
26
|
+
const items = data?.items;
|
|
27
|
+
if (!Array.isArray(items)) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
const record = asRecord(item);
|
|
33
|
+
const name = record?.name;
|
|
34
|
+
if (typeof name !== "string" || name === "") {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
rows.push({
|
|
38
|
+
name,
|
|
39
|
+
is_admin: flag(record, "is_admin"),
|
|
40
|
+
is_writable: flag(record, "is_writable"),
|
|
41
|
+
is_readonly: flag(record, "is_readonly"),
|
|
42
|
+
is_deny: flag(record, "is_deny"),
|
|
43
|
+
is_custom: flag(record, "is_custom"),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return rows.length > 0 ? rows : undefined;
|
|
47
|
+
}
|
|
48
|
+
function parseNames(payload, key) {
|
|
49
|
+
const data = dsmData(payload);
|
|
50
|
+
const list = data?.[key];
|
|
51
|
+
if (!Array.isArray(list)) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
const names = [];
|
|
55
|
+
for (const item of list) {
|
|
56
|
+
const name = asRecord(item)?.name;
|
|
57
|
+
if (typeof name === "string" && name !== "") {
|
|
58
|
+
names.push(name);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return names;
|
|
62
|
+
}
|
|
63
|
+
export function parseGroupNames(payload) {
|
|
64
|
+
const names = parseNames(payload, "groups");
|
|
65
|
+
return names && names.length > 0 ? names : undefined;
|
|
66
|
+
}
|
|
67
|
+
export function parseGroupMemberNames(payload) {
|
|
68
|
+
return parseNames(payload, "users");
|
|
69
|
+
}
|
|
70
|
+
export async function fetchSharePermissions(shareName, call) {
|
|
71
|
+
const read = async (userGroupType) => {
|
|
72
|
+
try {
|
|
73
|
+
return parseSharePermissionRows(await call(SHARE_PERMISSION_OPERATION, {
|
|
74
|
+
name: shareName,
|
|
75
|
+
user_group_type: userGroupType,
|
|
76
|
+
offset: "0",
|
|
77
|
+
limit: "-1",
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const users = await read("local_user");
|
|
85
|
+
const groups = await read("local_group");
|
|
86
|
+
// A half-read listing could hide the very row that grants or denies access.
|
|
87
|
+
if (!users || !groups) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return { users, groups };
|
|
91
|
+
}
|
|
92
|
+
export async function fetchUserGroups(userName, call) {
|
|
93
|
+
let groupNames;
|
|
94
|
+
try {
|
|
95
|
+
groupNames = parseGroupNames(await call(GROUP_LIST_OPERATION, { offset: "0", limit: "-1" }));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
if (!groupNames) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
const memberships = [];
|
|
104
|
+
for (const groupName of groupNames) {
|
|
105
|
+
let members;
|
|
106
|
+
try {
|
|
107
|
+
members = parseGroupMemberNames(await call(GROUP_MEMBER_OPERATION, { group: groupName, offset: "0", limit: "-1" }));
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
// One unreadable group could be the one that grants access, so a partial
|
|
113
|
+
// membership set must never be used to refuse an install.
|
|
114
|
+
if (!members) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
if (members.includes(userName)) {
|
|
118
|
+
memberships.push(groupName);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return memberships;
|
|
122
|
+
}
|
|
123
|
+
/** synoagentcli-backed `DsmApiCall`, matching how the rest of the host client talks to DSM. */
|
|
124
|
+
export function createSynoagentcliApiCall(profileName) {
|
|
125
|
+
return async (operationId, params) => {
|
|
126
|
+
const args = ["api", "call", "--operation-id", operationId];
|
|
127
|
+
for (const [key, value] of Object.entries(params)) {
|
|
128
|
+
args.push("--param", `${key}=${value}`);
|
|
129
|
+
}
|
|
130
|
+
if (profileName) {
|
|
131
|
+
args.push("--host", profileName);
|
|
132
|
+
}
|
|
133
|
+
const result = spawnSync("synoagentcli", args, { encoding: "utf8" });
|
|
134
|
+
if (result.status !== 0) {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
// synoagentcli may prepend an update notice before the JSON body.
|
|
138
|
+
const match = /\{[\s\S]*\}/.exec(result.stdout ?? "");
|
|
139
|
+
if (!match) {
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(match[0]);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Folder permission pre-flight for the dynamic package-only route.
|
|
3
|
+
*
|
|
4
|
+
* Why this lives here and not in the SPK's preinst: preinst runs as the
|
|
5
|
+
* low-privilege package user, which cannot read ACL at all -- `synoacltool -get`
|
|
6
|
+
* answers `Access deny` and there is no CAP_SETUID to try the access as the
|
|
7
|
+
* selected user. The two places that *do* hold an administrator session are the
|
|
8
|
+
* install wizard and CLI `deploy`, so the check belongs to them. That makes it a
|
|
9
|
+
* pre-flight guard, not an enforcement gate: `deploy` can still be handed
|
|
10
|
+
* extra_values directly, and share permission can change after install.
|
|
11
|
+
*
|
|
12
|
+
* The rows are SYNO.Core.Share.Permission v1 output verbatim. Read the tests
|
|
13
|
+
* before changing the predicate: on vt-nas, `newadmin` has is_writable=false on
|
|
14
|
+
* its own row for `pkgland-p0b` and still writes there, because the
|
|
15
|
+
* `administrators` group row carries the grant. A user-row-only check rejects
|
|
16
|
+
* the only configuration with live proof behind it.
|
|
17
|
+
*/
|
|
18
|
+
export interface SharePermissionRow {
|
|
19
|
+
name: string;
|
|
20
|
+
is_admin: boolean;
|
|
21
|
+
is_writable: boolean;
|
|
22
|
+
is_readonly: boolean;
|
|
23
|
+
is_deny: boolean;
|
|
24
|
+
is_custom: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type ShareAccess = "write" | "read" | "none" | "denied" | "unknown";
|
|
27
|
+
export interface ShareAccessVerdict {
|
|
28
|
+
access: ShareAccess;
|
|
29
|
+
/** Which row granted access, as `user:<name>` or `group:<name>`. */
|
|
30
|
+
grantedBy?: string;
|
|
31
|
+
/** Which row denied access, as `user:<name>` or `group:<name>`. */
|
|
32
|
+
deniedBy?: string;
|
|
33
|
+
/** Why an `unknown` verdict could not be decided. */
|
|
34
|
+
reason?: "custom-acl" | "no-rows";
|
|
35
|
+
}
|
|
36
|
+
export interface ShareAccessInput {
|
|
37
|
+
userName: string;
|
|
38
|
+
/** Group names the user belongs to. Matched exactly -- no case folding. */
|
|
39
|
+
userGroups: readonly string[];
|
|
40
|
+
userRows: readonly SharePermissionRow[];
|
|
41
|
+
groupRows: readonly SharePermissionRow[];
|
|
42
|
+
}
|
|
43
|
+
export declare function evaluateShareAccess(input: ShareAccessInput): ShareAccessVerdict;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Folder permission pre-flight for the dynamic package-only route.
|
|
3
|
+
*
|
|
4
|
+
* Why this lives here and not in the SPK's preinst: preinst runs as the
|
|
5
|
+
* low-privilege package user, which cannot read ACL at all -- `synoacltool -get`
|
|
6
|
+
* answers `Access deny` and there is no CAP_SETUID to try the access as the
|
|
7
|
+
* selected user. The two places that *do* hold an administrator session are the
|
|
8
|
+
* install wizard and CLI `deploy`, so the check belongs to them. That makes it a
|
|
9
|
+
* pre-flight guard, not an enforcement gate: `deploy` can still be handed
|
|
10
|
+
* extra_values directly, and share permission can change after install.
|
|
11
|
+
*
|
|
12
|
+
* The rows are SYNO.Core.Share.Permission v1 output verbatim. Read the tests
|
|
13
|
+
* before changing the predicate: on vt-nas, `newadmin` has is_writable=false on
|
|
14
|
+
* its own row for `pkgland-p0b` and still writes there, because the
|
|
15
|
+
* `administrators` group row carries the grant. A user-row-only check rejects
|
|
16
|
+
* the only configuration with live proof behind it.
|
|
17
|
+
*/
|
|
18
|
+
function relevantRows(input) {
|
|
19
|
+
const groups = new Set(input.userGroups);
|
|
20
|
+
const candidates = [];
|
|
21
|
+
for (const row of input.userRows) {
|
|
22
|
+
if (row.name === input.userName) {
|
|
23
|
+
candidates.push({ row, label: `user:${row.name}` });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
for (const row of input.groupRows) {
|
|
27
|
+
if (groups.has(row.name)) {
|
|
28
|
+
candidates.push({ row, label: `group:${row.name}` });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return candidates;
|
|
32
|
+
}
|
|
33
|
+
export function evaluateShareAccess(input) {
|
|
34
|
+
// An empty listing means the API answer was missing or truncated. Reading
|
|
35
|
+
// that as a denial would block installs on a transport error.
|
|
36
|
+
if (input.userRows.length === 0 && input.groupRows.length === 0) {
|
|
37
|
+
return { access: "unknown", reason: "no-rows" };
|
|
38
|
+
}
|
|
39
|
+
const candidates = relevantRows(input);
|
|
40
|
+
// Advanced/Windows ACL overrides share permission and only root-only
|
|
41
|
+
// synoacltool can read it, so any custom row in the deciding set makes the
|
|
42
|
+
// whole verdict inconclusive -- in both directions.
|
|
43
|
+
if (candidates.some(({ row }) => row.is_custom)) {
|
|
44
|
+
return { access: "unknown", reason: "custom-acl" };
|
|
45
|
+
}
|
|
46
|
+
const denied = candidates.find(({ row }) => row.is_deny);
|
|
47
|
+
if (denied) {
|
|
48
|
+
return { access: "denied", deniedBy: denied.label };
|
|
49
|
+
}
|
|
50
|
+
const writable = candidates.find(({ row }) => row.is_admin || row.is_writable);
|
|
51
|
+
if (writable) {
|
|
52
|
+
return { access: "write", grantedBy: writable.label };
|
|
53
|
+
}
|
|
54
|
+
const readable = candidates.find(({ row }) => row.is_readonly);
|
|
55
|
+
if (readable) {
|
|
56
|
+
return { access: "read", grantedBy: readable.label };
|
|
57
|
+
}
|
|
58
|
+
return { access: "none" };
|
|
59
|
+
}
|
package/dist/host/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SharePermissionListing } from "../deploy/share-permission-preflight.js";
|
|
1
2
|
import type { InstalledPackageState } from "./catalog-install.js";
|
|
2
3
|
export type AttestationKind = "local" | "app-package" | "none";
|
|
3
4
|
export type PackageRoute = "package-only" | "broker-installer" | "unknown";
|
|
@@ -79,6 +80,14 @@ export interface NasHost {
|
|
|
79
80
|
/** List the current logical Shared Folder names using an administrator session. */
|
|
80
81
|
listShareNames?(): Promise<string[]>;
|
|
81
82
|
resolveSharePath?(logicalName: string): Promise<string | undefined>;
|
|
83
|
+
/**
|
|
84
|
+
* Read SYNO.Core.Share.Permission for one Share using an administrator
|
|
85
|
+
* session. Returns undefined when the host cannot answer -- callers must
|
|
86
|
+
* treat that as "cannot tell", never as a denial.
|
|
87
|
+
*/
|
|
88
|
+
listSharePermissions?(logicalName: string): Promise<SharePermissionListing | undefined>;
|
|
89
|
+
/** Group names a DSM user belongs to, for resolving share permission. */
|
|
90
|
+
listUserGroups?(userName: string): Promise<string[] | undefined>;
|
|
82
91
|
/** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
|
|
83
92
|
resolveHomesPath?(): Promise<string | undefined>;
|
|
84
93
|
getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
|