@synopackageland/templates 0.9.5 → 0.9.6

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 (74) hide show
  1. package/README.md +27 -4
  2. package/package.json +2 -2
  3. package/templates/_shared/AGENTS.md +95 -18
  4. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +1 -1
  5. package/templates/native-go/backend/cmd/app/main.go +8 -1
  6. package/templates/native-go/backend/cmd/background-worker/main.go +5 -1
  7. package/templates/native-go/backend/go.mod +1 -1
  8. package/templates/native-go/backend/go.sum +2 -2
  9. package/templates/native-go/backend/internal/app/background.go +19 -8
  10. package/templates/native-go/backend/internal/app/background_owner_test.go +2 -2
  11. package/templates/native-go/backend/internal/app/background_test.go +24 -0
  12. package/templates/native-go/backend/internal/app/background_work.go +94 -0
  13. package/templates/native-go/backend/internal/app/background_work_test.go +95 -0
  14. package/templates/native-go/backend/internal/app/uninstall.go +19 -0
  15. package/templates/native-go/backend/internal/app/uninstall_test.go +33 -0
  16. package/templates/native-go/dsm-package.yaml +18 -0
  17. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +1 -1
  18. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +4 -0
  19. package/templates/native-go-jobs/frontend/test/background-page.test.ts +21 -0
  20. package/templates/native-go-vue/frontend/package-lock.json +4 -4
  21. package/templates/native-go-vue/frontend/package.json +1 -1
  22. package/templates/native-rust/README.md +381 -0
  23. package/templates/native-rust/backend/Cargo.lock +125 -0
  24. package/templates/native-rust/backend/Cargo.toml +24 -0
  25. package/templates/native-rust/backend/THIRD_PARTY_NOTICES.txt +207 -0
  26. package/templates/native-rust/backend/permissions.json +5 -0
  27. package/templates/native-rust/backend/rust-toolchain.toml +4 -0
  28. package/templates/native-rust/backend/src/app/background/mod.rs +424 -0
  29. package/templates/native-rust/backend/src/app/background/owner.rs +77 -0
  30. package/templates/native-rust/backend/src/app/background/owner_tests.rs +174 -0
  31. package/templates/native-rust/backend/src/app/background/schedule.rs +126 -0
  32. package/templates/native-rust/backend/src/app/background/schedule_tests.rs +108 -0
  33. package/templates/native-rust/backend/src/app/background/tests.rs +505 -0
  34. package/templates/native-rust/backend/src/app/background/work.rs +78 -0
  35. package/templates/native-rust/backend/src/app/background/work_tests.rs +123 -0
  36. package/templates/native-rust/backend/src/app/background_routes.rs +22 -0
  37. package/templates/native-rust/backend/src/app/delegate_config.rs +188 -0
  38. package/templates/native-rust/backend/src/app/delegate_routes.rs +22 -0
  39. package/templates/native-rust/backend/src/app/identity.rs +56 -0
  40. package/templates/native-rust/backend/src/app/migrations.rs +79 -0
  41. package/templates/native-rust/backend/src/app/mod.rs +30 -0
  42. package/templates/native-rust/backend/src/app/portal.rs +317 -0
  43. package/templates/native-rust/backend/src/app/root_routes.rs +23 -0
  44. package/templates/native-rust/backend/src/app/routes.rs +315 -0
  45. package/templates/native-rust/backend/src/app/session_files.rs +19 -0
  46. package/templates/native-rust/backend/src/app/shared_folder.rs +383 -0
  47. package/templates/native-rust/backend/src/app/test_support.rs +200 -0
  48. package/templates/native-rust/backend/src/app/uninstall.rs +38 -0
  49. package/templates/native-rust/backend/src/bin/api.rs +72 -0
  50. package/templates/native-rust/backend/src/bin/background-worker.rs +37 -0
  51. package/templates/native-rust/backend/src/build_env.rs +155 -0
  52. package/templates/native-rust/backend/src/lib.rs +6 -0
  53. package/templates/native-rust/backend/src/permissions.rs +54 -0
  54. package/templates/native-rust/backend/src/process.rs +91 -0
  55. package/templates/native-rust/dsm-package.yaml +89 -0
  56. package/templates/native-rust/gitignore +2 -0
  57. package/templates/native-rust/i18n/glossary.json +663 -0
  58. package/templates/native-rust/i18n/messages/de.json +29 -0
  59. package/templates/native-rust/i18n/messages/en.json +29 -0
  60. package/templates/native-rust/i18n/messages/es.json +29 -0
  61. package/templates/native-rust/i18n/messages/fr.json +29 -0
  62. package/templates/native-rust/i18n/messages/it.json +29 -0
  63. package/templates/native-rust/i18n/messages/ja.json +29 -0
  64. package/templates/native-rust/i18n/messages/ko.json +29 -0
  65. package/templates/native-rust/i18n/messages/zh-CN.json +29 -0
  66. package/templates/native-rust/i18n/messages/zh-TW.json +29 -0
  67. package/templates/native-rust/ui/app.css +45 -0
  68. package/templates/native-rust/ui/app.js +111 -0
  69. package/templates/native-rust-delegate/backend/src/app/delegate_routes.rs +92 -0
  70. package/templates/native-rust-delegate/backend/src/bin/background-worker.rs +52 -0
  71. package/templates/native-rust-jobs/backend/src/app/background_routes.rs +109 -0
  72. package/templates/native-rust-root-ops/backend/src/app/root_routes.rs +475 -0
  73. package/templates/native-rust-vue/README.md +55 -0
  74. package/templates/native-rust-vue/gitignore +3 -0
@@ -0,0 +1,383 @@
1
+ use std::collections::BTreeMap;
2
+ use std::ffi::CString;
3
+ use std::fmt;
4
+ use std::os::unix::ffi::OsStrExt;
5
+ use std::path::{Path, PathBuf};
6
+
7
+ use serde::{Deserialize, Serialize};
8
+ use serde_json::Value;
9
+ use syno_pkg_land_sdk::{BoxError, jsonx};
10
+
11
+ use super::Config;
12
+
13
+ // The install wizard stores the chosen Shared Folder's *name* in
14
+ // <data_dir>/config.json (see dsm-package.yaml install.sharedFolder). The path
15
+ // is resolved on every call, so a Shared Folder moved to another volume keeps
16
+ // working. config.json is written by the package's postinst/postupgrade, not
17
+ // by the app.
18
+
19
+ #[derive(Debug, PartialEq, Eq)]
20
+ pub enum SharedFolderError {
21
+ InvalidName(String),
22
+ NotFound,
23
+ Ambiguous,
24
+ }
25
+
26
+ impl fmt::Display for SharedFolderError {
27
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28
+ match self {
29
+ SharedFolderError::InvalidName(name) => {
30
+ write!(f, "invalid shared folder name {name:?}")
31
+ }
32
+ SharedFolderError::NotFound => f.write_str("shared folder not found on any volume"),
33
+ SharedFolderError::Ambiguous => {
34
+ f.write_str("shared folder name matches more than one volume")
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ impl std::error::Error for SharedFolderError {}
41
+
42
+ /// Says where one configured Shared Folder is right now and what the package
43
+ /// account may do there.
44
+ #[derive(Debug, Default, Serialize)]
45
+ pub struct SharedFolder {
46
+ pub key: String,
47
+ pub name: String,
48
+ #[serde(skip_serializing_if = "String::is_empty")]
49
+ pub path: String,
50
+ pub exists: bool,
51
+ pub readable: bool,
52
+ pub writable: bool,
53
+ #[serde(skip_serializing_if = "String::is_empty")]
54
+ pub error: String,
55
+ }
56
+
57
+ fn valid_share_name(name: &str) -> bool {
58
+ !name.is_empty()
59
+ && !name.contains(['/', '\\', '\0'])
60
+ && !name.contains("..")
61
+ && !name.starts_with(['@', '.'])
62
+ }
63
+
64
+ #[derive(Default, Deserialize)]
65
+ struct SharedFoldersFile {
66
+ #[serde(default, deserialize_with = "jsonx::null_default")]
67
+ schema: i64,
68
+ #[serde(default, deserialize_with = "jsonx::null_default")]
69
+ shared_folders: BTreeMap<String, String>,
70
+ }
71
+
72
+ /// Returns the Shared Folder names the installer stored, by key. A package
73
+ /// without config.json has none.
74
+ pub fn read_shared_folders(data_dir: &Path) -> Result<BTreeMap<String, String>, BoxError> {
75
+ let data = match std::fs::read(data_dir.join("config.json")) {
76
+ Ok(data) => data,
77
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
78
+ Err(e) => return Err(e.into()),
79
+ };
80
+ let file: SharedFoldersFile =
81
+ serde_json::from_slice(&data).map_err(|e| format!("config.json: {e}"))?;
82
+ if file.schema != 1 {
83
+ return Err(format!("config.json: unsupported schema {}", file.schema).into());
84
+ }
85
+ if let Some((key, _)) = file
86
+ .shared_folders
87
+ .iter()
88
+ .find(|(_, name)| !valid_share_name(name))
89
+ {
90
+ return Err(format!("config.json: invalid shared folder name for {key:?}").into());
91
+ }
92
+ Ok(file.shared_folders)
93
+ }
94
+
95
+ /// Whether name is a volume directory: "volume", a digit, then anything.
96
+ fn is_volume(name: &std::ffi::OsStr) -> bool {
97
+ name.as_bytes()
98
+ .strip_prefix(b"volume")
99
+ .and_then(<[u8]>::first)
100
+ .is_some_and(u8::is_ascii_digit)
101
+ }
102
+
103
+ /// Finds /volumeN/<name> under root ("/" on a NAS).
104
+ pub fn resolve_shared_folder(root: &Path, name: &str) -> Result<PathBuf, SharedFolderError> {
105
+ if !valid_share_name(name) {
106
+ return Err(SharedFolderError::InvalidName(name.to_string()));
107
+ }
108
+ let volumes = std::fs::read_dir(root)
109
+ .map(|entries| entries.flatten().collect::<Vec<_>>())
110
+ .unwrap_or_default();
111
+ let mut found: Vec<PathBuf> = volumes
112
+ .iter()
113
+ .filter(|volume| is_volume(&volume.file_name()))
114
+ .map(|volume| volume.path().join(name))
115
+ .filter(|path| path.is_dir())
116
+ .collect();
117
+ match found.len() {
118
+ 0 => Err(SharedFolderError::NotFound),
119
+ 1 => Ok(found.remove(0)),
120
+ _ => Err(SharedFolderError::Ambiguous),
121
+ }
122
+ }
123
+
124
+ const ACCESS_READ: libc::c_int = libc::R_OK;
125
+ const ACCESS_WRITE: libc::c_int = libc::W_OK;
126
+ const ACCESS_EXEC: libc::c_int = libc::X_OK;
127
+
128
+ /// Whether the package account has every access in mode to path.
129
+ fn accessible(path: &Path, mode: libc::c_int) -> bool {
130
+ let Ok(path) = CString::new(path.as_os_str().as_bytes()) else {
131
+ return false;
132
+ };
133
+ // SAFETY: path is a valid NUL-terminated string that outlives the call.
134
+ unsafe { libc::access(path.as_ptr(), mode) == 0 }
135
+ }
136
+
137
+ impl Config {
138
+ /// Reports every configured Shared Folder, sorted by key. The error is a
139
+ /// corrupt config.json.
140
+ pub fn shared_folders(&self) -> Result<Vec<SharedFolder>, BoxError> {
141
+ let names = read_shared_folders(&self.data_dir)?;
142
+ let root = self.volume_root.as_deref().unwrap_or(Path::new("/"));
143
+ // A BTreeMap iterates in key order.
144
+ Ok(names
145
+ .into_iter()
146
+ .map(|(key, name)| {
147
+ let mut folder = SharedFolder {
148
+ key,
149
+ name,
150
+ ..SharedFolder::default()
151
+ };
152
+ match resolve_shared_folder(root, &folder.name) {
153
+ Err(e) => folder.error = e.to_string(),
154
+ Ok(path) => {
155
+ folder.readable = accessible(&path, ACCESS_READ | ACCESS_EXEC);
156
+ folder.writable = accessible(&path, ACCESS_WRITE | ACCESS_EXEC);
157
+ folder.path = path.to_string_lossy().into_owned();
158
+ folder.exists = true;
159
+ }
160
+ }
161
+ folder
162
+ })
163
+ .collect())
164
+ }
165
+
166
+ /// Adds the configured Shared Folders to an app.status answer; a package
167
+ /// that configures none is answered exactly as before.
168
+ pub(super) fn with_shared_folders(&self, mut status: Value) -> Value {
169
+ match self.shared_folders() {
170
+ Err(e) => status["shared_folders_error"] = e.to_string().into(),
171
+ Ok(folders) if !folders.is_empty() => {
172
+ status["shared_folders"] = serde_json::to_value(folders).unwrap_or_default();
173
+ }
174
+ Ok(_) => {}
175
+ }
176
+ status
177
+ }
178
+ }
179
+
180
+ #[cfg(test)]
181
+ mod tests {
182
+ use std::fs;
183
+ use std::os::unix::fs::PermissionsExt;
184
+
185
+ use super::super::test_support::*;
186
+ use super::*;
187
+
188
+ fn write_config(dir: &Path, body: &str) {
189
+ fs::write(dir.join("config.json"), body).unwrap();
190
+ }
191
+
192
+ fn mkdirs(root: &Path, dirs: &[&str]) {
193
+ for dir in dirs {
194
+ fs::create_dir_all(root.join(dir)).unwrap();
195
+ }
196
+ }
197
+
198
+ #[test]
199
+ fn read_shared_folders_without_config_is_empty() {
200
+ let fixture = Fixture::new();
201
+ assert!(
202
+ read_shared_folders(&fixture.cfg.data_dir)
203
+ .unwrap()
204
+ .is_empty()
205
+ );
206
+ }
207
+
208
+ #[test]
209
+ fn read_shared_folders_returns_names_by_key() {
210
+ let fixture = Fixture::new();
211
+ write_config(
212
+ &fixture.cfg.data_dir,
213
+ r#"{"schema":1,"shared_folders":{"media":"Family Photos"}}"#,
214
+ );
215
+ let got = read_shared_folders(&fixture.cfg.data_dir).unwrap();
216
+ assert_eq!(
217
+ got,
218
+ BTreeMap::from([("media".into(), "Family Photos".into())])
219
+ );
220
+ }
221
+
222
+ #[test]
223
+ fn read_shared_folders_refuses_unknown_schema_and_bad_names() {
224
+ for body in [
225
+ r#"{"schema":2,"shared_folders":{"media":"x"}}"#,
226
+ r#"{"schema":1,"shared_folders":{"media":"a/b"}}"#,
227
+ r#"{"schema":1,"shared_folders":{"media":".."}}"#,
228
+ r#"{"schema":1,"shared_folders":{"media":"@appdata"}}"#,
229
+ r#"{"schema":1,"shared_folders":{"media":""}}"#,
230
+ "not json",
231
+ ] {
232
+ let fixture = Fixture::new();
233
+ write_config(&fixture.cfg.data_dir, body);
234
+ assert!(
235
+ read_shared_folders(&fixture.cfg.data_dir).is_err(),
236
+ "{body}"
237
+ );
238
+ }
239
+ }
240
+
241
+ #[test]
242
+ fn resolve_shared_folder_finds_the_name_on_its_volume() {
243
+ let fixture = Fixture::new();
244
+ let root = fixture.scratch();
245
+ mkdirs(
246
+ &root,
247
+ &["volume1/other", "volume2/Photos", "volume2/@appdata"],
248
+ );
249
+ assert_eq!(
250
+ resolve_shared_folder(&root, "Photos").unwrap(),
251
+ root.join("volume2/Photos")
252
+ );
253
+ }
254
+
255
+ #[test]
256
+ fn resolve_shared_folder_follows_a_moved_share() {
257
+ let fixture = Fixture::new();
258
+ let root = fixture.scratch();
259
+ mkdirs(&root, &["volume1/Photos"]);
260
+ let first = resolve_shared_folder(&root, "Photos").unwrap();
261
+ fs::rename(root.join("volume1/Photos"), root.join("Photos.tmp")).unwrap();
262
+ mkdirs(&root, &["volume2"]);
263
+ fs::rename(root.join("Photos.tmp"), root.join("volume2/Photos")).unwrap();
264
+ let second = resolve_shared_folder(&root, "Photos").unwrap();
265
+ assert_ne!(first, second);
266
+ assert_eq!(second, root.join("volume2/Photos"));
267
+ }
268
+
269
+ #[test]
270
+ fn resolve_shared_folder_errors() {
271
+ let fixture = Fixture::new();
272
+ let root = fixture.scratch();
273
+ assert_eq!(
274
+ resolve_shared_folder(&root, "Missing"),
275
+ Err(SharedFolderError::NotFound)
276
+ );
277
+ mkdirs(&root, &["volume1/Dup", "volume2/Dup"]);
278
+ assert_eq!(
279
+ resolve_shared_folder(&root, "Dup"),
280
+ Err(SharedFolderError::Ambiguous)
281
+ );
282
+ for bad in ["", "..", "a/b", "@appdata", ".x"] {
283
+ assert!(resolve_shared_folder(&root, bad).is_err(), "{bad:?}");
284
+ }
285
+ }
286
+
287
+ #[test]
288
+ fn resolve_shared_folder_ignores_a_file_of_that_name() {
289
+ let fixture = Fixture::new();
290
+ let root = fixture.scratch();
291
+ mkdirs(&root, &["volume1"]);
292
+ fs::write(root.join("volume1/Notes"), "").unwrap();
293
+ assert_eq!(
294
+ resolve_shared_folder(&root, "Notes"),
295
+ Err(SharedFolderError::NotFound)
296
+ );
297
+ }
298
+
299
+ #[test]
300
+ fn only_volume_directories_are_searched() {
301
+ let fixture = Fixture::new();
302
+ let root = fixture.scratch();
303
+ mkdirs(
304
+ &root,
305
+ &[
306
+ "volumes/Photos",
307
+ "volume/Photos",
308
+ "volumeX/Photos",
309
+ "volume10/Photos",
310
+ ],
311
+ );
312
+ assert_eq!(
313
+ resolve_shared_folder(&root, "Photos").unwrap(),
314
+ root.join("volume10/Photos")
315
+ );
316
+ }
317
+
318
+ #[test]
319
+ fn status_omits_shared_folders_when_none_configured() {
320
+ let (_, out) = call(&Fixture::new().cfg, &alice(), "app.status", "{}");
321
+ assert!(out["data"].get("shared_folders").is_none(), "{out}");
322
+ }
323
+
324
+ #[test]
325
+ fn status_reports_each_configured_shared_folder() {
326
+ let mut fixture = Fixture::new();
327
+ let root = fixture.scratch();
328
+ mkdirs(&root, &["volume1/Photos"]);
329
+ fixture.cfg.volume_root = Some(root.clone());
330
+ write_config(
331
+ &fixture.cfg.data_dir,
332
+ r#"{"schema":1,"shared_folders":{"media":"Photos","gone":"Missing"}}"#,
333
+ );
334
+ let (_, out) = call(&fixture.cfg, &alice(), "app.status", "{}");
335
+ let rows = out["data"]["shared_folders"].as_array().unwrap();
336
+ assert_eq!(rows.len(), 2);
337
+ let by_key = |key: &str| rows.iter().find(|r| r["key"] == key).unwrap();
338
+ let (media, gone) = (by_key("media"), by_key("gone"));
339
+ assert_eq!(media["name"], "Photos");
340
+ assert_eq!(media["exists"], true);
341
+ assert_eq!(media["readable"], true);
342
+ assert_eq!(media["writable"], true);
343
+ assert_eq!(media["path"], root.join("volume1/Photos").to_str().unwrap());
344
+ assert_eq!(gone["exists"], false);
345
+ assert_eq!(gone["readable"], false);
346
+ assert_eq!(gone["writable"], false);
347
+ assert!(gone["error"].is_string());
348
+ assert!(gone.get("path").is_none());
349
+ }
350
+
351
+ #[test]
352
+ fn status_reports_a_folder_the_package_account_cannot_write() {
353
+ // SAFETY: geteuid takes no arguments and cannot fail.
354
+ if unsafe { libc::geteuid() } == 0 {
355
+ return; // root ignores directory modes
356
+ }
357
+ let mut fixture = Fixture::new();
358
+ let root = fixture.scratch();
359
+ mkdirs(&root, &["volume1/Locked"]);
360
+ let dir = root.join("volume1/Locked");
361
+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o555)).unwrap();
362
+ fixture.cfg.volume_root = Some(root);
363
+ write_config(
364
+ &fixture.cfg.data_dir,
365
+ r#"{"schema":1,"shared_folders":{"media":"Locked"}}"#,
366
+ );
367
+ let (_, out) = call(&fixture.cfg, &alice(), "app.status", "{}");
368
+ fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap();
369
+ let row = &out["data"]["shared_folders"][0];
370
+ assert_eq!(row["exists"], true);
371
+ assert_eq!(row["readable"], true);
372
+ assert_eq!(row["writable"], false);
373
+ }
374
+
375
+ #[test]
376
+ fn status_reports_a_corrupt_config_without_failing() {
377
+ let fixture = Fixture::new();
378
+ write_config(&fixture.cfg.data_dir, "not json");
379
+ let (code, out) = call(&fixture.cfg, &alice(), "app.status", "{}");
380
+ assert_eq!(code, "");
381
+ assert!(out["data"]["shared_folders_error"].is_string(), "{out}");
382
+ }
383
+ }
@@ -0,0 +1,200 @@
1
+ //! Helpers shared by the unit tests: a data directory, stub authenticators and
2
+ //! a call through the App's server.
3
+
4
+ use std::path::{Path, PathBuf};
5
+ use std::sync::Arc;
6
+ use std::sync::atomic::{AtomicU64, Ordering};
7
+
8
+ use serde_json::Value;
9
+ use syno_pkg_land_sdk::api::{
10
+ ADMINISTRATOR_PERMISSION, AuthError, Authenticator, Identity, Route, Server,
11
+ };
12
+ use syno_pkg_land_sdk::http::{Handler, Recorder, Request};
13
+
14
+ use super::{Config, routes};
15
+ use crate::permissions::permissions;
16
+
17
+ /// A private directory removed on drop, like Go's t.TempDir.
18
+ pub struct TempDir(PathBuf);
19
+
20
+ impl TempDir {
21
+ pub fn new() -> Self {
22
+ static NEXT: AtomicU64 = AtomicU64::new(0);
23
+ let n = NEXT.fetch_add(1, Ordering::SeqCst);
24
+ let path = std::env::temp_dir().join(format!("app-test-{}-{n}", std::process::id()));
25
+ let _ = std::fs::remove_dir_all(&path);
26
+ std::fs::create_dir_all(&path).unwrap();
27
+ TempDir(path)
28
+ }
29
+
30
+ pub fn path(&self) -> &Path {
31
+ &self.0
32
+ }
33
+ }
34
+
35
+ impl Drop for TempDir {
36
+ fn drop(&mut self) {
37
+ let _ = std::fs::remove_dir_all(&self.0);
38
+ }
39
+ }
40
+
41
+ /// A Config over a fresh data directory that lives as long as the fixture.
42
+ pub struct Fixture {
43
+ pub cfg: Config,
44
+ dir: TempDir,
45
+ }
46
+
47
+ impl Fixture {
48
+ pub fn new() -> Self {
49
+ let dir = TempDir::new();
50
+ let cfg = Config {
51
+ package_id: "HelloRust".into(),
52
+ version: "0.1.0-0001".into(),
53
+ data_dir: dir.path().join("var"),
54
+ uid_lookup: Some(Arc::new(|_| "1024".into())),
55
+ ..Config::default()
56
+ };
57
+ std::fs::create_dir(&cfg.data_dir).unwrap();
58
+ Fixture { cfg, dir }
59
+ }
60
+
61
+ /// A scratch directory next to the data directory.
62
+ pub fn scratch(&self) -> PathBuf {
63
+ let path = self.dir.path().join("scratch");
64
+ std::fs::create_dir_all(&path).unwrap();
65
+ path
66
+ }
67
+
68
+ pub fn assert_data_dir_empty(&self) {
69
+ let entries: Vec<_> = std::fs::read_dir(&self.cfg.data_dir)
70
+ .unwrap()
71
+ .map(|e| e.unwrap().file_name())
72
+ .collect();
73
+ assert!(entries.is_empty(), "data dir changed: {entries:?}");
74
+ }
75
+ }
76
+
77
+ /// What DSM's authentication decided about a request.
78
+ #[derive(Clone)]
79
+ pub enum Stub {
80
+ Signed(Identity),
81
+ NoSession,
82
+ }
83
+
84
+ impl Authenticator for Stub {
85
+ fn authenticate(&self, _: &Request) -> Result<Identity, AuthError> {
86
+ match self {
87
+ Stub::Signed(identity) => Ok(identity.clone()),
88
+ Stub::NoSession => Err(AuthError::NoSession),
89
+ }
90
+ }
91
+ }
92
+
93
+ pub fn user(name: &str, admin: bool) -> Stub {
94
+ Stub::Signed(Identity {
95
+ user: name.into(),
96
+ admin,
97
+ })
98
+ }
99
+
100
+ pub fn alice() -> Stub {
101
+ user("alice", true)
102
+ }
103
+
104
+ pub fn bob() -> Stub {
105
+ user("bob", false)
106
+ }
107
+
108
+ pub fn carol() -> Stub {
109
+ user("carol", false)
110
+ }
111
+
112
+ pub fn nobody() -> Stub {
113
+ Stub::NoSession
114
+ }
115
+
116
+ /// The access rule the server applies to r: the App policy for App methods,
117
+ /// the SDK policy (reported by the route) for SDK methods.
118
+ pub fn rule(r: &Route) -> String {
119
+ let policy: Value = serde_json::from_slice(permissions().unwrap()).unwrap();
120
+ match policy["methods"][r.method()].as_str() {
121
+ Some(rule) => rule.to_string(),
122
+ None if r.public() => "public".into(),
123
+ None if r.admin_only() => ADMINISTRATOR_PERMISSION.into(),
124
+ None => "authenticated".into(),
125
+ }
126
+ }
127
+
128
+ /// A JSON API call, as the browser sends it.
129
+ pub fn api_request(method: &str, params: &str) -> Request {
130
+ let body = format!(r#"{{"method":"{method}","params":{params}}}"#);
131
+ let mut req = Request::new("POST", "/api.cgi").with_body(body);
132
+ req.header.set("Content-Type", "application/json");
133
+ req
134
+ }
135
+
136
+ /// Returns the envelope's error code ("" on success) and the envelope.
137
+ pub fn call(cfg: &Config, auth: &Stub, method: &str, params: &str) -> (String, Value) {
138
+ serve(
139
+ cfg,
140
+ auth,
141
+ api_request(method, params),
142
+ permissions().unwrap(),
143
+ )
144
+ }
145
+
146
+ /// Runs req through the App's server and returns as [`call`] does.
147
+ pub fn serve(cfg: &Config, auth: &Stub, mut req: Request, permissions: &[u8]) -> (String, Value) {
148
+ let server = Server::new_with_permissions(auth.clone(), permissions, routes(cfg.clone()))
149
+ .expect("the App's routes and policy are valid");
150
+ respond(&server, &mut req)
151
+ }
152
+
153
+ /// The envelope a handler wrote for req.
154
+ pub fn respond(handler: &dyn Handler, req: &mut Request) -> (String, Value) {
155
+ let mut rec = Recorder::default();
156
+ handler.serve_http(&mut rec, req);
157
+ let out: Value = serde_json::from_slice(&rec.body)
158
+ .unwrap_or_else(|_| panic!("not JSON: {}", String::from_utf8_lossy(&rec.body)));
159
+ assert_eq!(rec.status, Some(200), "DSM replaces non-200 CGI bodies");
160
+ let code = out["error"]["code"].as_str().unwrap_or("").to_string();
161
+ (code, out)
162
+ }
163
+
164
+ /// Valid params for one route, so a denial test proves the refusal came from
165
+ /// the policy and not from bad input, and the outcome the route has for an
166
+ /// administrator on a fresh data directory ("" is success).
167
+ pub struct Sample {
168
+ pub method: &'static str,
169
+ pub params: String,
170
+ pub outcome: &'static str,
171
+ }
172
+
173
+ pub fn sample(method: &'static str, params: &str, outcome: &'static str) -> Sample {
174
+ Sample {
175
+ method,
176
+ params: params.into(),
177
+ outcome,
178
+ }
179
+ }
180
+
181
+ /// The samples of every route the build has: the base's and each overlay's.
182
+ pub fn samples() -> Vec<Sample> {
183
+ let mut samples = vec![
184
+ sample("app.status", "{}", ""),
185
+ sample("greeting.get", "{}", ""),
186
+ sample("greeting.set", r#"{"text":"Hi"}"#, ""),
187
+ ];
188
+ samples.extend(super::background_routes::samples());
189
+ samples.extend(super::root_routes::samples());
190
+ samples.extend(super::delegate_routes::samples());
191
+ samples
192
+ }
193
+
194
+ pub fn params_of(method: &str) -> String {
195
+ samples()
196
+ .into_iter()
197
+ .find(|s| s.method == method)
198
+ .unwrap_or_else(|| panic!("route {method} has no sample params"))
199
+ .params
200
+ }
@@ -0,0 +1,38 @@
1
+ //! The App's purge hook (dsm-package.yaml `uninstall.purge`): DSM runs
2
+ //! `api.cgi uninstall` as the package account before the executor is removed,
3
+ //! with the admin's "delete state" answer.
4
+
5
+ use std::io::{self, ErrorKind};
6
+ use std::path::Path;
7
+
8
+ /// Release here what the App holds outside the package. With `delete_state`
9
+ /// it deletes the state `uninstall.state` lists, the background job spool;
10
+ /// package data stays for DSM's own "remove data" option.
11
+ pub fn uninstall(data_dir: &Path, delete_state: bool) -> io::Result<()> {
12
+ if !delete_state {
13
+ return Ok(());
14
+ }
15
+ match std::fs::remove_dir_all(data_dir.join("background")) {
16
+ Err(e) if e.kind() == ErrorKind::NotFound => Ok(()),
17
+ other => other,
18
+ }
19
+ }
20
+
21
+ #[cfg(test)]
22
+ mod tests {
23
+ use super::*;
24
+ use crate::app::test_support::TempDir;
25
+
26
+ #[test]
27
+ fn uninstall_deletes_the_job_spool_only_when_asked() {
28
+ let dir = TempDir::new();
29
+ let spool = dir.path().join("background");
30
+ std::fs::create_dir_all(spool.join("inputs")).unwrap();
31
+ uninstall(dir.path(), false).unwrap();
32
+ assert!(spool.exists(), "kept state is gone");
33
+ uninstall(dir.path(), true).unwrap();
34
+ assert!(!spool.exists(), "state not deleted");
35
+ assert!(dir.path().exists(), "package data must stay");
36
+ uninstall(dir.path(), true).expect("nothing left to delete");
37
+ }
38
+ }
@@ -0,0 +1,72 @@
1
+ //! The package backend. DSM runs it as a CGI (ui/cgi/api.cgi in the package)
2
+ //! under the package account, once per request, and as the uninstall hook
3
+ //! (`api.cgi uninstall`) when dsm-package.yaml declares one.
4
+
5
+ use std::path::Path;
6
+ use std::process::exit;
7
+ use std::sync::Arc;
8
+
9
+ use app::app::{migrate, parse_delegate_config, routes, standalone_identity, uninstall};
10
+ use app::build_env::Build;
11
+ use app::permissions::permissions;
12
+ use syno_pkg_land_sdk::BoxError;
13
+ use syno_pkg_land_sdk::api::{Authenticator, Server, with_request_deadline};
14
+ use syno_pkg_land_sdk::dsm;
15
+ use syno_pkg_land_sdk::http::{Handler, cgi};
16
+
17
+ fn main() {
18
+ let build = Build::compiled();
19
+ let cgi = !std::env::var("GATEWAY_INTERFACE")
20
+ .unwrap_or_default()
21
+ .is_empty();
22
+ let args: Vec<String> = std::env::args().skip(1).collect();
23
+ if !cgi && args == ["uninstall"] {
24
+ let delete_state = std::env::var("SYNOPKGLAND_DELETE_STATE").as_deref() == Ok("1");
25
+ if let Err(e) = uninstall(Path::new(&build.data_dir()), delete_state) {
26
+ eprintln!("{}: uninstall: {e}", build.package_id);
27
+ exit(1);
28
+ }
29
+ return;
30
+ }
31
+ if !cgi {
32
+ eprintln!(
33
+ "{} {}: this program runs as the package's DSM CGI; open the app from the DSM main menu.",
34
+ build.package_id, build.version
35
+ );
36
+ exit(2);
37
+ }
38
+ if let Err(e) = run(&build) {
39
+ eprintln!("{}: {e}", build.package_id);
40
+ exit(1);
41
+ }
42
+ }
43
+
44
+ fn run(build: &Build) -> Result<(), BoxError> {
45
+ let cfg = build.config();
46
+ parse_delegate_config(&cfg)?;
47
+ // Kernels without memfd_create stage DSM's bridges in the package's own temp.
48
+ dsm::set_bridge_fallback_dir(format!("/var/packages/{}/tmp", build.package_id));
49
+ let mut auth: Arc<dyn Authenticator> = Arc::new(dsm::Authenticator::new());
50
+ let mut standalone = None;
51
+ if build.identity == "standalone" {
52
+ let script_name = std::env::var("SCRIPT_NAME").unwrap_or_default();
53
+ let service = Arc::new(
54
+ standalone_identity(&cfg, &script_name)
55
+ .map_err(|_| "identity configuration unavailable")?,
56
+ );
57
+ auth = service.clone();
58
+ standalone = Some(service);
59
+ }
60
+ let auth = dsm::new_entry_gate(auth, build.entry, build.package_id, &cfg.data_dir)?;
61
+ let server = Server::new_with_permissions(auth, permissions()?, routes(cfg.clone()))?;
62
+ migrate(&cfg).map_err(|e| format!("schema migration failed: {e}"))?;
63
+ match standalone {
64
+ Some(identity) => serve(identity.wrap(server))?,
65
+ None => serve(server)?,
66
+ }
67
+ Ok(())
68
+ }
69
+
70
+ fn serve(handler: impl Handler) -> std::io::Result<()> {
71
+ cgi::serve(&with_request_deadline(handler))
72
+ }