@synopackageland/cli 0.3.8 → 0.3.10

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.
@@ -0,0 +1,208 @@
1
+ import { basename } from "node:path";
2
+ import { composeProjectName } from "../build-spk/resource-worker.js";
3
+ import { dsmFetch } from "./dsm-http.js";
4
+ /** The precision caveat for the administrator-only fallback. */
5
+ export const DSM_PORT_PROFILE_PRECISION_WARNING = "API 版未讀取 conf/resource;若有 App 自訂 Compose project 名可能誤報";
6
+ /**
7
+ * Parses the Docker .sc filename using the same rules as the Broker's
8
+ * parsePortProfileFilename(). In particular, projectName is everything before
9
+ * the final two dash-separated container segments.
10
+ */
11
+ export function parsePortProfileFilename(filename) {
12
+ const base = basename(filename);
13
+ if (!base.startsWith("docker_") || !base.endsWith(".sc")) {
14
+ return undefined;
15
+ }
16
+ const containerName = base.slice("docker_".length, base.length - ".sc".length);
17
+ if (!containerName) {
18
+ return undefined;
19
+ }
20
+ // Keep this split aligned with packages/broker/src/diagnostics/dsm-residue.ts:
21
+ // remove the final -<service>-<replica> pair, not an identity-specific guess.
22
+ const lastDash = containerName.lastIndexOf("-");
23
+ if (lastDash <= 0) {
24
+ return undefined;
25
+ }
26
+ const secondLastDash = containerName.lastIndexOf("-", lastDash - 1);
27
+ if (secondLastDash <= 0) {
28
+ return undefined;
29
+ }
30
+ const projectName = containerName.slice(0, secondLastDash);
31
+ const presumedPackageIdentity = projectName.replace(/-/g, ".");
32
+ return {
33
+ containerName,
34
+ projectName,
35
+ presumedPackageIdentity,
36
+ };
37
+ }
38
+ /** Parse one DSM dst_port value, including the documented range form. */
39
+ export function parseDsmPortRange(value) {
40
+ const match = /^([0-9]{1,5})(?:-([0-9]{1,5}))?$/.exec(value.trim());
41
+ if (!match) {
42
+ return undefined;
43
+ }
44
+ const start = Number(match[1]);
45
+ const end = Number(match[2] ?? match[1]);
46
+ if (!Number.isInteger(start) ||
47
+ !Number.isInteger(end) ||
48
+ start < 1 ||
49
+ end > 65535 ||
50
+ start > end) {
51
+ return undefined;
52
+ }
53
+ return { start, end };
54
+ }
55
+ function parseDsmPortRanges(value) {
56
+ if (!Array.isArray(value) || value.length === 0) {
57
+ return undefined;
58
+ }
59
+ const ranges = [];
60
+ for (const entry of value) {
61
+ if (typeof entry !== "string") {
62
+ return undefined;
63
+ }
64
+ const range = parseDsmPortRange(entry);
65
+ if (!range) {
66
+ return undefined;
67
+ }
68
+ ranges.push(range);
69
+ }
70
+ return ranges;
71
+ }
72
+ function record(value) {
73
+ return value && typeof value === "object" && !Array.isArray(value)
74
+ ? value
75
+ : undefined;
76
+ }
77
+ function nonEmptyString(value) {
78
+ return typeof value === "string" && value.length > 0 ? value : undefined;
79
+ }
80
+ function parseDockerPortInfo(value) {
81
+ const entry = record(value);
82
+ if (!entry) {
83
+ return undefined;
84
+ }
85
+ // PortInfo also contains ordinary DSM service rows. Only the documented
86
+ // Docker(TCP) rows correspond to docker_<container>.sc profiles.
87
+ if (entry.protocol !== "tcp" || entry.name !== "Docker(TCP)") {
88
+ return undefined;
89
+ }
90
+ const portId = nonEmptyString(entry.port_id);
91
+ if (!portId?.endsWith("_tcp")) {
92
+ return undefined;
93
+ }
94
+ const containerName = portId.slice(0, -"_tcp".length);
95
+ if (!containerName || entry.desc !== `Docker ${containerName}`) {
96
+ return undefined;
97
+ }
98
+ const parsed = parsePortProfileFilename(`docker_${containerName}.sc`);
99
+ const dstPorts = parseDsmPortRanges(entry.dst_port);
100
+ if (!parsed || !dstPorts) {
101
+ return undefined;
102
+ }
103
+ return {
104
+ portId,
105
+ containerName: parsed.containerName,
106
+ projectName: parsed.projectName,
107
+ presumedPackageIdentity: parsed.presumedPackageIdentity,
108
+ dstPorts,
109
+ };
110
+ }
111
+ /** Parse a successful SYNO.Core.Service.PortInfo/load response. */
112
+ export function parseDsmPortInfoPayload(payload) {
113
+ const root = record(payload);
114
+ const data = record(root?.data);
115
+ if (root?.success !== true || !data || !Array.isArray(data.port_info)) {
116
+ return undefined;
117
+ }
118
+ return data.port_info.flatMap((entry) => {
119
+ const profile = parseDockerPortInfo(entry);
120
+ return profile ? [profile] : [];
121
+ });
122
+ }
123
+ /** Parse the installed identity set from a successful DSM package list. */
124
+ export function installedPackageIdentitiesFromPayload(payload) {
125
+ const root = record(payload);
126
+ const data = record(root?.data);
127
+ if (root?.success !== true || !data || !Array.isArray(data.packages)) {
128
+ return undefined;
129
+ }
130
+ return [
131
+ ...new Set(data.packages
132
+ .map((entry) => record(entry)?.id)
133
+ .filter((id) => typeof id === "string" && id.length > 0)),
134
+ ];
135
+ }
136
+ /**
137
+ * Build the API-only inventory from the two independent DSM responses.
138
+ *
139
+ * The package list is a forward mapping: package identity -> project name.
140
+ * Do not use presumedPackageIdentity here; replacing dashes with dots is
141
+ * lossy for identities such as com.example.hello-web.
142
+ */
143
+ export function dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload) {
144
+ const profiles = parseDsmPortInfoPayload(portInfoPayload);
145
+ if (!profiles) {
146
+ return undefined;
147
+ }
148
+ // An empty PortInfo list is a safe, successful "nothing to report" result;
149
+ // there is no reason to turn an unavailable package list into an orphan.
150
+ if (profiles.length === 0) {
151
+ return { profiles, orphaned: [] };
152
+ }
153
+ const installedPackages = installedPackageIdentitiesFromPayload(packageListPayload);
154
+ // An empty or unavailable package list cannot prove that any profile is an
155
+ // orphan. Fail closed instead of classifying every visible Docker row.
156
+ if (!installedPackages || installedPackages.length === 0) {
157
+ return undefined;
158
+ }
159
+ const declaredProjectNames = new Set(installedPackages.map(composeProjectName));
160
+ const orphaned = profiles.filter((profile) => !declaredProjectNames.has(profile.projectName));
161
+ return { profiles, orphaned };
162
+ }
163
+ async function fetchJson(dsmOrigin, cookieHeader, params) {
164
+ const query = new URLSearchParams(params);
165
+ const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`, {
166
+ headers: { cookie: cookieHeader },
167
+ });
168
+ if (!response.ok) {
169
+ return undefined;
170
+ }
171
+ try {
172
+ return await response.json();
173
+ }
174
+ catch {
175
+ return undefined;
176
+ }
177
+ }
178
+ /**
179
+ * Read PortInfo.load and the administrator-visible installed package list.
180
+ * Any transport, HTTP, JSON, or DSM API failure becomes undefined so callers
181
+ * can skip without inventing residue.
182
+ */
183
+ export async function fetchDsmPortProfileInventory(dsmOrigin, cookieHeader) {
184
+ try {
185
+ const portInfoPayload = await fetchJson(dsmOrigin, cookieHeader, {
186
+ api: "SYNO.Core.Service.PortInfo",
187
+ version: "1",
188
+ method: "load",
189
+ });
190
+ const profiles = parseDsmPortInfoPayload(portInfoPayload);
191
+ if (!profiles) {
192
+ return undefined;
193
+ }
194
+ if (profiles.length === 0) {
195
+ return { profiles, orphaned: [] };
196
+ }
197
+ const packageListPayload = await fetchJson(dsmOrigin, cookieHeader, {
198
+ api: "SYNO.Core.Package",
199
+ version: "2",
200
+ method: "list",
201
+ additional: JSON.stringify(["status"]),
202
+ });
203
+ return dsmPortProfileInventoryFromPayloads(portInfoPayload, packageListPayload);
204
+ }
205
+ catch {
206
+ return undefined;
207
+ }
208
+ }
@@ -1,4 +1,10 @@
1
+ import type { SharePermissionRow } from "./share-permission.js";
2
+ import type { PortInfoEntry } from "./port-info.js";
1
3
  import type { AttestationKind, HostInstallRecord, HostOidcCompatSettings, NasHost, PackageRoute } from "./types.js";
4
+ import type { DsmPortProfileInventory } from "./dsm-port-profile.js";
5
+ export type SharePermissionRowInput = {
6
+ name: string;
7
+ } & Partial<Omit<SharePermissionRow, "name">>;
2
8
  export interface FakeNasHostOptions {
3
9
  curated?: string[];
4
10
  dsmOrigin?: string;
@@ -6,6 +12,21 @@ export interface FakeNasHostOptions {
6
12
  installationId?: string;
7
13
  shareNames?: string[];
8
14
  shares?: Record<string, string>;
15
+ /**
16
+ * SYNO.Core.Share.Permission rows per Share, as partials -- every flag
17
+ * defaults to false, matching a row DSM grants nothing on. Omit the whole
18
+ * option to model a host that cannot answer the permission question.
19
+ */
20
+ sharePermissions?: Record<string, {
21
+ users: SharePermissionRowInput[];
22
+ groups: SharePermissionRowInput[];
23
+ }>;
24
+ /** Group membership per DSM user, for share-permission resolution. */
25
+ userGroups?: Record<string, string[]>;
26
+ /** SYNO.Core.Service.PortInfo rows; omit to model a host without the capability. */
27
+ portInfo?: PortInfoEntry[];
28
+ /** Model a host whose PortInfo capability is present but the API failed. */
29
+ portInfoFailure?: boolean;
9
30
  installedEnv?: Record<string, Record<string, string>>;
10
31
  diagnostics?: {
11
32
  dsmResidue?: import("./types.js").DsmResidueInventory;
@@ -13,6 +34,8 @@ export interface FakeNasHostOptions {
13
34
  isPackageOnly?: boolean;
14
35
  [key: string]: unknown;
15
36
  };
37
+ /** Administrator-only DSM PortInfo inventory, when the fake should expose it. */
38
+ portProfileInventory?: DsmPortProfileInventory;
16
39
  /** Omit to model an older Broker without the OIDC settings endpoint. */
17
40
  oidcCompat?: HostOidcCompatSettings;
18
41
  }
@@ -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,43 @@ 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
+ : {}),
66
+ ...(options.portInfo !== undefined || options.portInfoFailure
67
+ ? {
68
+ async listPortInfo() {
69
+ if (options.portInfoFailure) {
70
+ return undefined;
71
+ }
72
+ return options.portInfo?.map((entry) => ({
73
+ ...entry,
74
+ dst_port: [...entry.dst_port],
75
+ ...(entry.src_port === undefined
76
+ ? {}
77
+ : { src_port: entry.src_port === null ? null : [...entry.src_port] }),
78
+ }));
79
+ },
80
+ }
81
+ : {}),
35
82
  async resolveHomesPath() {
36
83
  return options.homesPath;
37
84
  },
@@ -123,6 +170,9 @@ export function createFakeNasHost(options = {}) {
123
170
  async getDsmDiagnostics() {
124
171
  return options.diagnostics;
125
172
  },
173
+ async getDsmPortProfileInventory() {
174
+ return options.portProfileInventory;
175
+ },
126
176
  async getOidcCompatSettings() {
127
177
  if (!options.oidcCompat) {
128
178
  return undefined;
@@ -0,0 +1,9 @@
1
+ import type { PortInfoEntry } from "./port-info.js";
2
+ export type PortInfoHttpRequest = (url: string, init?: RequestInit) => Promise<Response>;
3
+ /**
4
+ * Parses the native DSM response, not a synoagentcli operation envelope.
5
+ * `undefined` means "cannot tell" and deliberately allows deployment to
6
+ * continue. An empty `port_info` list has the same fail-open meaning.
7
+ */
8
+ export declare function parsePortInfo(payload: unknown): PortInfoEntry[] | undefined;
9
+ export declare function fetchPortInfo(dsmOrigin: string, cookieHeader: string, request?: PortInfoHttpRequest): Promise<PortInfoEntry[] | undefined>;
@@ -0,0 +1,87 @@
1
+ import { dsmFetch } from "./dsm-http.js";
2
+ const PORT_INFO_API = "SYNO.Core.Service.PortInfo";
3
+ function asRecord(value) {
4
+ if (value && typeof value === "object" && !Array.isArray(value)) {
5
+ return value;
6
+ }
7
+ return undefined;
8
+ }
9
+ function validPortRange(value) {
10
+ const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value.trim());
11
+ if (!match) {
12
+ return false;
13
+ }
14
+ const start = Number(match[1]);
15
+ const end = Number(match[2] ?? match[1]);
16
+ return (Number.isSafeInteger(start) &&
17
+ Number.isSafeInteger(end) &&
18
+ start >= 1 &&
19
+ start <= end &&
20
+ end <= 65535);
21
+ }
22
+ /**
23
+ * Parses the native DSM response, not a synoagentcli operation envelope.
24
+ * `undefined` means "cannot tell" and deliberately allows deployment to
25
+ * continue. An empty `port_info` list has the same fail-open meaning.
26
+ */
27
+ export function parsePortInfo(payload) {
28
+ const outer = asRecord(payload);
29
+ const data = asRecord(outer?.data);
30
+ const items = data?.port_info;
31
+ if (outer?.success !== true || !Array.isArray(items) || items.length === 0) {
32
+ return undefined;
33
+ }
34
+ const entries = [];
35
+ for (const item of items) {
36
+ const record = asRecord(item);
37
+ if (!record) {
38
+ return undefined;
39
+ }
40
+ const desc = record?.desc;
41
+ const dstPort = record?.dst_port;
42
+ const name = record?.name;
43
+ const portId = record?.port_id;
44
+ const protocol = record?.protocol;
45
+ if (typeof desc !== "string" ||
46
+ typeof name !== "string" ||
47
+ typeof portId !== "string" ||
48
+ typeof protocol !== "string" ||
49
+ !Array.isArray(dstPort) ||
50
+ dstPort.length === 0 ||
51
+ !dstPort.every((value) => typeof value === "string" && validPortRange(value))) {
52
+ return undefined;
53
+ }
54
+ const srcPort = record.src_port;
55
+ if (srcPort !== undefined &&
56
+ srcPort !== null &&
57
+ (!Array.isArray(srcPort) || !srcPort.every((value) => typeof value === "string"))) {
58
+ return undefined;
59
+ }
60
+ entries.push({
61
+ desc,
62
+ dst_port: [...dstPort],
63
+ name,
64
+ port_id: portId,
65
+ protocol,
66
+ ...(srcPort === undefined ? {} : { src_port: srcPort === null ? null : [...srcPort] }),
67
+ });
68
+ }
69
+ return entries;
70
+ }
71
+ export async function fetchPortInfo(dsmOrigin, cookieHeader, request = dsmFetch) {
72
+ try {
73
+ const query = new URLSearchParams({
74
+ api: PORT_INFO_API,
75
+ version: "1",
76
+ method: "load",
77
+ });
78
+ const response = await request(`${dsmOrigin.replace(/\/$/, "")}/webapi/entry.cgi?${query.toString()}`, { headers: { cookie: cookieHeader } });
79
+ if (!response.ok) {
80
+ return undefined;
81
+ }
82
+ return parsePortInfo(await response.json());
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
@@ -0,0 +1,33 @@
1
+ /** One row returned by SYNO.Core.Service.PortInfo/load. */
2
+ export interface PortInfoEntry {
3
+ desc: string;
4
+ dst_port: readonly string[];
5
+ name: string;
6
+ port_id: string;
7
+ protocol: string;
8
+ src_port?: readonly string[] | null;
9
+ }
10
+ export type DsmPortInfoEntry = PortInfoEntry;
11
+ export type PortInfoRow = PortInfoEntry;
12
+ export interface PortConflict {
13
+ port: number;
14
+ services: readonly string[];
15
+ occupants: readonly PortInfoEntry[];
16
+ }
17
+ /**
18
+ * Returns the Docker container name represented by a DSM port row, when the
19
+ * row has Docker's known `port_id` or `desc` shape. Other DSM services are
20
+ * intentionally returned as undefined so they remain conflicts.
21
+ */
22
+ export declare function dockerContainerName(entry: PortInfoEntry): string | undefined;
23
+ export declare function formatPortOccupant(entry: PortInfoEntry): string;
24
+ /**
25
+ * Finds occupied published ports, ignoring only containers that are one of
26
+ * the Compose containers for this exact package identity. A same-identity
27
+ * occupant is the existing install being upgraded or reinstalled.
28
+ */
29
+ export declare function findPortConflicts(input: {
30
+ publishedPorts: ReadonlyMap<number, readonly string[]>;
31
+ packageIdentity: string;
32
+ portInfo: readonly PortInfoEntry[];
33
+ }): PortConflict[];
@@ -0,0 +1,66 @@
1
+ import { composeProjectName } from "../build-spk/resource-worker.js";
2
+ function parsePortRange(value) {
3
+ const match = /^(\d+)(?:\s*-\s*(\d+))?$/.exec(value.trim());
4
+ if (!match) {
5
+ return undefined;
6
+ }
7
+ const start = Number(match[1]);
8
+ const end = Number(match[2] ?? match[1]);
9
+ if (!Number.isSafeInteger(start) ||
10
+ !Number.isSafeInteger(end) ||
11
+ start < 1 ||
12
+ end > 65535 ||
13
+ start > end) {
14
+ return undefined;
15
+ }
16
+ return { start, end };
17
+ }
18
+ function escapeRegExp(value) {
19
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ }
21
+ function entryCoversPort(entry, port) {
22
+ return entry.dst_port.some((value) => {
23
+ const range = parsePortRange(value);
24
+ return range !== undefined && range.start <= port && port <= range.end;
25
+ });
26
+ }
27
+ /**
28
+ * Returns the Docker container name represented by a DSM port row, when the
29
+ * row has Docker's known `port_id` or `desc` shape. Other DSM services are
30
+ * intentionally returned as undefined so they remain conflicts.
31
+ */
32
+ export function dockerContainerName(entry) {
33
+ const portIdMatch = /^(.*)_tcp$/i.exec(entry.port_id.trim());
34
+ if (portIdMatch?.[1]) {
35
+ return portIdMatch[1];
36
+ }
37
+ const descMatch = /^Docker\s+(.+)$/i.exec(entry.desc.trim());
38
+ return descMatch?.[1]?.trim() || undefined;
39
+ }
40
+ export function formatPortOccupant(entry) {
41
+ return dockerContainerName(entry) ?? (entry.desc.trim() || entry.port_id.trim() || entry.name.trim());
42
+ }
43
+ /**
44
+ * Finds occupied published ports, ignoring only containers that are one of
45
+ * the Compose containers for this exact package identity. A same-identity
46
+ * occupant is the existing install being upgraded or reinstalled.
47
+ */
48
+ export function findPortConflicts(input) {
49
+ const conflicts = [];
50
+ const projectName = composeProjectName(input.packageIdentity);
51
+ for (const [port, services] of input.publishedPorts) {
52
+ // Compose names a container <project>-<service>-<replica>. Match the
53
+ // replica as a number rather than assuming -1: a scaled service would
54
+ // otherwise look like a foreign occupant and refuse this package's own
55
+ // redeploy. The service name is still matched exactly, so a different
56
+ // package whose project name merely shares a prefix stays a conflict.
57
+ const ownContainer = (containerName) => services.some((service) => new RegExp(`^${escapeRegExp(`${projectName}-${service}-`)}\\d+$`).test(containerName));
58
+ const occupants = input.portInfo.filter((entry) => entry.protocol.trim().toLowerCase() === "tcp" &&
59
+ entryCoversPort(entry, port) &&
60
+ !ownContainer(dockerContainerName(entry) ?? ""));
61
+ if (occupants.length > 0) {
62
+ conflicts.push({ port, services, occupants });
63
+ }
64
+ }
65
+ return conflicts;
66
+ }
@@ -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
+ }