Skip to main content

ryra_core/registry/
service_def.rs

1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::capability::Capability;
6
7/// A service definition from a registry's `services/<name>/service.toml`.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ServiceDef {
10    pub service: ServiceMeta,
11    #[serde(default)]
12    pub requirements: Option<Requirements>,
13    #[serde(default)]
14    pub ports: Vec<PortDef>,
15    #[serde(default)]
16    pub env: Vec<EnvVar>,
17    /// Optional, user-toggled bundles of env vars. A group is either fully
18    /// enabled (every member lands in `.env`) or fully disabled (none do) —
19    /// makes "client_id without client_secret" unrepresentable.
20    #[serde(default, rename = "env_group")]
21    pub env_groups: Vec<EnvGroup>,
22    #[serde(default)]
23    pub requires: Vec<ServiceRequirement>,
24    #[serde(default)]
25    pub mappings: Mappings,
26    #[serde(default)]
27    pub integrations: IntegrationFlags,
28    /// Roles this service can play for *other* services. The dual of
29    /// [`IntegrationFlags`] (which describes what this service consumes).
30    /// Drives capability-based dispatch — see [`crate::capability`].
31    #[serde(default)]
32    pub capabilities: Capabilities,
33    /// Backup configuration. Present only when the author has declared
34    /// `backup = true` in `[integrations]` and the service needs more
35    /// than the default "back up everything classified as data."
36    /// Carries hooks (pre/post dump) and exclude lists.
37    #[serde(default)]
38    pub backup: Option<BackupConfig>,
39}
40
41/// Capability declarations on a service.
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct Capabilities {
44    /// Capabilities this service offers to other services.
45    #[serde(default)]
46    pub provides: Vec<Capability>,
47}
48
49/// System resource requirements for a service.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct Requirements {
52    /// RAM requirements in megabytes.
53    pub ram: RamRequirement,
54    /// Disk requirements in gigabytes.
55    #[serde(default)]
56    pub disk: Option<DiskRequirement>,
57}
58
59/// RAM requirement with minimum and recommended thresholds.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct RamRequirement {
62    /// Minimum RAM in MB — service may fail below this.
63    pub min: u64,
64    /// Recommended RAM in MB — service will run well at this level.
65    #[serde(default)]
66    pub recommended: Option<u64>,
67}
68
69/// Disk requirement in gigabytes.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct DiskRequirement {
72    /// Minimum disk in GB — container images + data must fit.
73    pub min: u32,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct ServiceMeta {
78    pub name: String,
79    pub description: String,
80    /// Optional URL to documentation or project homepage.
81    #[serde(default)]
82    pub url: Option<String>,
83    #[serde(default)]
84    pub kind: ServiceKind,
85    /// Supported CPU architectures (e.g. ["amd64", "arm64"]).
86    /// Empty means all architectures are supported.
87    #[serde(default)]
88    pub architecture: Vec<Arch>,
89    /// Whether this service requires HTTPS to function.
90    #[serde(default)]
91    pub https: HttpsRequirement,
92}
93
94/// What role this service plays in the system.
95#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "lowercase")]
97pub enum ServiceKind {
98    #[default]
99    Application,
100    Infrastructure,
101}
102
103/// CPU architecture for container images.
104#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum Arch {
107    Amd64,
108    Arm64,
109}
110
111impl std::fmt::Display for Arch {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Arch::Amd64 => write!(f, "amd64"),
115            Arch::Arm64 => write!(f, "arm64"),
116        }
117    }
118}
119
120/// Whether this service requires HTTPS to function.
121///
122/// Declarative, per-service. No magic derivation from other fields — a
123/// service that needs HTTPS must say so explicitly.
124///
125/// - `Never` (default): HTTP is fine. Per RFC 8252 loopback redirect URIs
126///   (`http://127.0.0.1`, `http://localhost`) are valid OIDC callbacks, so
127///   most services work over plain HTTP even with `--auth`.
128/// - `Auth`: HTTPS required when `--auth` is used. For services whose OIDC
129///   implementation rejects plain-HTTP even on loopback (e.g. nextcloud's
130///   `user_oidc` refuses to render the SSO button over HTTP).
131/// - `Always`: HTTPS required regardless of flags. For services that
132///   refuse HTTP outright (e.g. authelia, vaultwarden).
133#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
134#[serde(rename_all = "lowercase")]
135pub enum HttpsRequirement {
136    #[default]
137    Never,
138    Auth,
139    Always,
140}
141
142/// Whether a port uses TCP or UDP.
143#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
144#[serde(rename_all = "lowercase")]
145pub enum PortProtocol {
146    #[default]
147    Tcp,
148    Udp,
149}
150
151impl std::fmt::Display for PortProtocol {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            PortProtocol::Tcp => write!(f, "tcp"),
155            PortProtocol::Udp => write!(f, "udp"),
156        }
157    }
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct PortDef {
162    pub name: String,
163    pub container_port: u16,
164    /// Fixed host port (for privileged services like Caddy that need specific ports).
165    /// If not set, ryra allocates a port dynamically.
166    #[serde(default)]
167    pub host_port: Option<u16>,
168    #[serde(default)]
169    pub protocol: PortProtocol,
170}
171
172/// How an env var is presented to the user during `ryra add`.
173///
174/// - `default`: static value or template (e.g. `{{secret.password}}`),
175///   not prompted — user can edit `.env` manually after install
176/// - `prompted`: shown during `ryra add` with a default value — optional
177///   but visible (e.g. API keys that can be left empty)
178/// - `required`: must be provided during `ryra add` — no usable default,
179///   blocks install if not provided. Tests must supply these via `env` overrides.
180#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
181#[serde(rename_all = "lowercase")]
182pub enum EnvKind {
183    /// Not prompted. Value is used as-is (may contain templates like `{{secret.*}}`).
184    #[default]
185    Default,
186    /// Prompted during `ryra add` with a default. User can accept or change.
187    Prompted,
188    /// Must be provided. No usable default — fails in non-interactive mode
189    /// unless supplied via env overrides.
190    Required,
191}
192
193/// Format of an env var's value — used for secret generation and input validation.
194#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
195#[serde(rename_all = "snake_case")]
196pub enum EnvFormat {
197    /// Free-form alphanumeric string (default).
198    #[default]
199    String,
200    /// Hexadecimal characters only.
201    Hex,
202    /// UUID v4.
203    Uuid,
204    /// HS256-signed JWT. Requires `jwt_role` and `jwt_signing_key` on the env var.
205    JwtHs256,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct EnvVar {
210    pub name: String,
211    pub value: String,
212    #[serde(default)]
213    pub kind: EnvKind,
214    /// Prompt message shown during `ryra add` (for `prompted` and `required` kinds).
215    #[serde(default)]
216    pub prompt: Option<String>,
217    /// Value format — used to generate secrets and validate user input.
218    #[serde(default)]
219    pub format: EnvFormat,
220    /// Length for generated secrets. Ignored for `uuid` and `jwt_hs256` formats.
221    /// Defaults to 32 for `string`, 64 for `hex`.
222    #[serde(default)]
223    pub length: Option<u32>,
224    /// JSON payload claims for `jwt_hs256` format (e.g., `{"role": "anon", "iss": "supabase"}`).
225    /// `iat` and `exp` are added automatically if not present.
226    #[serde(default)]
227    pub jwt_claims: Option<std::collections::BTreeMap<std::string::String, serde_json::Value>>,
228    /// Secret name used as the HS256 signing key (e.g., "jwt_secret"). Required for `jwt_hs256` format.
229    #[serde(default)]
230    pub jwt_signing_key: Option<std::string::String>,
231}
232
233/// A user-toggled bundle of env vars. Enabling the group writes every
234/// member into `.env`; disabling it writes none of them.
235///
236/// Members reuse the full [`EnvVar`] shape — `kind = "default"` members are
237/// auto-included with their rendered template when the group is on,
238/// `prompted` members get shown with a default, `required` members must be
239/// supplied (interactively or via process env).
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct EnvGroup {
242    /// Identifier used by the `--enable <name>` CLI flag. Lowercase
243    /// snake_case by convention.
244    pub name: String,
245    /// Yes/no question shown during `ryra add` to toggle the group.
246    pub prompt: String,
247    #[serde(default)]
248    pub env: Vec<EnvVar>,
249}
250
251/// A service that must already be installed on the system before this one.
252///
253/// References separately-installed ryra services whose env vars
254/// and ports can be referenced via `{{services.<name>.*}}` templates.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ServiceRequirement {
257    pub service: String,
258}
259
260#[derive(Debug, Clone, Default, Serialize, Deserialize)]
261pub struct Mappings {
262    #[serde(default)]
263    pub smtp: BTreeMap<String, String>,
264    #[serde(default)]
265    pub auth: BTreeMap<String, String>,
266}
267
268/// What kind of auth integration a service supports.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270#[serde(rename_all = "kebab-case")]
271pub enum AuthKind {
272    /// Service handles OIDC auth itself (e.g. affine, forgejo).
273    Oidc,
274}
275
276impl std::fmt::Display for AuthKind {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        match self {
279            AuthKind::Oidc => write!(f, "oidc"),
280        }
281    }
282}
283
284/// OIDC token endpoint authentication method for authelia client registration.
285#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
286#[serde(rename_all = "snake_case")]
287pub enum TokenAuthMethod {
288    #[default]
289    ClientSecretPost,
290    ClientSecretBasic,
291    /// PKCE public client — no client_secret sent. Used by apps like Zammad
292    /// that only support the public-client + PKCE OIDC flow.
293    None,
294}
295
296impl TokenAuthMethod {
297    pub fn as_str(&self) -> &'static str {
298        match self {
299            TokenAuthMethod::ClientSecretPost => "client_secret_post",
300            TokenAuthMethod::ClientSecretBasic => "client_secret_basic",
301            TokenAuthMethod::None => "none",
302        }
303    }
304}
305
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct IntegrationFlags {
308    /// Auth types this service supports. Empty = no auth support.
309    #[serde(default)]
310    pub auth: Vec<AuthKind>,
311    /// OIDC token endpoint auth method for authelia client registration.
312    #[serde(default)]
313    pub token_auth_method: TokenAuthMethod,
314    /// OIDC callback path suffixes registered with the auth provider.
315    /// Appended to the service's base URL(s) to form redirect_uris.
316    #[serde(default)]
317    pub oidc_callbacks: Vec<String>,
318    #[serde(default = "default_true")]
319    pub smtp: bool,
320    /// True if the service author has certified this service can be
321    /// backed up safely. The default is `false` (explicit opt-in)
322    /// because the worst failure mode is a backup that takes cleanly
323    /// but won't restore (e.g. forgot to write a pg_dump hook), so
324    /// authors must consciously declare support.
325    ///
326    /// When `true`, an accompanying `[backup]` section MAY provide
327    /// hooks and excludes; when absent, the default behaviour is to
328    /// back up every top-level child of the service home dir that the
329    /// classifier marks as data.
330    #[serde(default)]
331    pub backup: bool,
332}
333
334impl Default for IntegrationFlags {
335    fn default() -> Self {
336        Self {
337            auth: vec![],
338            token_auth_method: TokenAuthMethod::default(),
339            oidc_callbacks: vec![],
340            smtp: true,
341            backup: false,
342        }
343    }
344}
345
346fn default_true() -> bool {
347    true
348}
349
350/// Per-service backup configuration. Present only when the service's
351/// `[integrations]` section sets `backup = true` AND the service needs
352/// non-default behaviour (excludes or hooks).
353///
354/// Hooks are filenames inside `configs/scripts/` (same convention as
355/// the existing `ExecStartPost=` scripts). They run with the same env
356/// as those scripts: `$SERVICE_HOME` plus everything in the service's
357/// `.env` file.
358///
359/// Pre/post hooks form a pair around the operation:
360///
361/// ```text
362/// backup:  [pre_backup]  -> restic snapshot   -> [post_backup]
363/// restore: [pre_restore] -> restic restore    -> [post_restore]
364/// ```
365///
366/// Hooks must dump to `$SERVICE_HOME/.backup/` (a sibling of `data/`)
367/// so it's clear which files are user-owned data versus snapshot
368/// artefacts. Listing `.backup/<file>` in `paths` is required if the
369/// hook writes one; nothing is implicitly included.
370#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
371pub struct BackupConfig {
372    /// Explicit list of paths (relative to service home) to include in
373    /// the snapshot. When empty, the default is "every top-level child
374    /// of the service home dir that the classifier marks as data."
375    #[serde(default)]
376    pub paths: Vec<String>,
377    /// Restic-style exclude patterns relative to service home.
378    /// Useful for skipping caches, previews, transcoding artefacts.
379    #[serde(default)]
380    pub exclude: Vec<String>,
381    /// Script filename (in `configs/scripts/`) run before the restic
382    /// snapshot. Typically dumps a database to `$SERVICE_HOME/.backup/`.
383    #[serde(default)]
384    pub pre_backup: Option<String>,
385    /// Script filename run after a successful restic snapshot.
386    /// Typically cleans up `$SERVICE_HOME/.backup/`.
387    #[serde(default)]
388    pub post_backup: Option<String>,
389    /// Script filename run before restoring (typically stops the
390    /// service and wipes the live data dir).
391    #[serde(default)]
392    pub pre_restore: Option<String>,
393    /// Script filename run after restoring (typically imports the
394    /// dump back into the live database and restarts the service).
395    #[serde(default)]
396    pub post_restore: Option<String>,
397}
398
399// ---------------------------------------------------------------------------
400// Validation
401// ---------------------------------------------------------------------------
402
403impl ServiceDef {
404    /// Check if this service supports the current system architecture.
405    /// Returns None if supported (or no restriction), Some(error) if not.
406    pub fn check_architecture(&self) -> Option<String> {
407        if self.service.architecture.is_empty() {
408            return None;
409        }
410        let current = current_architecture();
411        if self.service.architecture.contains(&current) {
412            None
413        } else {
414            let supported: Vec<_> = self
415                .service
416                .architecture
417                .iter()
418                .map(|a| a.to_string())
419                .collect();
420            Some(format!(
421                "{} only supports {} — this system is {current}",
422                self.service.name,
423                supported.join(", "),
424            ))
425        }
426    }
427
428    /// Returns env var names that are required — must be provided during install.
429    pub fn required_env_vars(&self) -> Vec<&str> {
430        self.env
431            .iter()
432            .filter(|e| e.kind == EnvKind::Required)
433            .map(|e| e.name.as_str())
434            .collect()
435    }
436
437    /// Validate structural invariants that serde can't enforce.
438    /// Called once after deserialization — if this returns Ok, the definition
439    /// is safe to use without further checks.
440    pub fn validate(&self) -> Result<(), String> {
441        let name = &self.service.name;
442        let mut errors: Vec<String> = Vec::new();
443
444        // --- Duplicate names ---
445
446        let mut seen_ports = std::collections::HashSet::new();
447        for p in &self.ports {
448            if !seen_ports.insert(&p.name) {
449                errors.push(format!("duplicate port name '{}'", p.name));
450            }
451        }
452
453        // Every env var name (top-level + every group member) must be unique
454        // across the whole service — podman's .env is a flat keyspace so two
455        // FOO= lines would be ambiguous.
456        let mut seen_envs: std::collections::HashSet<&str> = std::collections::HashSet::new();
457        for e in &self.env {
458            if !seen_envs.insert(&e.name) {
459                errors.push(format!("duplicate env var name '{}'", e.name));
460            }
461        }
462        for g in &self.env_groups {
463            for e in &g.env {
464                if !seen_envs.insert(&e.name) {
465                    errors.push(format!(
466                        "env var '{}' in group '{}' collides with another env var",
467                        e.name, g.name
468                    ));
469                }
470            }
471        }
472
473        // --- Env var name format + kind consistency ---
474
475        for e in &self.env {
476            check_env_var(e, None, &mut errors);
477        }
478
479        // --- Env group names + members ---
480
481        let mut seen_groups = std::collections::HashSet::new();
482        for g in &self.env_groups {
483            if !seen_groups.insert(&g.name) {
484                errors.push(format!("duplicate env_group name '{}'", g.name));
485            }
486            if g.name.is_empty() {
487                errors.push("env_group has empty name".to_string());
488            } else if !g
489                .name
490                .chars()
491                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
492            {
493                errors.push(format!(
494                    "env_group '{}' must be lowercase snake_case ([a-z0-9_])",
495                    g.name
496                ));
497            }
498            if g.prompt.is_empty() {
499                errors.push(format!("env_group '{}' has empty prompt", g.name));
500            }
501            if g.env.is_empty() {
502                errors.push(format!("env_group '{}' has no env vars", g.name));
503            }
504            for e in &g.env {
505                check_env_var(e, Some(&g.name), &mut errors);
506            }
507        }
508
509        // --- RAM requirements consistency ---
510
511        if let Some(ref req) = self.requirements
512            && let Some(rec) = req.ram.recommended
513            && rec < req.ram.min
514        {
515            errors.push(format!(
516                "recommended RAM ({rec}MB) is less than minimum ({}MB)",
517                req.ram.min
518            ));
519        }
520
521        // --- Backup consistency ---
522        //
523        // The `[backup]` section is only meaningful when the author has
524        // certified the service is backup-safe via `backup = true`. If
525        // they wrote hooks/excludes without flipping the flag we'd
526        // silently ship a service whose backup support is half-declared,
527        // so reject it loudly.
528        if let Some(ref backup) = self.backup
529            && !self.integrations.backup
530        {
531            errors.push("[backup] section requires `backup = true` in [integrations]".to_string());
532            // No-op read so the binding isn't unused if all sub-checks
533            // below get gated out by serde defaults.
534            let _ = backup;
535        }
536        if let Some(ref backup) = self.backup {
537            for (label, hook) in [
538                ("pre_backup", &backup.pre_backup),
539                ("post_backup", &backup.post_backup),
540                ("pre_restore", &backup.pre_restore),
541                ("post_restore", &backup.post_restore),
542            ] {
543                if let Some(script) = hook
544                    && (script.is_empty() || script.contains('/') || script.contains(".."))
545                {
546                    errors.push(format!(
547                        "backup hook '{label}' must be a bare filename under configs/scripts/ \
548                         (got {script:?})"
549                    ));
550                }
551            }
552            for p in &backup.paths {
553                if p.is_empty() || p.starts_with('/') || p.contains("..") {
554                    errors.push(format!(
555                        "backup path {p:?} must be a relative path within the service home"
556                    ));
557                }
558            }
559        }
560
561        if errors.is_empty() {
562            Ok(())
563        } else {
564            Err(format!("{name}: {}", errors.join("; ")))
565        }
566    }
567}
568
569/// Shared name-format + kind-consistency check for a single `EnvVar`, used
570/// for both top-level `[[env]]` entries and `[[env_group.env]]` members.
571/// `group` is `Some(group_name)` for member vars — it's used to make error
572/// messages locate the offending declaration.
573fn check_env_var(e: &EnvVar, group: Option<&str>, errors: &mut Vec<String>) {
574    let where_ = match group {
575        Some(g) => format!(" in group '{g}'"),
576        None => String::new(),
577    };
578    if e.name.is_empty() {
579        errors.push(format!("env var has empty name{where_}"));
580    } else if !e
581        .name
582        .chars()
583        .next()
584        .is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
585    {
586        errors.push(format!(
587            "env var '{}'{where_} must start with a letter or _",
588            e.name
589        ));
590    } else if !e
591        .name
592        .chars()
593        .all(|c| c.is_ascii_alphanumeric() || c == '_')
594    {
595        errors.push(format!(
596            "env var '{}'{where_} contains invalid characters — must match [A-Za-z0-9_]",
597            e.name
598        ));
599    }
600    if e.kind == EnvKind::Required && e.value.contains("{{secret.") {
601        errors.push(format!(
602            "env var '{}'{where_} is kind=required but has a secret template default — use kind=prompted or kind=default",
603            e.name
604        ));
605    }
606}
607
608/// Detect the current system architecture using OCI/Docker naming conventions.
609pub fn current_architecture() -> Arch {
610    match std::env::consts::ARCH {
611        "x86_64" => Arch::Amd64,
612        "aarch64" => Arch::Arm64,
613        // Fallback: default to amd64 for unknown architectures.
614        // The service's check_architecture() will catch unsupported ones.
615        _ => Arch::Amd64,
616    }
617}
618
619#[cfg(test)]
620mod backup_tests {
621    use super::*;
622
623    fn parse(toml_src: &str) -> ServiceDef {
624        toml::from_str(toml_src).expect("parse")
625    }
626
627    #[test]
628    fn backup_defaults_to_false_when_omitted() {
629        let svc = parse(
630            r#"
631[service]
632name = "x"
633description = "x"
634"#,
635        );
636        assert!(!svc.integrations.backup);
637        assert!(svc.backup.is_none());
638        svc.validate().expect("default is valid");
639    }
640
641    #[test]
642    fn backup_section_alone_is_rejected_without_integration_flag() {
643        let svc = parse(
644            r#"
645[service]
646name = "x"
647description = "x"
648
649[backup]
650"#,
651        );
652        let err = svc.validate().expect_err("must reject");
653        assert!(
654            err.contains("backup = true"),
655            "error mentions the required flag: {err}"
656        );
657    }
658
659    #[test]
660    fn backup_supported_without_hooks_validates() {
661        let svc = parse(
662            r#"
663[service]
664name = "x"
665description = "x"
666
667[integrations]
668backup = true
669"#,
670        );
671        assert!(svc.integrations.backup);
672        assert!(svc.backup.is_none());
673        svc.validate().expect("ok without [backup] table");
674    }
675
676    #[test]
677    fn backup_with_full_hooks_validates() {
678        let svc = parse(
679            r#"
680[service]
681name = "x"
682description = "x"
683
684[integrations]
685backup = true
686
687[backup]
688paths = [".backup/db.sql.gz", "data"]
689exclude = ["data/cache"]
690pre_backup = "backup-pre.sh"
691post_backup = "backup-post.sh"
692pre_restore = "restore-pre.sh"
693post_restore = "restore-post.sh"
694"#,
695        );
696        svc.validate().expect("ok");
697        let backup = svc.backup.as_ref().expect("section present");
698        assert_eq!(backup.paths, vec![".backup/db.sql.gz", "data"]);
699        assert_eq!(backup.pre_backup.as_deref(), Some("backup-pre.sh"));
700    }
701
702    #[test]
703    fn backup_hook_with_slash_is_rejected() {
704        let svc = parse(
705            r#"
706[service]
707name = "x"
708description = "x"
709
710[integrations]
711backup = true
712
713[backup]
714pre_backup = "subdir/script.sh"
715"#,
716        );
717        let err = svc.validate().expect_err("must reject");
718        assert!(err.contains("pre_backup"), "{err}");
719    }
720
721    #[test]
722    fn backup_hook_with_dotdot_is_rejected() {
723        let svc = parse(
724            r#"
725[service]
726name = "x"
727description = "x"
728
729[integrations]
730backup = true
731
732[backup]
733post_backup = "../escape.sh"
734"#,
735        );
736        let err = svc.validate().expect_err("must reject");
737        assert!(err.contains("post_backup"), "{err}");
738    }
739
740    #[test]
741    fn backup_absolute_path_is_rejected() {
742        let svc = parse(
743            r#"
744[service]
745name = "x"
746description = "x"
747
748[integrations]
749backup = true
750
751[backup]
752paths = ["/etc/passwd"]
753"#,
754        );
755        let err = svc.validate().expect_err("must reject");
756        assert!(err.contains("/etc/passwd"), "{err}");
757    }
758
759    #[test]
760    fn backup_path_with_dotdot_is_rejected() {
761        let svc = parse(
762            r#"
763[service]
764name = "x"
765description = "x"
766
767[integrations]
768backup = true
769
770[backup]
771paths = ["../../somewhere"]
772"#,
773        );
774        let err = svc.validate().expect_err("must reject");
775        assert!(err.contains("somewhere"), "{err}");
776    }
777}