@synopackageland/cli 0.3.7 → 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/build-spk/generator.js +80 -4
- package/dist/cli.js +3 -0
- package/dist/compose/analyze.d.ts +1 -0
- package/dist/compose/analyze.js +11 -3
- package/dist/contract/posix-all-shares.js +2 -2
- package/dist/contract/schema-validate.js +1 -26
- package/dist/contract/validate.js +0 -10
- package/dist/deploy/deploy.d.ts +2 -0
- package/dist/deploy/deploy.js +47 -1
- package/dist/deploy/param-values.js +2 -1
- package/dist/deploy/share-permission-preflight.d.ts +37 -0
- package/dist/deploy/share-permission-preflight.js +81 -0
- package/dist/error-registry.js +13 -3
- package/dist/host/dsm-client.js +11 -1
- package/dist/host/fake-nas.d.ts +16 -0
- package/dist/host/fake-nas.js +39 -0
- package/dist/host/share-lookup.d.ts +2 -0
- package/dist/host/share-lookup.js +17 -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 +11 -0
- package/dist/validate.js +7 -2
- package/package.json +1 -1
- package/schema/synology-app.schema.json +1 -1
- package/skills/reference/create.md +4 -0
|
@@ -185,6 +185,7 @@ export async function buildSpk(options) {
|
|
|
185
185
|
const resourceProjectDir = join(staging, "package", "compose");
|
|
186
186
|
mkdirSync(resourceProjectDir, { recursive: true });
|
|
187
187
|
const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
|
|
188
|
+
let composeContent;
|
|
188
189
|
if (dsmUserParam || rwEligibility?.headless === true || hasOidcCompat(contract)) {
|
|
189
190
|
const rawYaml = readFileSync(options.project.composePath, "utf8");
|
|
190
191
|
const doc = parseDocument(rawYaml);
|
|
@@ -222,16 +223,17 @@ export async function buildSpk(options) {
|
|
|
222
223
|
if (rwEligibility?.headless === true) {
|
|
223
224
|
injectHeadlessProbeVolume(doc);
|
|
224
225
|
}
|
|
225
|
-
|
|
226
|
+
composeContent = doc.toString();
|
|
226
227
|
if (dsmUserParam) {
|
|
227
228
|
const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
|
|
228
229
|
composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
|
|
229
230
|
}
|
|
230
|
-
writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
|
|
231
231
|
}
|
|
232
232
|
else {
|
|
233
|
-
|
|
233
|
+
composeContent = readFileSync(options.project.composePath, "utf8");
|
|
234
234
|
}
|
|
235
|
+
composeContent = injectPosixAllSharesMarkers(composeContent, contract);
|
|
236
|
+
writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
|
|
235
237
|
chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
|
|
236
238
|
if (rwEligibility?.headless === true) {
|
|
237
239
|
const probePayloadDir = join(resourceProjectDir, ".synopkgland");
|
|
@@ -354,6 +356,7 @@ function renderPrivilege() {
|
|
|
354
356
|
}
|
|
355
357
|
const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
|
|
356
358
|
const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
|
|
359
|
+
const POSIX_ALL_SHARES_MAX_WARNING = "掛載這台 NAS 目前列出的每一個共用資料夾。容器內可讀(或讀寫,依 Compose 模式)所有這些資料夾的檔案。安裝後新建的共用資料夾不會自動加入。由管理員決定是否安裝。";
|
|
357
360
|
const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
|
|
358
361
|
const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
|
|
359
362
|
const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
|
|
@@ -451,6 +454,29 @@ function injectHeadlessProbeVolume(doc) {
|
|
|
451
454
|
}
|
|
452
455
|
doc.setIn(["services", serviceName, "volumes"], volumes);
|
|
453
456
|
}
|
|
457
|
+
function injectPosixAllSharesMarkers(composeContent, contract) {
|
|
458
|
+
const names = collectInstallParameters(contract)
|
|
459
|
+
.filter(({ param }) => param.type === "posix_all_shares")
|
|
460
|
+
.map(({ name }) => name);
|
|
461
|
+
if (names.length === 0) {
|
|
462
|
+
return composeContent;
|
|
463
|
+
}
|
|
464
|
+
const declared = new Set(names);
|
|
465
|
+
const lines = composeContent.split("\n");
|
|
466
|
+
const output = [];
|
|
467
|
+
for (const line of lines) {
|
|
468
|
+
const match = line.match(/^(\s*)-\s*["']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}:/);
|
|
469
|
+
const name = match?.[2];
|
|
470
|
+
if (match && name && declared.has(name)) {
|
|
471
|
+
const marker = `# synopkgland:posix_all_shares ${name}`;
|
|
472
|
+
if (output.at(-1)?.trim() !== marker) {
|
|
473
|
+
output.push(`${match[1]}${marker}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
output.push(line);
|
|
477
|
+
}
|
|
478
|
+
return output.join("\n");
|
|
479
|
+
}
|
|
454
480
|
const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
|
|
455
481
|
function renderInstallWizard(contract, compose) {
|
|
456
482
|
const items = [];
|
|
@@ -557,6 +583,39 @@ function renderInstallWizard(contract, compose) {
|
|
|
557
583
|
});
|
|
558
584
|
continue;
|
|
559
585
|
}
|
|
586
|
+
if (param.type === "posix_all_shares") {
|
|
587
|
+
items.push({
|
|
588
|
+
type: "textblock",
|
|
589
|
+
desc: POSIX_ALL_SHARES_MAX_WARNING,
|
|
590
|
+
});
|
|
591
|
+
items.push({
|
|
592
|
+
type: "combobox",
|
|
593
|
+
multiSelect: true,
|
|
594
|
+
desc: `POSIX all shares: ${name}`,
|
|
595
|
+
subitems: [{
|
|
596
|
+
key: name,
|
|
597
|
+
desc: name,
|
|
598
|
+
displayField: "name",
|
|
599
|
+
valueField: "name",
|
|
600
|
+
multiSelect: true,
|
|
601
|
+
editable: false,
|
|
602
|
+
forceSelection: true,
|
|
603
|
+
autoSelect: false,
|
|
604
|
+
mode: "remote",
|
|
605
|
+
api_store: {
|
|
606
|
+
api: "SYNO.Core.Share",
|
|
607
|
+
method: "list",
|
|
608
|
+
version: 1,
|
|
609
|
+
baseParams: { limit: -1, offset: 0 },
|
|
610
|
+
root: "shares",
|
|
611
|
+
idProperty: "name",
|
|
612
|
+
fields: ["name"],
|
|
613
|
+
},
|
|
614
|
+
validator: { allowBlank: false },
|
|
615
|
+
}],
|
|
616
|
+
});
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
560
619
|
if (param.type === "dsm_user") {
|
|
561
620
|
items.push({
|
|
562
621
|
type: "combobox",
|
|
@@ -654,6 +713,7 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
654
713
|
const allParams = collectInstallParameters(contract);
|
|
655
714
|
const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
|
|
656
715
|
const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
|
|
716
|
+
const posixAllSharesParams = allParams.filter(({ param }) => param.type === "posix_all_shares");
|
|
657
717
|
const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
|
|
658
718
|
const lines = [
|
|
659
719
|
"#!/bin/sh",
|
|
@@ -716,9 +776,12 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
716
776
|
" esac",
|
|
717
777
|
"}",
|
|
718
778
|
];
|
|
719
|
-
if (sharedFolderParams.length > 0) {
|
|
779
|
+
if (sharedFolderParams.length > 0 || posixAllSharesParams.length > 0) {
|
|
720
780
|
lines.push("resolve_shared_folder() {", " share_name=\"$1\"", " case \"$share_name\" in", " */*|*..*) die \"invalid share name $share_name\" ;;", " '') die \"share name is empty\" ;;", " esac", " mounts_file=\"${D54_MOUNTS_FILE:-/proc/mounts}\"", " if [ ! -r \"$mounts_file\" ] && [ -r \"/etc/mtab\" ]; then", " mounts_file=\"/etc/mtab\"", " fi", " if [ ! -r \"$mounts_file\" ]; then", " die \"cannot read mount points from $mounts_file\"", " fi", " vols=\"$(awk '$2 ~ /^\\/volume[0-9]+$/ {print $2}' \"$mounts_file\" | sort -u)\"", ' if [ -z "$vols" ]; then', " vols=\"$(awk '{print $2}' \"$mounts_file\" | sort -u)\"", " fi", " matched=\"\"", " count=0", " for v in $vols; do", " if [ -d \"$v/$share_name\" ]; then", " matched=\"$v/$share_name\"", " count=$((count + 1))", " fi", " done", " if [ \"$count\" -eq 0 ]; then", " die \"share $share_name not found on any volume\"", " elif [ \"$count\" -gt 1 ]; then", " die \"share $share_name matched multiple volumes\"", " fi", " printf '%s' \"$matched\"", "}");
|
|
721
781
|
}
|
|
782
|
+
if (posixAllSharesParams.length > 0) {
|
|
783
|
+
lines.push("normalize_posix_all_shares() {", " value=\"$1\"", " case \"$value\" in", " \\[*\\]) value=\"$(printf '%s' \"$value\" | sed 's/^[[:space:]]*\\[//; s/\\][[:space:]]*$//; s/\"//g')\" ;;", " esac", " printf '%s' \"$value\" | awk -F, '{ for (i = 1; i <= NF; i++) { gsub(/^[ \\t]+|[ \\t]+$/, \"\", $i); if ($i != \"\") print $i } }'", "}", "resolve_posix_all_shares() {", " raw_list=\"$1\"", " normalized=\"$(normalize_posix_all_shares \"$raw_list\")\"", " [ -n \"$normalized\" ] || die \"POSIX_ALL_SHARES_EMPTY\"", " names=\"\"", " paths=\"\"", " count=0", " while IFS= read -r share_name; do", " case \"$share_name\" in", " */*|*..*|@*|'') die \"invalid Share name $share_name\" ;;", " *[[:cntrl:]]*) die \"invalid Share name $share_name\" ;;", " esac", " share_path=\"$(resolve_shared_folder \"$share_name\")\"", " names=\"${names}${names:+,}${share_name}\"", " paths=\"${paths}${paths:+,}${share_path}\"", " count=$((count + 1))", " done <<EOF", "$normalized", "EOF", " [ \"$count\" -gt 0 ] || die \"POSIX_ALL_SHARES_EMPTY\"", " printf '%s\\n%s\\n%s' \"$names\" \"$paths\" \"$count\"", "}", "rewrite_posix_all_shares() {", " p_name=\"$1\"", " share_names=\"$2\"", " share_paths=\"$3\"", " compose_file=\"$4\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v share_names=\"$share_names\" -v share_paths=\"$share_paths\" '", " BEGIN {", " name_count = split(share_names, names, \",\")", " path_count = split(share_paths, paths, \",\")", " marker = \"# synopkgland:posix_all_shares \" param", " pending = 0", " single_quote = sprintf(\"%c\", 39)", " }", " {", " if (index($0, marker) > 0 && $0 ~ /^[ \\t]*# synopkgland:posix_all_shares /) {", " print $0", " pending = 1", " next", " }", " if (!pending) { print $0; next }", " pending = 0", " line = $0", " indent = line", " sub(/[^ \\t].*$/, \"\", indent)", " entry = line", " sub(/^[ \\t]*-[ \\t]*/, \"\", entry)", " if (entry ~ /^\".*\"$/) entry = substr(entry, 2, length(entry) - 2)", " if (length(entry) >= 2 && substr(entry, 1, 1) == single_quote && substr(entry, length(entry), 1) == single_quote) entry = substr(entry, 2, length(entry) - 2)", " split(entry, fields, \":\")", " if (fields[1] != \"${\" param \"}\" || fields[2] == \"\") { print $0; next }", " mode = \"\"", " if (length(fields) >= 3) mode = \":\" fields[3]", " for (i = 1; i <= name_count && i <= path_count; i++) {", " print indent \"- \" paths[i] \":\" fields[2] \"/\" names[i] mode", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to expand POSIX all-shares in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
|
|
784
|
+
}
|
|
722
785
|
if (dsmUserParam) {
|
|
723
786
|
lines.push("resolve_dsm_user() {", " user_name=\"$1\"", " case \"$user_name\" in", " */*|*..*|*[[:cntrl:]]*) die \"invalid user name $user_name\" ;;", " '') die \"user name is empty\" ;;", " esac", " u_uid=\"$(id -u -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_uid\" in", " ''|*[!0-9]*) die \"resolved uid for $user_name is not a valid integer\" ;;", " 0) die \"resolved uid for $user_name cannot be 0 (root)\" ;;", " esac", " u_gid=\"$(id -g -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_gid\" in", " ''|*[!0-9]*) die \"resolved gid for $user_name is not a valid integer\" ;;", " esac", " # id -G returns all group ids including primary", " raw_groups=\"$(id -G -- \"$user_name\" 2>/dev/null || true)\"", " # filter out primary gid, deduplicate, keep numeric only", " supp_groups=\"\"", " for g in $raw_groups; do", " case \"$g\" in", " ''|*[!0-9]*) continue ;;", " esac", " if [ \"$g\" != \"$u_gid\" ]; then", " already=0", " for sg in $supp_groups; do", " if [ \"$sg\" = \"$g\" ]; then", " already=1", " break", " fi", " done", " if [ \"$already\" -eq 0 ]; then", " supp_groups=\"${supp_groups}${supp_groups:+ }$g\"", " fi", " fi", " done", " printf '%s %s %s' \"$u_uid\" \"$u_gid\" \"$supp_groups\"", "}", "rewrite_group_add() {", " p_name=\"$1\"", " g_list=\"$2\"", " compose_file=\"$3\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v g_list=\"$g_list\" '", " BEGIN {", " n = split(g_list, g_arr, \" \")", " }", " {", " regex = \"^[ \\t]*# synopkgland:group_add \" param \"$\"", " if ($0 ~ regex) {", " match($0, /^[ \\t]*/)", " indent = substr($0, RSTART, RLENGTH)", " if (n > 0) {", " print indent \"group_add:\"", " for (i = 1; i <= n; i++) {", " print indent \" - ${\" param \"_GROUP_\" i \"}\"", " }", " }", " } else {", " print $0", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to rewrite group_add in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
|
|
724
787
|
}
|
|
@@ -752,6 +815,16 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
752
815
|
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
753
816
|
`${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required shared folder parameter ${name}"`, " fi", ` case "$${rawVar}" in`, " /*) " + pathVar + "=\"$" + rawVar + "\" ;;", " *) " + pathVar + "=\"$(resolve_shared_folder \"$" + rawVar + "\")\" ;;", " esac");
|
|
754
817
|
}
|
|
818
|
+
const posixVars = [];
|
|
819
|
+
for (const { name } of posixAllSharesParams) {
|
|
820
|
+
const rawVar = "D54_RAW_POSIX_" + name;
|
|
821
|
+
const namesVar = "D54_POSIX_NAMES_" + name;
|
|
822
|
+
const pathsVar = "D54_POSIX_PATHS_" + name;
|
|
823
|
+
const countVar = "D54_POSIX_COUNT_" + name;
|
|
824
|
+
posixVars.push({ name, namesVar, pathsVar, countVar });
|
|
825
|
+
lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
|
|
826
|
+
`${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required POSIX all-shares parameter ${name}"`, " fi", ` posix_info="$(resolve_posix_all_shares "$${rawVar}")"`, ` ${namesVar}="$(printf '%s\\n' "$posix_info" | sed -n '1p')"`, ` ${pathsVar}="$(printf '%s\\n' "$posix_info" | sed -n '2p')"`, ` ${countVar}="$(printf '%s\\n' "$posix_info" | sed -n '3p')"`, ` rewrite_posix_all_shares "${name}" "$${namesVar}" "$${pathsVar}" "$PROJECT/docker-compose.yml"`);
|
|
827
|
+
}
|
|
755
828
|
if (dsmUserParam) {
|
|
756
829
|
const name = dsmUserParam.name;
|
|
757
830
|
const rawUserVar = "D54_RAW_USER_" + name;
|
|
@@ -770,6 +843,9 @@ function renderDynamicPreinstMaterializer(contract) {
|
|
|
770
843
|
for (const { name, pathVar } of shareVars) {
|
|
771
844
|
lines.push(` append_env "${name}" "$${pathVar}" "string"`);
|
|
772
845
|
}
|
|
846
|
+
for (const { name, namesVar, countVar } of posixVars) {
|
|
847
|
+
lines.push(` append_env "${name}" "$${namesVar}" "string"`, ` append_env "${name}_COUNT" "$${countVar}" "string"`);
|
|
848
|
+
}
|
|
773
849
|
if (dsmUserParam) {
|
|
774
850
|
const name = dsmUserParam.name;
|
|
775
851
|
const rawUserVar = "D54_RAW_USER_" + name;
|
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}`);
|
|
@@ -57,6 +57,7 @@ export declare function validateComposeNumericCommandArgs(compose: ComposeDocume
|
|
|
57
57
|
export interface HostBindContext {
|
|
58
58
|
sharedFolderParams: ReadonlySet<string>;
|
|
59
59
|
homesParams?: ReadonlySet<string>;
|
|
60
|
+
posixAllSharesParams?: ReadonlySet<string>;
|
|
60
61
|
installParamNames: ReadonlySet<string>;
|
|
61
62
|
}
|
|
62
63
|
export declare function collectVolumeSourceInterpolations(compose: ComposeDocument): Set<string>;
|
package/dist/compose/analyze.js
CHANGED
|
@@ -190,6 +190,7 @@ const HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES = new Set([
|
|
|
190
190
|
"boolean",
|
|
191
191
|
"shared_folder",
|
|
192
192
|
"dsm_user",
|
|
193
|
+
"posix_all_shares",
|
|
193
194
|
]);
|
|
194
195
|
/**
|
|
195
196
|
* Chooses the local route D0 can safely emit. The first dynamic slice is
|
|
@@ -252,10 +253,14 @@ function collectRouteRejectErrors(contract, parsed, composeFile) {
|
|
|
252
253
|
const installParams = collectInstallParameters(contract);
|
|
253
254
|
const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
|
|
254
255
|
const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
|
|
256
|
+
const posixAllSharesParams = new Set(installParams
|
|
257
|
+
.filter(({ param }) => param.type === "posix_all_shares")
|
|
258
|
+
.map(({ name }) => name));
|
|
255
259
|
const installParamNames = new Set(installParams.map(({ name }) => name));
|
|
256
260
|
errors.push(...validateComposeHostBinds(compose, composeFile, {
|
|
257
261
|
sharedFolderParams,
|
|
258
262
|
homesParams,
|
|
263
|
+
posixAllSharesParams,
|
|
259
264
|
installParamNames,
|
|
260
265
|
}));
|
|
261
266
|
return dedupeValidationErrors(errors);
|
|
@@ -296,7 +301,8 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
|
296
301
|
(param.type === "string" ||
|
|
297
302
|
param.type === "boolean" ||
|
|
298
303
|
param.type === "shared_folder" ||
|
|
299
|
-
param.type === "dsm_user"
|
|
304
|
+
param.type === "dsm_user" ||
|
|
305
|
+
param.type === "posix_all_shares"))
|
|
300
306
|
.map(({ name }) => name));
|
|
301
307
|
const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
|
|
302
308
|
const dsmUserDerivedNames = dsmUserParam
|
|
@@ -310,7 +316,8 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
|
|
|
310
316
|
(param.type !== "string" &&
|
|
311
317
|
param.type !== "boolean" &&
|
|
312
318
|
param.type !== "shared_folder" &&
|
|
313
|
-
param.type !== "dsm_user"
|
|
319
|
+
param.type !== "dsm_user" &&
|
|
320
|
+
param.type !== "posix_all_shares"));
|
|
314
321
|
const brokerOidcInterpolationNames = hasBrokerManagedOidc(contract)
|
|
315
322
|
? BROKER_OIDC_INTERPOLATION_VARS
|
|
316
323
|
: [];
|
|
@@ -726,7 +733,8 @@ function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile
|
|
|
726
733
|
}
|
|
727
734
|
if (PLATFORM_BIND_VARS.has(varName) ||
|
|
728
735
|
context.sharedFolderParams.has(varName) ||
|
|
729
|
-
context.homesParams?.has(varName)
|
|
736
|
+
context.homesParams?.has(varName) ||
|
|
737
|
+
context.posixAllSharesParams?.has(varName)) {
|
|
730
738
|
return null;
|
|
731
739
|
}
|
|
732
740
|
if (context.installParamNames.has(varName)) {
|
|
@@ -39,12 +39,12 @@ export function validatePosixAllSharesSources(compose, composeFile, declaredPara
|
|
|
39
39
|
const path = `services.${serviceName}.volumes.${index}`;
|
|
40
40
|
if (declaredParameters.has(name)) {
|
|
41
41
|
if (suffix !== "") {
|
|
42
|
-
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由
|
|
42
|
+
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由 package-only preinst 在安裝時展開。`));
|
|
43
43
|
}
|
|
44
44
|
continue;
|
|
45
45
|
}
|
|
46
46
|
if (RESERVED_MARKER_NAMES.has(name)) {
|
|
47
|
-
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓
|
|
47
|
+
errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓 wizard/CLI 取得安裝當下的 Share 清單。`));
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
}
|
|
@@ -30,7 +30,7 @@ function mapSchemaError(error, contractFile, parsed) {
|
|
|
30
30
|
}
|
|
31
31
|
export function validateContractSchema(parsed, contractFile) {
|
|
32
32
|
const validate = getValidator();
|
|
33
|
-
const data =
|
|
33
|
+
const data = parsed.doc.toJSON();
|
|
34
34
|
const valid = validate(data);
|
|
35
35
|
if (valid) {
|
|
36
36
|
return [];
|
|
@@ -48,28 +48,3 @@ export function validateContractSchema(parsed, contractFile) {
|
|
|
48
48
|
}
|
|
49
49
|
return result;
|
|
50
50
|
}
|
|
51
|
-
/**
|
|
52
|
-
* Keep the published v1 schema's historical enum stable for older tooling,
|
|
53
|
-
* while allowing the P4 contract extension to be validated by the same v1
|
|
54
|
-
* object shape. The semantic validator and Broker retain the raw type.
|
|
55
|
-
*/
|
|
56
|
-
function schemaCompatibleContract(value) {
|
|
57
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
58
|
-
return value;
|
|
59
|
-
}
|
|
60
|
-
const contract = value;
|
|
61
|
-
const install = contract.install;
|
|
62
|
-
if (!install || typeof install !== "object" || Array.isArray(install)) {
|
|
63
|
-
return value;
|
|
64
|
-
}
|
|
65
|
-
for (const parameter of Object.values(install)) {
|
|
66
|
-
if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) {
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
const record = parameter;
|
|
70
|
-
if (record.type === "posix_all_shares") {
|
|
71
|
-
record.type = "homes";
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return value;
|
|
75
|
-
}
|
|
@@ -51,16 +51,6 @@ export function validateContract(parsed, contractFile) {
|
|
|
51
51
|
errors.push(...validateInstallParameters(parsed, contractFile));
|
|
52
52
|
errors.push(...validatePosixAllShares(parsed, contractFile));
|
|
53
53
|
errors.push(...validateWebLaunch(parsed, contractFile));
|
|
54
|
-
// The P4 worker cannot edit the shared host-bind analyzer. Its existing
|
|
55
|
-
// platform-resolved `homes` slot has the same analyzer semantics (declared
|
|
56
|
-
// interpolation is an allowed source and must be used). This normalization
|
|
57
|
-
// is local to the validation object; Broker and SPK generation read the raw
|
|
58
|
-
// YAML again and retain the actual posix_all_shares type.
|
|
59
|
-
for (const parameter of Object.values(contract.install ?? {})) {
|
|
60
|
-
if (parameter && isPosixAllSharesType(parameter.type)) {
|
|
61
|
-
parameter.type = "homes";
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
54
|
return errors;
|
|
65
55
|
}
|
|
66
56
|
function validatePosixAllShares(parsed, contractFile) {
|
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,10 +9,12 @@ 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";
|
|
15
16
|
const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
|
|
17
|
+
const POSIX_ALL_SHARES_LOOKUP_FAILED = "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED";
|
|
16
18
|
function brokerCapabilitiesFromContract(project) {
|
|
17
19
|
const contract = loadContract(project.contractPath).contract;
|
|
18
20
|
const capabilities = ["identity"];
|
|
@@ -67,9 +69,34 @@ export async function deployProject(options) {
|
|
|
67
69
|
return { ok: false, code: "CURATED_DEPLOY_REFUSED", message: CURATED_DEPLOY_REFUSED_MESSAGE };
|
|
68
70
|
}
|
|
69
71
|
const contract = loadContract(options.project.contractPath).contract;
|
|
72
|
+
const paramValues = { ...(options.paramValues ?? {}) };
|
|
73
|
+
const posixAllSharesNames = Object.entries(contract.install ?? {})
|
|
74
|
+
.filter(([, parameter]) => parameter?.type === "posix_all_shares")
|
|
75
|
+
.map(([name]) => name);
|
|
76
|
+
for (const name of posixAllSharesNames) {
|
|
77
|
+
if (Object.prototype.hasOwnProperty.call(paramValues, name)) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let shareNames;
|
|
81
|
+
try {
|
|
82
|
+
shareNames = await options.host.listShareNames?.();
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
shareNames = undefined;
|
|
86
|
+
}
|
|
87
|
+
const names = (shareNames ?? []).map((value) => value.trim()).filter(Boolean);
|
|
88
|
+
if (names.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
code: POSIX_ALL_SHARES_LOOKUP_FAILED,
|
|
92
|
+
message: `無法用管理員 session 列出這台 NAS 的 Shared Folder,無法填入 POSIX all-shares 參數「${name}」。`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
paramValues[name] = names.join(",");
|
|
96
|
+
}
|
|
70
97
|
const paramResolved = resolveInstallParameterExtraValues({
|
|
71
98
|
contract,
|
|
72
|
-
provided:
|
|
99
|
+
provided: paramValues,
|
|
73
100
|
});
|
|
74
101
|
if (!paramResolved.ok) {
|
|
75
102
|
return { ok: false, code: paramResolved.code, message: paramResolved.message };
|
|
@@ -98,6 +125,24 @@ export async function deployProject(options) {
|
|
|
98
125
|
if (!runAsResolved.ok) {
|
|
99
126
|
return { ok: false, code: runAsResolved.code, message: runAsResolved.message };
|
|
100
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
|
+
}
|
|
101
146
|
const extraValues = {
|
|
102
147
|
...paramResolved.extraValues,
|
|
103
148
|
...homesResolved.extraValues,
|
|
@@ -247,5 +292,6 @@ export async function deployProject(options) {
|
|
|
247
292
|
spkPath: build.outputPath,
|
|
248
293
|
openUrl: info.openUrl ?? appOriginFromContract(options.host.dsmOrigin, contract),
|
|
249
294
|
catalogVersion,
|
|
295
|
+
notes: sharePermission.notes.length > 0 ? sharePermission.notes : undefined,
|
|
250
296
|
};
|
|
251
297
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { collectInstallParameters, stringViolations } from "../contract/load.js";
|
|
2
|
+
import { isPosixAllSharesType } from "../contract/posix-all-shares.js";
|
|
2
3
|
/**
|
|
3
4
|
* Validate the local, non-secret values accepted by `deploy --param`.
|
|
4
5
|
*
|
|
@@ -30,7 +31,7 @@ export function resolveInstallParameterExtraValues(input) {
|
|
|
30
31
|
extraValues[name] = raw;
|
|
31
32
|
continue;
|
|
32
33
|
}
|
|
33
|
-
if (param.type !== "string") {
|
|
34
|
+
if (param.type !== "string" && !isPosixAllSharesType(param.type)) {
|
|
34
35
|
return {
|
|
35
36
|
ok: false,
|
|
36
37
|
code: "DEPLOY_PARAM_TYPE_INVALID",
|
|
@@ -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
|
@@ -83,7 +83,7 @@ export const ERROR_REGISTRY = {
|
|
|
83
83
|
CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN: {
|
|
84
84
|
code: "CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN",
|
|
85
85
|
description: "POSIX all-shares 參數不得在契約中指定 default 或 NAS 實體路徑。",
|
|
86
|
-
suggestion: "請只宣告 type: posix_all_shares,讓
|
|
86
|
+
suggestion: "請只宣告 type: posix_all_shares,讓 wizard/CLI 在安裝時取得當下的 Share snapshot。",
|
|
87
87
|
},
|
|
88
88
|
DSM_USER_REQUIRED: {
|
|
89
89
|
code: "DSM_USER_REQUIRED",
|
|
@@ -149,12 +149,12 @@ export const ERROR_REGISTRY = {
|
|
|
149
149
|
COMPOSE_POSIX_ALL_SHARES_UNDECLARED: {
|
|
150
150
|
code: "COMPOSE_POSIX_ALL_SHARES_UNDECLARED",
|
|
151
151
|
description: "Compose 使用了未宣告的 POSIX all-shares marker。",
|
|
152
|
-
suggestion: "請宣告 type: posix_all_shares,讓
|
|
152
|
+
suggestion: "請宣告 type: posix_all_shares,讓 package-only wizard/CLI 取得安裝當下的 Share snapshot。",
|
|
153
153
|
},
|
|
154
154
|
COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN: {
|
|
155
155
|
code: "COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN",
|
|
156
156
|
description: "POSIX all-shares marker 不得附加 Share 子路徑。",
|
|
157
|
-
suggestion: "請只使用完整的 ${ALL_SHARES} source;每個 Share 的 container target 由
|
|
157
|
+
suggestion: "請只使用完整的 ${ALL_SHARES} source;每個 Share 的 container target 由 package-only preinst 展開。",
|
|
158
158
|
},
|
|
159
159
|
COMPOSE_HOST_BIND_FORBIDDEN: {
|
|
160
160
|
code: "COMPOSE_HOST_BIND_FORBIDDEN",
|
|
@@ -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,禁止隨意覆蓋部署。",
|
|
@@ -529,6 +534,11 @@ export const ERROR_REGISTRY = {
|
|
|
529
534
|
description: "deploy --param 的值不符合契約宣告的 string 或 boolean 型別。",
|
|
530
535
|
suggestion: "string 請傳非空字串;boolean 請只傳 true 或 false。",
|
|
531
536
|
},
|
|
537
|
+
DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED: {
|
|
538
|
+
code: "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED",
|
|
539
|
+
description: "無法以管理員 session 列出部署當下的 Shared Folder 清單。",
|
|
540
|
+
suggestion: "請確認 DSM 管理員 session 可呼叫 SYNO.Core.Share list,或用 --param NAME=... 明確提供清單。",
|
|
541
|
+
},
|
|
532
542
|
DSM_DEPENDENCY_CONFLICT: {
|
|
533
543
|
code: "DSM_DEPENDENCY_CONFLICT",
|
|
534
544
|
description: "DSM 複合安裝請求因執行中的依賴套件而拒絕。",
|
package/dist/host/dsm-client.js
CHANGED
|
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
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
|
-
import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
|
|
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";
|
|
@@ -231,6 +232,15 @@ function createDsmNasHost(credentials, profileName) {
|
|
|
231
232
|
async resolveSharePath(logicalName) {
|
|
232
233
|
return lookupSharePathWithSynoagentcli(logicalName, profileName);
|
|
233
234
|
},
|
|
235
|
+
async listShareNames() {
|
|
236
|
+
return listShareNamesWithSynoagentcli(profileName);
|
|
237
|
+
},
|
|
238
|
+
async listSharePermissions(logicalName) {
|
|
239
|
+
return fetchSharePermissions(logicalName, createSynoagentcliApiCall(profileName));
|
|
240
|
+
},
|
|
241
|
+
async listUserGroups(userName) {
|
|
242
|
+
return fetchUserGroups(userName, createSynoagentcliApiCall(profileName));
|
|
243
|
+
},
|
|
234
244
|
async resolveHomesPath() {
|
|
235
245
|
try {
|
|
236
246
|
const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
|
package/dist/host/fake-nas.d.ts
CHANGED
|
@@ -1,10 +1,26 @@
|
|
|
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;
|
|
5
9
|
homesPath?: string;
|
|
6
10
|
installationId?: string;
|
|
11
|
+
shareNames?: string[];
|
|
7
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[]>;
|
|
8
24
|
installedEnv?: Record<string, Record<string, string>>;
|
|
9
25
|
diagnostics?: {
|
|
10
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";
|
|
@@ -8,6 +18,11 @@ export function createFakeNasHost(options = {}) {
|
|
|
8
18
|
const installed = new Set();
|
|
9
19
|
const openUrls = new Map();
|
|
10
20
|
const shares = { ...(options.shares ?? {}) };
|
|
21
|
+
const shareNames = options.shareNames
|
|
22
|
+
? [...options.shareNames]
|
|
23
|
+
: Object.keys(shares).length > 0
|
|
24
|
+
? Object.keys(shares)
|
|
25
|
+
: ["docker", "home", "photo"];
|
|
11
26
|
const installedEnv = Object.fromEntries(Object.entries(options.installedEnv ?? {}).map(([packageId, env]) => [packageId, { ...env }]));
|
|
12
27
|
const host = {
|
|
13
28
|
dsmOrigin,
|
|
@@ -24,6 +39,30 @@ export function createFakeNasHost(options = {}) {
|
|
|
24
39
|
async resolveSharePath(logicalName) {
|
|
25
40
|
return shares[logicalName];
|
|
26
41
|
},
|
|
42
|
+
async listShareNames() {
|
|
43
|
+
return [...shareNames];
|
|
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
|
+
: {}),
|
|
27
66
|
async resolveHomesPath() {
|
|
28
67
|
return options.homesPath;
|
|
29
68
|
},
|
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function shareRealPathFromEntry(entry: Record<string, unknown>, logicalName: string): string | undefined;
|
|
2
2
|
export declare function findShareRealPath(payload: unknown, logicalName: string): string | undefined;
|
|
3
|
+
export declare function findShareNames(payload: unknown): string[];
|
|
3
4
|
export declare function lookupSharePathWithSynoagentcli(logicalName: string, profileName?: string): string | undefined;
|
|
5
|
+
export declare function listShareNamesWithSynoagentcli(profileName?: string): string[];
|
|
@@ -59,6 +59,16 @@ export function findShareRealPath(payload, logicalName) {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
+
export function findShareNames(payload) {
|
|
63
|
+
const names = new Set();
|
|
64
|
+
for (const record of collectRecords(payload)) {
|
|
65
|
+
const name = record.name ?? record.share_name;
|
|
66
|
+
if (typeof name === "string" && name.trim() !== "") {
|
|
67
|
+
names.add(name.trim());
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...names];
|
|
71
|
+
}
|
|
62
72
|
function synoagentcli(args, profileName) {
|
|
63
73
|
const fullArgs = [...args];
|
|
64
74
|
if (profileName) {
|
|
@@ -142,3 +152,10 @@ export function lookupSharePathWithSynoagentcli(logicalName, profileName) {
|
|
|
142
152
|
}
|
|
143
153
|
return lookupSharePathWithDsmHttp(logicalName, profileName);
|
|
144
154
|
}
|
|
155
|
+
export function listShareNamesWithSynoagentcli(profileName) {
|
|
156
|
+
const list = synoagentcli(["dsm", "control-panel", "shared-folder", "list"], profileName);
|
|
157
|
+
if (list.status !== 0) {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
return findShareNames(parseJson(list.stdout));
|
|
161
|
+
}
|
|
@@ -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";
|
|
@@ -76,7 +77,17 @@ export interface NasHost {
|
|
|
76
77
|
* caller refuses instead of guessing.
|
|
77
78
|
*/
|
|
78
79
|
getArchFamily?(): Promise<"x86_64" | "aarch64" | undefined>;
|
|
80
|
+
/** List the current logical Shared Folder names using an administrator session. */
|
|
81
|
+
listShareNames?(): Promise<string[]>;
|
|
79
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>;
|
|
80
91
|
/** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
|
|
81
92
|
resolveHomesPath?(): Promise<string | undefined>;
|
|
82
93
|
getInstalledProjectEnv?(packageIdentity: string): Promise<Record<string, string> | undefined>;
|
package/dist/validate.js
CHANGED
|
@@ -68,10 +68,14 @@ export function validateProject(project, options = {}) {
|
|
|
68
68
|
const installParams = collectInstallParameters(parsed.contract);
|
|
69
69
|
const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
|
|
70
70
|
const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
|
|
71
|
+
const posixAllSharesParams = new Set(installParams
|
|
72
|
+
.filter(({ param }) => param.type === "posix_all_shares")
|
|
73
|
+
.map(({ name }) => name));
|
|
71
74
|
const installParamNames = new Set(installParams.map(({ name }) => name));
|
|
72
75
|
errors.push(...validateComposeHostBinds(parsedCompose.compose, composeFile, {
|
|
73
76
|
sharedFolderParams,
|
|
74
77
|
homesParams,
|
|
78
|
+
posixAllSharesParams,
|
|
75
79
|
installParamNames,
|
|
76
80
|
}));
|
|
77
81
|
if (typeof parsed.contract.adminport === "number") {
|
|
@@ -105,13 +109,14 @@ export function validateProject(project, options = {}) {
|
|
|
105
109
|
? { ...item, severity: (profile === "listing" ? "error" : "warning") }
|
|
106
110
|
: item));
|
|
107
111
|
for (const { name, param } of installParams) {
|
|
108
|
-
const referenced = param.type === "shared_folder" || param.type === "homes"
|
|
112
|
+
const referenced = param.type === "shared_folder" || param.type === "homes" || param.type === "posix_all_shares"
|
|
109
113
|
? volumeSourceVars.has(name)
|
|
110
114
|
: interpolations.has(name);
|
|
111
115
|
if ((param.type === "string" ||
|
|
112
116
|
param.type === "boolean" ||
|
|
113
117
|
param.type === "shared_folder" ||
|
|
114
|
-
param.type === "homes"
|
|
118
|
+
param.type === "homes" ||
|
|
119
|
+
param.type === "posix_all_shares") &&
|
|
115
120
|
!referenced) {
|
|
116
121
|
errors.push({
|
|
117
122
|
code: "INSTALL_PARAMETER_UNREFERENCED",
|
package/package.json
CHANGED
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"properties": {
|
|
60
60
|
"type": {
|
|
61
61
|
"type": "string",
|
|
62
|
-
"enum": ["string", "boolean", "secret", "shared_folder", "dsm_user", "homes"]
|
|
62
|
+
"enum": ["string", "boolean", "secret", "shared_folder", "dsm_user", "homes", "posix_all_shares"]
|
|
63
63
|
},
|
|
64
64
|
"default": {},
|
|
65
65
|
"generate": {
|
|
@@ -38,6 +38,10 @@ ports:
|
|
|
38
38
|
|
|
39
39
|
包現成 Compose 時,先用 `inspect` 看原本的 volume mapping。容器路徑跟 Compose/映像的 upstream target 走,不要為了套平台範例改成 `/data`;只把 host source 換成 `${PARAM}`。原有的 upstream named volume 要保留在頂層 `volumes:`。需要既有 Share 時宣告 `install.<PARAM>.type: shared_folder`,管理員在安裝時選這台 NAS 的 Share。
|
|
40
40
|
|
|
41
|
+
### `posix_all_shares`
|
|
42
|
+
|
|
43
|
+
當 App 必須在安裝當下掛載這台 NAS 目前列出的每一個共用資料夾時,宣告 `install.<NAME>.type: posix_all_shares`,並把 `${NAME}` 作為完整 Compose volume source,例如 `${NAME}:/shares:ro`。Package Center wizard 會以 `SYNO.Core.Share.list` 的 multi-select 讓管理員再次確認;這是最大警告:容器可讀(或依 Compose 模式讀寫)所有列出的 Share,安裝後新建的共用資料夾不會自動加入,由管理員決定是否安裝。不要在契約或 Compose 寫 `/volume*`、domain、實體 path;`CLI deploy` 未給 `--param NAME=...` 時會用管理員 session 重新列出當下的 Share,preinst 只解析清單,不掃描檔案系統。
|
|
44
|
+
|
|
41
45
|
## 路線判準:零設定(Static Zero-Config)vs Dynamic vs Broker
|
|
42
46
|
|
|
43
47
|
Syno Package Land 在打包 Compose App 時,依契約與 Compose 內容自動分類為不同路線:
|