@synopackageland/templates 0.0.0-stage → 0.9.1

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.
Files changed (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/package.json +11 -4
  4. package/templates/_shared/AGENTS.md +149 -0
  5. package/templates/_shared/CLAUDE.md +1 -0
  6. package/templates/_shared/GO-LICENSE +27 -0
  7. package/templates/_shared/GO-PATENTS +22 -0
  8. package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
  9. package/templates/_shared/third-party-notices.mjs +55 -0
  10. package/templates/binary/LICENSES/GO-LICENSE +27 -0
  11. package/templates/binary/LICENSES/GO-PATENTS +22 -0
  12. package/templates/binary/dsm-package.yaml +46 -0
  13. package/templates/binary/example/main.go +16 -0
  14. package/templates/binary/gitignore +2 -0
  15. package/templates/binary/i18n/messages/de.json +4 -0
  16. package/templates/binary/i18n/messages/en.json +4 -0
  17. package/templates/binary/i18n/messages/es.json +4 -0
  18. package/templates/binary/i18n/messages/fr.json +4 -0
  19. package/templates/binary/i18n/messages/it.json +4 -0
  20. package/templates/binary/i18n/messages/ja.json +4 -0
  21. package/templates/binary/i18n/messages/ko.json +4 -0
  22. package/templates/binary/i18n/messages/zh-CN.json +4 -0
  23. package/templates/binary/i18n/messages/zh-TW.json +4 -0
  24. package/templates/binary/ui/app.css +4 -0
  25. package/templates/binary/ui/app.js +32 -0
  26. package/templates/compose/compose.yaml +8 -0
  27. package/templates/compose/synology-app.yaml +16 -0
  28. package/templates/native-go/README.md +372 -0
  29. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
  30. package/templates/native-go/backend/cmd/app/main.go +101 -0
  31. package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
  32. package/templates/native-go/backend/go.mod +9 -0
  33. package/templates/native-go/backend/go.sum +4 -0
  34. package/templates/native-go/backend/internal/api/permissions.go +26 -0
  35. package/templates/native-go/backend/internal/api/permissions.json +5 -0
  36. package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
  37. package/templates/native-go/backend/internal/app/background.go +249 -0
  38. package/templates/native-go/backend/internal/app/background_none.go +8 -0
  39. package/templates/native-go/backend/internal/app/background_owner.go +38 -0
  40. package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
  41. package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
  42. package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
  43. package/templates/native-go/backend/internal/app/background_test.go +320 -0
  44. package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
  45. package/templates/native-go/backend/internal/app/delegate_config_test.go +36 -0
  46. package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
  47. package/templates/native-go/backend/internal/app/identity.go +20 -0
  48. package/templates/native-go/backend/internal/app/identity_test.go +15 -0
  49. package/templates/native-go/backend/internal/app/migrations.go +41 -0
  50. package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
  51. package/templates/native-go/backend/internal/app/portal.go +30 -0
  52. package/templates/native-go/backend/internal/app/portal_test.go +55 -0
  53. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  54. package/templates/native-go/backend/internal/app/routes.go +137 -0
  55. package/templates/native-go/backend/internal/app/routes_test.go +207 -0
  56. package/templates/native-go/backend/internal/app/session_files.go +9 -0
  57. package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
  58. package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
  59. package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
  60. package/templates/native-go/dsm-package.yaml +71 -0
  61. package/templates/native-go/gitignore +1 -0
  62. package/templates/native-go/i18n/glossary.json +663 -0
  63. package/templates/native-go/i18n/messages/de.json +29 -0
  64. package/templates/native-go/i18n/messages/en.json +29 -0
  65. package/templates/native-go/i18n/messages/es.json +29 -0
  66. package/templates/native-go/i18n/messages/fr.json +29 -0
  67. package/templates/native-go/i18n/messages/it.json +29 -0
  68. package/templates/native-go/i18n/messages/ja.json +29 -0
  69. package/templates/native-go/i18n/messages/ko.json +29 -0
  70. package/templates/native-go/i18n/messages/zh-CN.json +29 -0
  71. package/templates/native-go/i18n/messages/zh-TW.json +29 -0
  72. package/templates/native-go/ui/app.css +45 -0
  73. package/templates/native-go/ui/app.js +111 -0
  74. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
  75. package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
  76. package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
  77. package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
  78. package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
  79. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
  80. package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
  81. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
  82. package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
  83. package/templates/native-go-root-ops/backend/internal/app/executor.go +128 -0
  84. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +237 -0
  85. package/templates/native-go-root-ops/root/whoami.sh +3 -0
  86. package/templates/native-go-vue/README.md +51 -0
  87. package/templates/native-go-vue/frontend/DESIGN.md +61 -0
  88. package/templates/native-go-vue/frontend/package-lock.json +2550 -0
  89. package/templates/native-go-vue/frontend/package.json +23 -0
  90. package/templates/native-go-vue/frontend/src/App.vue +90 -0
  91. package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
  92. package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
  93. package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
  94. package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
  95. package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
  96. package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
  97. package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
  98. package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
  99. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
  100. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
  101. package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
  102. package/templates/native-go-vue/frontend/src/main.ts +33 -0
  103. package/templates/native-go-vue/frontend/src/style.css +27 -0
  104. package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
  105. package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
  106. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
  107. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  108. package/templates/native-go-vue/frontend/vite.config.ts +11 -0
  109. package/templates/native-go-vue/gitignore +2 -0
@@ -0,0 +1,137 @@
1
+ // Package app holds this package's business operations. Add a route here and
2
+ // register its handler here. App permissions live in internal/api/permissions.json; SDK capability routes
3
+ // use the immutable SDK policy.
4
+ // unlisted methods require membership in DSM's administrators group.
5
+ //
6
+ //line __PACKAGE__/internal/app/routes.go:1
7
+ package app
8
+
9
+ import (
10
+ "bytes"
11
+ "context"
12
+ "encoding/json"
13
+ "errors"
14
+ "io/fs"
15
+ "os"
16
+ "path/filepath"
17
+ "runtime"
18
+ "time"
19
+ "unicode/utf8"
20
+
21
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
22
+ "github.com/synopackageland/syno-pkg-land-sdk/filestation"
23
+ )
24
+
25
+ // Config is fixed at build time and startup, never taken from a request.
26
+ type Config struct {
27
+ DelegateScope string
28
+ DelegateOperationsJSON, DelegateDestinationsJSON, DelegateLifetime string
29
+ ExecutorDigest, PolicyDigest string
30
+
31
+ // SessionFiles is the interactive File Station adapter; nil keeps legacy refusal.
32
+ SessionFiles *filestation.Service
33
+ PackageID string
34
+ Version string
35
+ // DataDir is the package's persistent, package-owned directory
36
+ // (/var/packages/<package>/var). It survives upgrades.
37
+ DataDir string
38
+ // VolumeRoot is where /volumeN lives; empty means "/". Tests set it.
39
+ VolumeRoot string
40
+ // Portal serves portal.status; PortalConfig overrides DSM's saved
41
+ // AppPortal.json path in tests.
42
+ Portal bool
43
+ PortalConfig string
44
+ }
45
+
46
+ func Routes(cfg Config) []api.Route {
47
+ routes := cfg.delegateRoutes()
48
+ routes = append(routes, []api.Route{
49
+ api.Handle("app.status", cfg.status),
50
+ api.Handle("greeting.get", cfg.getGreeting),
51
+ api.Handle("greeting.set", cfg.setGreeting),
52
+ }...)
53
+ routes = append(routes, cfg.SessionFiles.Routes()...)
54
+ routes = append(routes, cfg.backgroundRoutes()...)
55
+ routes = append(routes, cfg.portalRoutes()...)
56
+ return append(routes, cfg.rootRoutes()...)
57
+ }
58
+
59
+ func (cfg Config) status(_ context.Context, caller api.Caller, _ json.RawMessage) (any, error) {
60
+ return cfg.withSharedFolders(map[string]any{
61
+ "package": cfg.PackageID,
62
+ "version": cfg.Version,
63
+ "user": caller.User(),
64
+ "admin": caller.Admin(),
65
+ "process": map[string]int{"uid": os.Getuid(), "gid": os.Getgid()},
66
+ "go_version": runtime.Version(),
67
+ "platform": runtime.GOOS + "/" + runtime.GOARCH,
68
+ "time": time.Now().UTC().Format(time.RFC3339),
69
+ }), nil
70
+ }
71
+
72
+ type greeting struct {
73
+ Text string `json:"text"`
74
+ UpdatedBy string `json:"updated_by,omitempty"`
75
+ }
76
+
77
+ func (cfg Config) greetingPath() string { return filepath.Join(cfg.DataDir, "greeting.json") }
78
+
79
+ func (cfg Config) getGreeting(context.Context, api.Caller, json.RawMessage) (any, error) {
80
+ data, err := os.ReadFile(cfg.greetingPath())
81
+ if errors.Is(err, fs.ErrNotExist) {
82
+ return greeting{Text: "Hello"}, nil
83
+ }
84
+ if err != nil {
85
+ return nil, err
86
+ }
87
+ var g greeting
88
+ return g, json.Unmarshal(data, &g)
89
+ }
90
+
91
+ func (cfg Config) setGreeting(_ context.Context, caller api.Caller, params json.RawMessage) (any, error) {
92
+ var in struct {
93
+ Text string `json:"text"`
94
+ }
95
+ if err := decodeParams(params, &in); err != nil {
96
+ return nil, err
97
+ }
98
+ if n := utf8.RuneCountInString(in.Text); n < 1 || n > 200 {
99
+ return nil, api.BadRequest("text must be 1 to 200 characters")
100
+ }
101
+ g := greeting{Text: in.Text, UpdatedBy: caller.User()}
102
+ data, err := json.Marshal(g)
103
+ if err != nil {
104
+ return nil, err
105
+ }
106
+ return g, writeFileAtomic(cfg.greetingPath(), data)
107
+ }
108
+
109
+ // decodeParams decodes params strictly: unknown fields are an error.
110
+ func decodeParams(params json.RawMessage, into any) error {
111
+ if len(params) == 0 {
112
+ params = json.RawMessage(`{}`)
113
+ }
114
+ dec := json.NewDecoder(bytes.NewReader(params))
115
+ dec.DisallowUnknownFields()
116
+ if err := dec.Decode(into); err != nil {
117
+ return api.BadRequest("invalid params: " + err.Error())
118
+ }
119
+ return nil
120
+ }
121
+
122
+ // writeFileAtomic replaces path in one rename, so readers never see half a file.
123
+ func writeFileAtomic(path string, data []byte) error {
124
+ tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")
125
+ if err != nil {
126
+ return err
127
+ }
128
+ defer os.Remove(tmp.Name())
129
+ if _, err := tmp.Write(data); err != nil {
130
+ tmp.Close()
131
+ return err
132
+ }
133
+ if err := tmp.Close(); err != nil {
134
+ return err
135
+ }
136
+ return os.Rename(tmp.Name(), path)
137
+ }
@@ -0,0 +1,207 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "os"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
12
+
13
+ appapi "__PACKAGE__/internal/api"
14
+ )
15
+
16
+ type stubAuth struct {
17
+ identity api.Identity
18
+ err error
19
+ }
20
+
21
+ func (s stubAuth) Authenticate(*http.Request) (api.Identity, error) { return s.identity, s.err }
22
+
23
+ func testConfig(t *testing.T) Config {
24
+ return Config{PackageID: "HelloGo", Version: "0.1.0-0001", DataDir: t.TempDir()}
25
+ }
26
+
27
+ // rule is the access rule the server applies to r: the App policy for App
28
+ // methods, the SDK policy (reported by the route) for SDK methods.
29
+ func rule(t *testing.T, r api.Route) string {
30
+ t.Helper()
31
+ raw, err := appapi.Permissions()
32
+ if err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ var policy struct{ Methods map[string]string }
36
+ if err := json.Unmarshal(raw, &policy); err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ switch value, ok := policy.Methods[r.Method()]; {
40
+ case ok:
41
+ return value
42
+ case r.Public():
43
+ return "public"
44
+ case r.AdminOnly():
45
+ return api.AdministratorPermission
46
+ }
47
+ return "authenticated"
48
+ }
49
+
50
+ // call returns the envelope's error code ("" on success) and the envelope.
51
+ func call(t *testing.T, cfg Config, auth api.Authenticator, method, params string, permissionJSON ...[]byte) (string, map[string]any) {
52
+ t.Helper()
53
+ previous := lookupSubmitterUID
54
+ lookupSubmitterUID = func(string) string { return "1024" }
55
+ defer func() { lookupSubmitterUID = previous }()
56
+ permissions, err := appapi.Permissions()
57
+ if err != nil {
58
+ t.Fatal(err)
59
+ }
60
+ if len(permissionJSON) != 0 {
61
+ permissions = permissionJSON[0]
62
+ }
63
+ return serve(t, cfg, auth, apiRequest(method, params), permissions)
64
+ }
65
+
66
+ func apiRequest(method, params string) *http.Request {
67
+ body := `{"method":"` + method + `","params":` + params + `}`
68
+ req := httptest.NewRequest(http.MethodPost, "/api.cgi", strings.NewReader(body))
69
+ req.Header.Set("Content-Type", "application/json")
70
+ return req
71
+ }
72
+
73
+ // serve runs req through the App's server and returns as call does.
74
+ func serve(t *testing.T, cfg Config, auth api.Authenticator, req *http.Request, permissions []byte) (string, map[string]any) {
75
+ t.Helper()
76
+ srv, err := api.NewServerWithPermissions(auth, permissions, Routes(cfg)...)
77
+ if err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ rec := httptest.NewRecorder()
81
+ srv.ServeHTTP(rec, req)
82
+ var out map[string]any
83
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
84
+ t.Fatalf("not JSON: %s", rec.Body.String())
85
+ }
86
+ if rec.Code != http.StatusOK {
87
+ t.Fatalf("HTTP %d: DSM replaces non-200 CGI bodies", rec.Code)
88
+ }
89
+ code := ""
90
+ if e, ok := out["error"].(map[string]any); ok {
91
+ code, _ = e["code"].(string)
92
+ }
93
+ return code, out
94
+ }
95
+
96
+ var (
97
+ alice = stubAuth{identity: api.Identity{User: "alice", Admin: true}}
98
+ bob = stubAuth{identity: api.Identity{User: "bob"}}
99
+ nobody = stubAuth{err: api.ErrNoSession}
100
+ )
101
+
102
+ // sampleParams holds valid params for every route, so the denial test below
103
+ // proves the refusal came from the policy and not from bad input.
104
+ // Expected domain errors for routes an overlay adds (fresh store, no DSM cookie).
105
+ var sampleOutcome = map[string]string{}
106
+
107
+ var sampleParams = map[string]string{
108
+ "app.status": `{}`,
109
+ "greeting.get": `{}`,
110
+ "greeting.set": `{"text":"Hi"}`,
111
+ }
112
+
113
+ func TestEveryRouteHasSampleParamsAndNoneIsPublic(t *testing.T) {
114
+ routes := Routes(testConfig(t))
115
+ if len(routes) != len(sampleParams) {
116
+ t.Fatalf("%d routes but %d sample params: add the new route to sampleParams", len(routes), len(sampleParams))
117
+ }
118
+ for _, r := range routes {
119
+ if _, ok := sampleParams[r.Method()]; !ok {
120
+ t.Errorf("route %s has no sample params", r.Method())
121
+ }
122
+ if rule(t, r) == "public" {
123
+ t.Errorf("route %s is public; this template ships none", r.Method())
124
+ }
125
+ }
126
+ }
127
+
128
+ func TestEveryRouteRefusesCallersWithoutASessionAndChangesNothing(t *testing.T) {
129
+ for _, r := range Routes(testConfig(t)) {
130
+ cfg := testConfig(t)
131
+ code, _ := call(t, cfg, nobody, r.Method(), sampleParams[r.Method()])
132
+ if code != "session_required" {
133
+ t.Errorf("%s: %q, want session_required", r.Method(), code)
134
+ }
135
+ assertDataDirEmpty(t, cfg)
136
+ }
137
+ }
138
+
139
+ func TestAdministratorRoutesRefuseOrdinaryUsersAndChangeNothing(t *testing.T) {
140
+ for _, r := range Routes(testConfig(t)) {
141
+ if rule(t, r) != api.AdministratorPermission {
142
+ continue
143
+ }
144
+ cfg := testConfig(t)
145
+ code, _ := call(t, cfg, bob, r.Method(), sampleParams[r.Method()])
146
+ if code != "admin_required" {
147
+ t.Errorf("%s: %q, want admin_required", r.Method(), code)
148
+ }
149
+ assertDataDirEmpty(t, cfg)
150
+ }
151
+ }
152
+
153
+ func TestEveryRouteSucceedsForAnAdmin(t *testing.T) {
154
+ for _, r := range Routes(testConfig(t)) {
155
+ code, out := call(t, testConfig(t), alice, r.Method(), sampleParams[r.Method()])
156
+ want := sampleOutcome[r.Method()]
157
+ if strings.HasPrefix(r.Method(), "jobs.") {
158
+ want = "unavailable"
159
+ }
160
+ if code != want {
161
+ t.Errorf("%s: %q %v", r.Method(), code, out)
162
+ }
163
+ }
164
+ }
165
+
166
+ func TestStatusReportsPackageAndCaller(t *testing.T) {
167
+ code, out := call(t, testConfig(t), alice, "app.status", `{}`)
168
+ data := out["data"].(map[string]any)
169
+ if code != "" || data["package"] != "HelloGo" || data["version"] != "0.1.0-0001" || data["user"] != "alice" || data["admin"] != true {
170
+ t.Fatalf("got %q %v", code, out)
171
+ }
172
+ }
173
+
174
+ func TestGreetingDefaultsAndAdminCanChangeIt(t *testing.T) {
175
+ cfg := testConfig(t)
176
+ _, out := call(t, cfg, alice, "greeting.get", `{}`)
177
+ if got := out["data"].(map[string]any)["text"]; got != "Hello" {
178
+ t.Fatalf("default greeting = %v", got)
179
+ }
180
+ if code, out := call(t, cfg, alice, "greeting.set", `{"text":"Hi from DSM"}`); code != "" {
181
+ t.Fatalf("set: %q %v", code, out)
182
+ }
183
+ _, out = call(t, cfg, alice, "greeting.get", `{}`)
184
+ data := out["data"].(map[string]any)
185
+ if data["text"] != "Hi from DSM" || data["updated_by"] != "alice" {
186
+ t.Fatalf("after set: %v", data)
187
+ }
188
+ }
189
+
190
+ func TestGreetingSetValidatesText(t *testing.T) {
191
+ for _, params := range []string{`{}`, `{"text":""}`, `{"text":"` + strings.Repeat("x", 201) + `"}`, `{"text":7}`, `{"text":"ok","extra":1}`} {
192
+ cfg := testConfig(t)
193
+ code, _ := call(t, cfg, alice, "greeting.set", params)
194
+ if code != "bad_request" {
195
+ t.Errorf("%s: %q, want bad_request", params, code)
196
+ }
197
+ assertDataDirEmpty(t, cfg)
198
+ }
199
+ }
200
+
201
+ func assertDataDirEmpty(t *testing.T, cfg Config) {
202
+ t.Helper()
203
+ entries, err := os.ReadDir(cfg.DataDir)
204
+ if err != nil || len(entries) != 0 {
205
+ t.Fatalf("data dir changed: %v %v", entries, err)
206
+ }
207
+ }
@@ -0,0 +1,9 @@
1
+ package app
2
+
3
+ // FileStationSession selects the interactive File Station adapter using the caller’s DSM session.
4
+ const FileStationSession = "session"
5
+
6
+ // SessionFilesEnabled accepts only the fixed build-time session setting.
7
+ func SessionFilesEnabled(fileStation string) bool {
8
+ return fileStation == FileStationSession
9
+ }
@@ -0,0 +1,14 @@
1
+ package app
2
+
3
+ import "testing"
4
+
5
+ func TestSessionFilesEnabledByTrustedBuildSetting(t *testing.T) {
6
+ for _, tc := range []struct {
7
+ value string
8
+ want bool
9
+ }{{"", false}, {"session", true}, {"yes", false}} {
10
+ if got := SessionFilesEnabled(tc.value); got != tc.want {
11
+ t.Errorf("%q: got %v, want %v", tc.value, got, tc.want)
12
+ }
13
+ }
14
+ }
@@ -0,0 +1,144 @@
1
+ package app
2
+
3
+ import (
4
+ "encoding/json"
5
+ "errors"
6
+ "fmt"
7
+ "io/fs"
8
+ "os"
9
+ "path/filepath"
10
+ "sort"
11
+ "strings"
12
+ "syscall"
13
+ )
14
+
15
+ // The install wizard stores the chosen Shared Folder's *name* in
16
+ // <DataDir>/config.json (see dsm-package.yaml install.sharedFolder). The path
17
+ // is resolved on every call, so a Shared Folder moved to another volume keeps
18
+ // working. config.json is written by the package's postinst/postupgrade, not
19
+ // by the app.
20
+
21
+ var (
22
+ ErrSharedFolderNotFound = errors.New("shared folder not found on any volume")
23
+ ErrSharedFolderAmbiguous = errors.New("shared folder name matches more than one volume")
24
+ )
25
+
26
+ // SharedFolder says where one configured Shared Folder is right now and what
27
+ // the package account may do there.
28
+ type SharedFolder struct {
29
+ Key string `json:"key"`
30
+ Name string `json:"name"`
31
+ Path string `json:"path,omitempty"`
32
+ Exists bool `json:"exists"`
33
+ Readable bool `json:"readable"`
34
+ Writable bool `json:"writable"`
35
+ Error string `json:"error,omitempty"`
36
+ }
37
+
38
+ func validShareName(name string) bool {
39
+ return name != "" && !strings.ContainsAny(name, "/\\\x00") && !strings.Contains(name, "..") &&
40
+ !strings.HasPrefix(name, "@") && !strings.HasPrefix(name, ".")
41
+ }
42
+
43
+ // ReadSharedFolders returns the Shared Folder names the installer stored, by
44
+ // key. A package without config.json has none.
45
+ func ReadSharedFolders(dataDir string) (map[string]string, error) {
46
+ data, err := os.ReadFile(filepath.Join(dataDir, "config.json"))
47
+ if errors.Is(err, fs.ErrNotExist) {
48
+ return nil, nil
49
+ }
50
+ if err != nil {
51
+ return nil, err
52
+ }
53
+ var cfg struct {
54
+ Schema int `json:"schema"`
55
+ SharedFolders map[string]string `json:"shared_folders"`
56
+ }
57
+ if err := json.Unmarshal(data, &cfg); err != nil {
58
+ return nil, fmt.Errorf("config.json: %w", err)
59
+ }
60
+ if cfg.Schema != 1 {
61
+ return nil, fmt.Errorf("config.json: unsupported schema %d", cfg.Schema)
62
+ }
63
+ for key, name := range cfg.SharedFolders {
64
+ if !validShareName(name) {
65
+ return nil, fmt.Errorf("config.json: invalid shared folder name for %q", key)
66
+ }
67
+ }
68
+ return cfg.SharedFolders, nil
69
+ }
70
+
71
+ // ResolveSharedFolder finds /volumeN/<name> under root ("/" on a NAS).
72
+ func ResolveSharedFolder(root, name string) (string, error) {
73
+ if !validShareName(name) {
74
+ return "", fmt.Errorf("invalid shared folder name %q", name)
75
+ }
76
+ volumes, err := filepath.Glob(filepath.Join(root, "volume[0-9]*"))
77
+ if err != nil {
78
+ return "", err
79
+ }
80
+ var found []string
81
+ for _, volume := range volumes {
82
+ path := filepath.Join(volume, name)
83
+ if info, err := os.Stat(path); err == nil && info.IsDir() {
84
+ found = append(found, path)
85
+ }
86
+ }
87
+ switch len(found) {
88
+ case 0:
89
+ return "", ErrSharedFolderNotFound
90
+ case 1:
91
+ return found[0], nil
92
+ }
93
+ return "", ErrSharedFolderAmbiguous
94
+ }
95
+
96
+ const (
97
+ accessRead = 0x4 // R_OK
98
+ accessWrite = 0x2 // W_OK
99
+ accessExec = 0x1 // X_OK
100
+ )
101
+
102
+ // SharedFolders reports every configured Shared Folder, sorted by key. The
103
+ // error is a corrupt config.json.
104
+ func (cfg Config) SharedFolders() ([]SharedFolder, error) {
105
+ names, err := ReadSharedFolders(cfg.DataDir)
106
+ if err != nil {
107
+ return nil, err
108
+ }
109
+ root := cfg.VolumeRoot
110
+ if root == "" {
111
+ root = "/"
112
+ }
113
+ keys := make([]string, 0, len(names))
114
+ for key := range names {
115
+ keys = append(keys, key)
116
+ }
117
+ sort.Strings(keys)
118
+ folders := make([]SharedFolder, 0, len(keys))
119
+ for _, key := range keys {
120
+ folder := SharedFolder{Key: key, Name: names[key]}
121
+ path, err := ResolveSharedFolder(root, folder.Name)
122
+ if err != nil {
123
+ folder.Error = err.Error()
124
+ } else {
125
+ folder.Path, folder.Exists = path, true
126
+ folder.Readable = syscall.Access(path, accessRead|accessExec) == nil
127
+ folder.Writable = syscall.Access(path, accessWrite|accessExec) == nil
128
+ }
129
+ folders = append(folders, folder)
130
+ }
131
+ return folders, nil
132
+ }
133
+
134
+ // withSharedFolders adds the configured Shared Folders to an app.status
135
+ // answer; a package that configures none is answered exactly as before.
136
+ func (cfg Config) withSharedFolders(status map[string]any) map[string]any {
137
+ folders, err := cfg.SharedFolders()
138
+ if err != nil {
139
+ status["shared_folders_error"] = err.Error()
140
+ } else if len(folders) > 0 {
141
+ status["shared_folders"] = folders
142
+ }
143
+ return status
144
+ }
@@ -0,0 +1,158 @@
1
+ package app
2
+
3
+ import (
4
+ "errors"
5
+ "os"
6
+ "path/filepath"
7
+ "testing"
8
+ )
9
+
10
+ func writeConfig(t *testing.T, dir, body string) {
11
+ t.Helper()
12
+ if err := os.WriteFile(filepath.Join(dir, "config.json"), []byte(body), 0o600); err != nil {
13
+ t.Fatal(err)
14
+ }
15
+ }
16
+
17
+ func TestReadSharedFoldersWithoutConfigIsEmpty(t *testing.T) {
18
+ got, err := ReadSharedFolders(t.TempDir())
19
+ if err != nil || len(got) != 0 {
20
+ t.Fatalf("got %v %v", got, err)
21
+ }
22
+ }
23
+
24
+ func TestReadSharedFoldersReturnsNamesByKey(t *testing.T) {
25
+ dir := t.TempDir()
26
+ writeConfig(t, dir, `{"schema":1,"shared_folders":{"media":"Family Photos"}}`)
27
+ got, err := ReadSharedFolders(dir)
28
+ if err != nil || got["media"] != "Family Photos" || len(got) != 1 {
29
+ t.Fatalf("got %v %v", got, err)
30
+ }
31
+ }
32
+
33
+ func TestReadSharedFoldersRefusesUnknownSchemaAndBadNames(t *testing.T) {
34
+ for _, body := range []string{
35
+ `{"schema":2,"shared_folders":{"media":"x"}}`,
36
+ `{"schema":1,"shared_folders":{"media":"a/b"}}`,
37
+ `{"schema":1,"shared_folders":{"media":".."}}`,
38
+ `{"schema":1,"shared_folders":{"media":"@appdata"}}`,
39
+ `{"schema":1,"shared_folders":{"media":""}}`,
40
+ `not json`,
41
+ } {
42
+ dir := t.TempDir()
43
+ writeConfig(t, dir, body)
44
+ if _, err := ReadSharedFolders(dir); err == nil {
45
+ t.Errorf("%s: want an error", body)
46
+ }
47
+ }
48
+ }
49
+
50
+ func TestResolveSharedFolderFindsTheNameOnItsVolume(t *testing.T) {
51
+ root := t.TempDir()
52
+ for _, d := range []string{"volume1/other", "volume2/Photos", "volume2/@appdata"} {
53
+ if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
54
+ t.Fatal(err)
55
+ }
56
+ }
57
+ got, err := ResolveSharedFolder(root, "Photos")
58
+ if err != nil || got != filepath.Join(root, "volume2", "Photos") {
59
+ t.Fatalf("got %q %v", got, err)
60
+ }
61
+ }
62
+
63
+ func TestResolveSharedFolderFollowsAMovedShare(t *testing.T) {
64
+ root := t.TempDir()
65
+ os.MkdirAll(filepath.Join(root, "volume1", "Photos"), 0o755)
66
+ first, _ := ResolveSharedFolder(root, "Photos")
67
+ os.Rename(filepath.Join(root, "volume1", "Photos"), filepath.Join(root, "Photos.tmp"))
68
+ os.MkdirAll(filepath.Join(root, "volume2"), 0o755)
69
+ os.Rename(filepath.Join(root, "Photos.tmp"), filepath.Join(root, "volume2", "Photos"))
70
+ second, err := ResolveSharedFolder(root, "Photos")
71
+ if err != nil || first == second || second != filepath.Join(root, "volume2", "Photos") {
72
+ t.Fatalf("%q -> %q %v", first, second, err)
73
+ }
74
+ }
75
+
76
+ func TestResolveSharedFolderErrors(t *testing.T) {
77
+ root := t.TempDir()
78
+ if _, err := ResolveSharedFolder(root, "Missing"); !errors.Is(err, ErrSharedFolderNotFound) {
79
+ t.Fatalf("missing: %v", err)
80
+ }
81
+ os.MkdirAll(filepath.Join(root, "volume1", "Dup"), 0o755)
82
+ os.MkdirAll(filepath.Join(root, "volume2", "Dup"), 0o755)
83
+ if _, err := ResolveSharedFolder(root, "Dup"); !errors.Is(err, ErrSharedFolderAmbiguous) {
84
+ t.Fatalf("ambiguous: %v", err)
85
+ }
86
+ for _, bad := range []string{"", "..", "a/b", "@appdata", ".x"} {
87
+ if _, err := ResolveSharedFolder(root, bad); err == nil {
88
+ t.Errorf("%q: want an error", bad)
89
+ }
90
+ }
91
+ }
92
+
93
+ func TestResolveSharedFolderIgnoresAFileOfThatName(t *testing.T) {
94
+ root := t.TempDir()
95
+ os.MkdirAll(filepath.Join(root, "volume1"), 0o755)
96
+ os.WriteFile(filepath.Join(root, "volume1", "Notes"), nil, 0o644)
97
+ if _, err := ResolveSharedFolder(root, "Notes"); !errors.Is(err, ErrSharedFolderNotFound) {
98
+ t.Fatalf("got %v", err)
99
+ }
100
+ }
101
+
102
+ func TestStatusOmitsSharedFoldersWhenNoneConfigured(t *testing.T) {
103
+ _, out := call(t, testConfig(t), alice, "app.status", `{}`)
104
+ if _, present := out["data"].(map[string]any)["shared_folders"]; present {
105
+ t.Fatalf("unexpected shared_folders: %v", out)
106
+ }
107
+ }
108
+
109
+ func TestStatusReportsEachConfiguredSharedFolder(t *testing.T) {
110
+ cfg := testConfig(t)
111
+ cfg.VolumeRoot = t.TempDir()
112
+ os.MkdirAll(filepath.Join(cfg.VolumeRoot, "volume1", "Photos"), 0o755)
113
+ writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Photos","gone":"Missing"}}`)
114
+ _, out := call(t, cfg, alice, "app.status", `{}`)
115
+ rows := out["data"].(map[string]any)["shared_folders"].([]any)
116
+ if len(rows) != 2 {
117
+ t.Fatalf("rows: %v", rows)
118
+ }
119
+ byKey := map[string]map[string]any{}
120
+ for _, r := range rows {
121
+ m := r.(map[string]any)
122
+ byKey[m["key"].(string)] = m
123
+ }
124
+ media, gone := byKey["media"], byKey["gone"]
125
+ if media["name"] != "Photos" || media["exists"] != true || media["readable"] != true || media["writable"] != true || media["path"] != filepath.Join(cfg.VolumeRoot, "volume1", "Photos") {
126
+ t.Fatalf("media: %v", media)
127
+ }
128
+ if gone["exists"] != false || gone["readable"] != false || gone["writable"] != false || gone["error"] == nil || gone["path"] != nil {
129
+ t.Fatalf("gone: %v", gone)
130
+ }
131
+ }
132
+
133
+ func TestStatusReportsAFolderThePackageAccountCannotWrite(t *testing.T) {
134
+ if os.Geteuid() == 0 {
135
+ t.Skip("root ignores directory modes")
136
+ }
137
+ cfg := testConfig(t)
138
+ cfg.VolumeRoot = t.TempDir()
139
+ dir := filepath.Join(cfg.VolumeRoot, "volume1", "Locked")
140
+ os.MkdirAll(dir, 0o755)
141
+ os.Chmod(dir, 0o555)
142
+ t.Cleanup(func() { os.Chmod(dir, 0o755) })
143
+ writeConfig(t, cfg.DataDir, `{"schema":1,"shared_folders":{"media":"Locked"}}`)
144
+ _, out := call(t, cfg, alice, "app.status", `{}`)
145
+ row := out["data"].(map[string]any)["shared_folders"].([]any)[0].(map[string]any)
146
+ if row["exists"] != true || row["readable"] != true || row["writable"] != false {
147
+ t.Fatalf("row: %v", row)
148
+ }
149
+ }
150
+
151
+ func TestStatusReportsACorruptConfigWithoutFailing(t *testing.T) {
152
+ cfg := testConfig(t)
153
+ writeConfig(t, cfg.DataDir, `not json`)
154
+ code, out := call(t, cfg, alice, "app.status", `{}`)
155
+ if code != "" || out["data"].(map[string]any)["shared_folders_error"] == nil {
156
+ t.Fatalf("%q %v", code, out)
157
+ }
158
+ }