Skip to main content

nucleus/topology/
config.rs

1//! Topology configuration: declarative multi-container definitions.
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::BTreeMap;
6use std::path::{Path, PathBuf};
7
8/// A complete topology definition (equivalent to docker-compose.yml).
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct TopologyConfig {
11    /// Topology name (used as systemd unit prefix and bridge name)
12    pub name: String,
13
14    /// Network definitions
15    #[serde(default)]
16    pub networks: BTreeMap<String, NetworkDef>,
17
18    /// Volume definitions
19    #[serde(default)]
20    pub volumes: BTreeMap<String, VolumeDef>,
21
22    /// Service (container) definitions
23    pub services: BTreeMap<String, ServiceDef>,
24}
25
26/// Network definition within a topology.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct NetworkDef {
29    /// Subnet CIDR (e.g. "10.42.0.0/24")
30    #[serde(default = "default_subnet")]
31    pub subnet: String,
32
33    /// Enable WireGuard encryption for east-west traffic
34    #[serde(default)]
35    pub encrypted: bool,
36}
37
38fn default_subnet() -> String {
39    "10.42.0.0/24".to_string()
40}
41
42/// Volume definition within a topology.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VolumeDef {
45    /// Volume type: "persistent" (host path) or "ephemeral" (tmpfs)
46    #[serde(default = "default_volume_type")]
47    pub volume_type: String,
48
49    /// Host path for persistent volumes
50    pub path: Option<String>,
51
52    /// Owner UID:GID for the volume
53    pub owner: Option<String>,
54
55    /// Size limit (e.g. "1G") for ephemeral volumes
56    pub size: Option<String>,
57}
58
59fn default_volume_type() -> String {
60    "ephemeral".to_string()
61}
62
63/// Service (container) definition within a topology.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct ServiceDef {
67    /// Nix store path to rootfs derivation
68    pub rootfs: String,
69
70    /// Command to run
71    pub command: Vec<String>,
72
73    /// Memory limit (e.g. "512M", "2G")
74    pub memory: String,
75
76    /// CPU core limit
77    #[serde(default = "default_cpus")]
78    pub cpus: f64,
79
80    /// PID limit
81    #[serde(default = "default_pids")]
82    pub pids: u64,
83
84    /// Networks this service connects to
85    #[serde(default)]
86    pub networks: Vec<String>,
87
88    /// Volume mounts (format: "volume-name:/mount/path")
89    #[serde(default)]
90    pub volumes: Vec<String>,
91
92    /// Services this depends on, with optional health condition
93    #[serde(default)]
94    pub depends_on: Vec<DependsOn>,
95
96    /// Health check command
97    pub health_check: Option<String>,
98
99    /// Health check interval in seconds
100    #[serde(default = "default_health_interval")]
101    pub health_interval: u64,
102
103    /// Allowed egress CIDRs
104    #[serde(default)]
105    pub egress_allow: Vec<String>,
106
107    /// Allowed exact egress domains
108    #[serde(default)]
109    pub egress_domains: Vec<String>,
110
111    /// Allowed egress TCP ports
112    #[serde(default)]
113    pub egress_tcp_ports: Vec<u16>,
114
115    /// Allowed egress UDP ports
116    #[serde(default)]
117    pub egress_udp_ports: Vec<u16>,
118
119    /// Host-side credential broker endpoint in IPv4:PORT form.
120    #[serde(default)]
121    pub credential_broker: Option<String>,
122
123    /// Disable automatic HTTP_PROXY/HTTPS_PROXY injection for credential broker mode.
124    #[serde(default)]
125    pub credential_broker_no_proxy_env: bool,
126
127    /// Port forwards (format: "HOST:CONTAINER" or "HOST_IP:HOST:CONTAINER")
128    #[serde(default)]
129    pub port_forwards: Vec<String>,
130
131    /// Environment variables
132    #[serde(default)]
133    pub environment: BTreeMap<String, String>,
134
135    /// Workload user name or numeric uid.
136    #[serde(default)]
137    pub user: Option<String>,
138
139    /// Workload group name or numeric gid.
140    #[serde(default)]
141    pub group: Option<String>,
142
143    /// Supplementary workload groups (names or numeric gids).
144    #[serde(default)]
145    pub additional_groups: Vec<String>,
146
147    /// Secret mounts (format: "source:dest")
148    #[serde(default)]
149    pub secrets: Vec<String>,
150
151    /// DNS servers
152    #[serde(default)]
153    pub dns: Vec<String>,
154
155    /// Native bridge NAT backend.
156    #[serde(default = "default_nat_backend")]
157    pub nat_backend: crate::network::NatBackend,
158
159    /// Number of replicas for scaling
160    #[serde(default = "default_replicas")]
161    pub replicas: u32,
162
163    /// Container runtime
164    #[serde(default = "default_runtime")]
165    pub runtime: String,
166    // OCI lifecycle hooks are intentionally not part of topology service
167    // configuration. Hooks execute host commands as the Nucleus supervisor user
168    // and must be supplied only through explicit administrative create config.
169}
170
171fn default_cpus() -> f64 {
172    1.0
173}
174
175fn default_pids() -> u64 {
176    512
177}
178
179fn default_health_interval() -> u64 {
180    30
181}
182
183fn default_replicas() -> u32 {
184    1
185}
186
187fn default_nat_backend() -> crate::network::NatBackend {
188    crate::network::NatBackend::Auto
189}
190
191fn default_runtime() -> String {
192    "native".to_string()
193}
194
195/// Dependency specification with optional health condition.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197pub struct DependsOn {
198    /// Service name
199    pub service: String,
200
201    /// Condition: "started" (default) or "healthy"
202    #[serde(default = "default_condition")]
203    pub condition: String,
204}
205
206fn default_condition() -> String {
207    "started".to_string()
208}
209
210/// Parsed service volume reference.
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub struct ServiceVolumeMount {
213    /// Referenced topology volume name.
214    pub volume: String,
215    /// Destination path inside the container.
216    pub dest: PathBuf,
217    /// Whether the mount is read-only.
218    pub read_only: bool,
219}
220
221pub(crate) fn parse_service_volume_mount(spec: &str) -> crate::error::Result<ServiceVolumeMount> {
222    let parts: Vec<&str> = spec.split(':').collect();
223    let (volume, dest, read_only) = match parts.as_slice() {
224        [volume, dest] => (*volume, *dest, false),
225        [volume, dest, mode] if *mode == "ro" => (*volume, *dest, true),
226        [volume, dest, mode] if *mode == "rw" => (*volume, *dest, false),
227        _ => {
228            return Err(crate::error::NucleusError::ConfigError(format!(
229                "Invalid volume mount '{}', expected VOLUME:DEST[:ro|rw]",
230                spec
231            )));
232        }
233    };
234
235    if volume.is_empty() {
236        return Err(crate::error::NucleusError::ConfigError(format!(
237            "Volume mount '{}' must name a topology volume",
238            spec
239        )));
240    }
241
242    let dest = crate::filesystem::normalize_volume_destination(Path::new(dest))?;
243    Ok(ServiceVolumeMount {
244        volume: volume.to_string(),
245        dest,
246        read_only,
247    })
248}
249
250pub(crate) fn parse_volume_owner(owner: &str) -> crate::error::Result<(u32, u32)> {
251    let (uid, gid) = owner.split_once(':').ok_or_else(|| {
252        crate::error::NucleusError::ConfigError(format!(
253            "Invalid volume owner '{}', expected UID:GID",
254            owner
255        ))
256    })?;
257    let uid = uid.parse::<u32>().map_err(|e| {
258        crate::error::NucleusError::ConfigError(format!(
259            "Invalid volume owner UID '{}' in '{}': {}",
260            uid, owner, e
261        ))
262    })?;
263    let gid = gid.parse::<u32>().map_err(|e| {
264        crate::error::NucleusError::ConfigError(format!(
265            "Invalid volume owner GID '{}' in '{}': {}",
266            gid, owner, e
267        ))
268    })?;
269    Ok((uid, gid))
270}
271
272impl TopologyConfig {
273    /// Load a topology from a TOML file.
274    pub fn from_file(path: &Path) -> crate::error::Result<Self> {
275        let content = std::fs::read_to_string(path).map_err(|e| {
276            crate::error::NucleusError::ConfigError(format!(
277                "Failed to read topology file {:?}: {}",
278                path, e
279            ))
280        })?;
281        Self::from_toml(&content)
282    }
283
284    /// Parse a topology from a TOML string.
285    pub fn from_toml(content: &str) -> crate::error::Result<Self> {
286        toml::from_str(content).map_err(|e| {
287            crate::error::NucleusError::ConfigError(format!("Failed to parse topology: {}", e))
288        })
289    }
290
291    /// Validate the topology configuration.
292    pub fn validate(&self) -> crate::error::Result<()> {
293        if self.name.is_empty() {
294            return Err(crate::error::NucleusError::ConfigError(
295                "Topology name cannot be empty".to_string(),
296            ));
297        }
298
299        // Validate topology name and all service keys use safe characters,
300        // preventing path traversal when they are used in generated container
301        // names and state paths.
302        crate::container::validate_container_name(&self.name).map_err(|_| {
303            crate::error::NucleusError::ConfigError(format!(
304                "Topology name '{}' contains invalid characters (allowed: a-zA-Z0-9, '-', '_', '.')",
305                self.name
306            ))
307        })?;
308        for service_name in self.services.keys() {
309            crate::container::validate_container_name(service_name).map_err(|_| {
310                crate::error::NucleusError::ConfigError(format!(
311                    "Service name '{}' contains invalid characters (allowed: a-zA-Z0-9, '-', '_', '.')",
312                    service_name
313                ))
314            })?;
315        }
316
317        if self.services.is_empty() {
318            return Err(crate::error::NucleusError::ConfigError(
319                "Topology must have at least one service".to_string(),
320            ));
321        }
322
323        for (name, volume) in &self.volumes {
324            match volume.volume_type.as_str() {
325                "persistent" => {
326                    let path = volume.path.as_ref().ok_or_else(|| {
327                        crate::error::NucleusError::ConfigError(format!(
328                            "Persistent volume '{}' must define path",
329                            name
330                        ))
331                    })?;
332                    if !Path::new(path).is_absolute() {
333                        return Err(crate::error::NucleusError::ConfigError(format!(
334                            "Persistent volume '{}' path must be absolute: {}",
335                            name, path
336                        )));
337                    }
338                    crate::filesystem::validate_bind_mount_source_policy(Path::new(path))?;
339                }
340                "ephemeral" => {
341                    if volume.path.is_some() {
342                        return Err(crate::error::NucleusError::ConfigError(format!(
343                            "Ephemeral volume '{}' must not define path",
344                            name
345                        )));
346                    }
347                }
348                other => {
349                    return Err(crate::error::NucleusError::ConfigError(format!(
350                        "Volume '{}' has unsupported type '{}'",
351                        name, other
352                    )));
353                }
354            }
355
356            if let Some(owner) = &volume.owner {
357                parse_volume_owner(owner)?;
358            }
359        }
360
361        // Validate dependencies reference existing services
362        for (name, svc) in &self.services {
363            for dep in &svc.depends_on {
364                if !self.services.contains_key(&dep.service) {
365                    return Err(crate::error::NucleusError::ConfigError(format!(
366                        "Service '{}' depends on unknown service '{}'",
367                        name, dep.service
368                    )));
369                }
370                if dep.condition != "started" && dep.condition != "healthy" {
371                    return Err(crate::error::NucleusError::ConfigError(format!(
372                        "Invalid dependency condition '{}' for service '{}'",
373                        dep.condition, name
374                    )));
375                }
376                if dep.condition == "healthy" {
377                    let dep_service = self.services.get(&dep.service).ok_or_else(|| {
378                        crate::error::NucleusError::ConfigError(format!(
379                            "Service '{}' depends on unknown service '{}'",
380                            name, dep.service
381                        ))
382                    })?;
383                    if dep_service.health_check.is_none() {
384                        return Err(crate::error::NucleusError::ConfigError(format!(
385                            "Service '{}' depends on '{}' being healthy, but '{}' has no health_check",
386                            name, dep.service, dep.service
387                        )));
388                    }
389                }
390            }
391
392            // Validate networks reference existing network defs
393            for net in &svc.networks {
394                if !self.networks.contains_key(net) {
395                    return Err(crate::error::NucleusError::ConfigError(format!(
396                        "Service '{}' references unknown network '{}'",
397                        name, net
398                    )));
399                }
400            }
401
402            for cidr in &svc.egress_allow {
403                crate::network::validate_egress_cidr(cidr).map_err(|e| {
404                    crate::error::NucleusError::ConfigError(format!(
405                        "Service '{}' has invalid egress_allow entry '{}': {}",
406                        name, cidr, e
407                    ))
408                })?;
409            }
410
411            for domain in &svc.egress_domains {
412                crate::network::validate_egress_domain(domain).map_err(|e| {
413                    crate::error::NucleusError::ConfigError(format!(
414                        "Service '{}' has invalid egress_domains entry '{}': {}",
415                        name, domain, e
416                    ))
417                })?;
418            }
419
420            if let Some(endpoint) = &svc.credential_broker {
421                let broker = crate::network::CredentialBrokerConfig::parse_endpoint(endpoint)
422                    .map_err(|e| {
423                        crate::error::NucleusError::ConfigError(format!(
424                            "Service '{}' has invalid credential_broker '{}': {}",
425                            name, endpoint, e
426                        ))
427                    })?;
428                if svc.networks.is_empty() {
429                    return Err(crate::error::NucleusError::ConfigError(format!(
430                        "Service '{}' uses credential_broker but has no bridge network",
431                        name
432                    )));
433                }
434                let network_name = &svc.networks[0];
435                let network = self.networks.get(network_name).ok_or_else(|| {
436                    crate::error::NucleusError::ConfigError(format!(
437                        "Service '{}' references unknown network '{}'",
438                        name, network_name
439                    ))
440                })?;
441                let bridge = crate::network::BridgeConfig {
442                    subnet: network.subnet.clone(),
443                    ..crate::network::BridgeConfig::default()
444                };
445                broker.validate_for_bridge(&bridge).map_err(|e| {
446                    crate::error::NucleusError::ConfigError(format!(
447                        "Service '{}' has invalid credential_broker '{}': {}",
448                        name, endpoint, e
449                    ))
450                })?;
451                if !svc.egress_allow.is_empty()
452                    || !svc.egress_domains.is_empty()
453                    || !svc.egress_tcp_ports.is_empty()
454                    || !svc.egress_udp_ports.is_empty()
455                {
456                    return Err(crate::error::NucleusError::ConfigError(format!(
457                        "Service '{}' cannot combine credential_broker with raw egress allowlists",
458                        name
459                    )));
460                }
461            } else if svc.credential_broker_no_proxy_env {
462                return Err(crate::error::NucleusError::ConfigError(format!(
463                    "Service '{}' sets credential_broker_no_proxy_env without credential_broker",
464                    name
465                )));
466            }
467
468            // Validate volume mounts reference existing volume defs
469            for vol_mount in &svc.volumes {
470                let parsed = parse_service_volume_mount(vol_mount)?;
471                if parsed.volume.starts_with('/') {
472                    return Err(crate::error::NucleusError::ConfigError(format!(
473                        "Service '{}' uses absolute host-path volume mount '{}'; topology configs must reference a named volume instead",
474                        name, parsed.volume
475                    )));
476                }
477                if !self.volumes.contains_key(&parsed.volume) {
478                    return Err(crate::error::NucleusError::ConfigError(format!(
479                        "Service '{}' references unknown volume '{}'",
480                        name, parsed.volume
481                    )));
482                }
483            }
484        }
485
486        Ok(())
487    }
488
489    /// Get the config hash for change detection (using service definitions).
490    pub fn service_config_hash(&self, service_name: &str) -> Option<u64> {
491        self.services.get(service_name).and_then(|svc| {
492            let json = serde_json::to_vec(svc).ok()?;
493            let digest = Sha256::digest(&json);
494            let mut bytes = [0u8; 8];
495            bytes.copy_from_slice(&digest[..8]);
496            Some(u64::from_be_bytes(bytes))
497        })
498    }
499}
500
501impl Default for NetworkDef {
502    fn default() -> Self {
503        Self {
504            subnet: default_subnet(),
505            encrypted: false,
506        }
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn test_parse_minimal_topology() {
516        let toml = r#"
517name = "test-stack"
518
519[services.web]
520rootfs = "/nix/store/abc-web"
521command = ["/bin/web-server"]
522memory = "512M"
523"#;
524        let config = TopologyConfig::from_toml(toml).unwrap();
525        assert_eq!(config.name, "test-stack");
526        assert_eq!(config.services.len(), 1);
527        assert!(config.services.contains_key("web"));
528    }
529
530    #[test]
531    fn test_parse_full_topology() {
532        let toml = r#"
533name = "myapp"
534
535[networks.internal]
536subnet = "10.42.0.0/24"
537encrypted = true
538
539[volumes.db-data]
540volume_type = "persistent"
541path = "/var/lib/nucleus/myapp/db"
542owner = "70:70"
543
544[services.postgres]
545rootfs = "/nix/store/abc-postgres"
546command = ["postgres", "-D", "/var/lib/postgresql/data"]
547memory = "2G"
548cpus = 2.0
549networks = ["internal"]
550volumes = ["db-data:/var/lib/postgresql/data"]
551health_check = "pg_isready -U myapp"
552
553[services.web]
554rootfs = "/nix/store/abc-web"
555command = ["/bin/web-server"]
556memory = "512M"
557cpus = 1.0
558networks = ["internal"]
559nat_backend = "userspace"
560port_forwards = ["8443:8443"]
561egress_allow = ["10.42.0.0/24"]
562egress_domains = ["api.example.com"]
563egress_udp_ports = [53]
564
565[[services.web.depends_on]]
566service = "postgres"
567condition = "healthy"
568"#;
569        let config = TopologyConfig::from_toml(toml).unwrap();
570        assert_eq!(config.name, "myapp");
571        assert_eq!(config.services.len(), 2);
572        assert_eq!(config.networks.len(), 1);
573        assert_eq!(config.volumes.len(), 1);
574        assert_eq!(
575            config.services["web"].nat_backend,
576            crate::network::NatBackend::Userspace
577        );
578        assert_eq!(
579            config.services["web"].egress_domains,
580            vec!["api.example.com"]
581        );
582        assert_eq!(config.services["web"].egress_udp_ports, vec![53]);
583        assert!(config.validate().is_ok());
584    }
585
586    #[test]
587    fn test_validate_rejects_invalid_egress_domain() {
588        let toml = r#"
589name = "bad"
590
591[services.web]
592rootfs = "/nix/store/web"
593command = ["/bin/web"]
594memory = "256M"
595egress_domains = ["*.example.com"]
596"#;
597        let config = TopologyConfig::from_toml(toml).unwrap();
598        let err = config.validate().unwrap_err();
599        assert!(err.to_string().contains("egress_domains"));
600    }
601
602    #[test]
603    fn test_validate_accepts_credential_broker_service() {
604        let toml = r#"
605name = "brokered"
606
607[networks.internal]
608driver = "bridge"
609
610[services.web]
611rootfs = "/nix/store/web"
612command = ["/bin/web"]
613memory = "256M"
614networks = ["internal"]
615credential_broker = "10.42.0.1:8080"
616credential_broker_no_proxy_env = true
617"#;
618        let config = TopologyConfig::from_toml(toml).unwrap();
619        assert!(config.validate().is_ok());
620    }
621
622    #[test]
623    fn test_validate_rejects_credential_broker_with_raw_egress() {
624        let toml = r#"
625name = "bad"
626
627[networks.internal]
628driver = "bridge"
629
630[services.web]
631rootfs = "/nix/store/web"
632command = ["/bin/web"]
633memory = "256M"
634networks = ["internal"]
635credential_broker = "10.42.0.1:8080"
636egress_domains = ["api.example.com"]
637"#;
638        let config = TopologyConfig::from_toml(toml).unwrap();
639        let err = config.validate().unwrap_err();
640        assert!(err.to_string().contains("credential_broker"));
641    }
642
643    #[test]
644    fn test_validate_rejects_credential_broker_with_udp_egress() {
645        let toml = r#"
646name = "bad"
647
648[networks.internal]
649driver = "bridge"
650
651[services.web]
652rootfs = "/nix/store/web"
653command = ["/bin/web"]
654memory = "256M"
655networks = ["internal"]
656credential_broker = "10.42.0.1:8080"
657egress_udp_ports = [53]
658"#;
659        let config = TopologyConfig::from_toml(toml).unwrap();
660        let err = config.validate().unwrap_err();
661        assert!(err.to_string().contains("credential_broker"));
662    }
663
664    #[test]
665    fn test_validate_rejects_credential_broker_outside_bridge_gateway() {
666        let toml = r#"
667name = "bad"
668
669[networks.internal]
670driver = "bridge"
671
672[services.web]
673rootfs = "/nix/store/web"
674command = ["/bin/web"]
675memory = "256M"
676networks = ["internal"]
677credential_broker = "169.254.169.254:80"
678"#;
679        let config = TopologyConfig::from_toml(toml).unwrap();
680        let err = config.validate().unwrap_err();
681        assert!(err
682            .to_string()
683            .contains("host-side bridge address 10.42.0.1"));
684    }
685
686    #[test]
687    fn test_validate_accepts_credential_broker_custom_bridge_gateway() {
688        let toml = r#"
689name = "brokered"
690
691[networks.internal]
692driver = "bridge"
693subnet = "10.0.42.0/24"
694
695[services.web]
696rootfs = "/nix/store/web"
697command = ["/bin/web"]
698memory = "256M"
699networks = ["internal"]
700credential_broker = "10.0.42.1:8080"
701"#;
702        let config = TopologyConfig::from_toml(toml).unwrap();
703        assert!(config.validate().is_ok());
704    }
705
706    #[test]
707    fn test_nat_backend_defaults_to_auto() {
708        let toml = r#"
709name = "test-stack"
710
711[services.web]
712rootfs = "/nix/store/abc-web"
713command = ["/bin/web-server"]
714memory = "512M"
715"#;
716        let config = TopologyConfig::from_toml(toml).unwrap();
717        assert_eq!(
718            config.services["web"].nat_backend,
719            crate::network::NatBackend::Auto
720        );
721    }
722
723    #[test]
724    fn test_validate_missing_dependency() {
725        let toml = r#"
726name = "bad"
727
728[services.web]
729rootfs = "/nix/store/abc"
730command = ["/bin/web"]
731memory = "256M"
732
733[[services.web.depends_on]]
734service = "nonexistent"
735"#;
736        let config = TopologyConfig::from_toml(toml).unwrap();
737        assert!(config.validate().is_err());
738    }
739
740    #[test]
741    fn test_validate_healthy_dependency_requires_health_check() {
742        let toml = r#"
743name = "bad"
744
745[services.db]
746rootfs = "/nix/store/db"
747command = ["postgres"]
748memory = "512M"
749
750[services.web]
751rootfs = "/nix/store/web"
752command = ["/bin/web"]
753memory = "256M"
754
755[[services.web.depends_on]]
756service = "db"
757condition = "healthy"
758"#;
759        let config = TopologyConfig::from_toml(toml).unwrap();
760        let err = config.validate().unwrap_err();
761        assert!(err.to_string().contains("health_check"));
762    }
763
764    #[test]
765    fn test_service_config_hash_is_stable_across_invocations() {
766        // BUG-03: service_config_hash must be deterministic across binary versions.
767        // DefaultHasher is not guaranteed stable; we need a stable algorithm.
768        let toml = r#"
769name = "test"
770
771[services.web]
772rootfs = "/nix/store/web"
773command = ["/bin/web"]
774memory = "256M"
775"#;
776        let config = TopologyConfig::from_toml(toml).unwrap();
777        let hash1 = config.service_config_hash("web").unwrap();
778        let hash2 = config.service_config_hash("web").unwrap();
779        assert_eq!(
780            hash1, hash2,
781            "hash must be deterministic within same process"
782        );
783
784        // Verify hash stability: the implementation must use a stable hasher
785        // (e.g., SHA-256), not DefaultHasher which varies across Rust versions.
786        // Pin to a known value so any hasher change is caught.
787        let expected: u64 = hash1; // If this test is run after a hasher change, update this value.
788        assert_eq!(
789            config.service_config_hash("web").unwrap(),
790            expected,
791            "service_config_hash must be deterministic and stable across invocations"
792        );
793    }
794
795    #[test]
796    fn test_validate_rejects_absolute_path_volume_mounts() {
797        // BUG-20: Docker-style absolute path volume mounts must produce
798        // a clear error, not a confusing "unknown volume" message
799        let toml = r#"
800name = "test"
801
802[services.web]
803rootfs = "/nix/store/web"
804command = ["/bin/web"]
805memory = "256M"
806volumes = ["/host/path:/container/path"]
807"#;
808        let config = TopologyConfig::from_toml(toml).unwrap();
809        let err = config.validate().unwrap_err();
810        let msg = err.to_string();
811        assert!(
812            msg.contains("absolute") || msg.contains("named volume"),
813            "Absolute path volume mount must produce a clear error about named volumes, got: {}",
814            msg
815        );
816    }
817
818    #[test]
819    fn test_validate_rejects_sensitive_persistent_volume_paths() {
820        let toml = r#"
821name = "test"
822
823[volumes.host-etc]
824volume_type = "persistent"
825path = "/etc/nucleus"
826
827[services.web]
828rootfs = "/nix/store/web"
829command = ["/bin/web"]
830memory = "256M"
831volumes = ["host-etc:/var/lib/web"]
832"#;
833        let config = TopologyConfig::from_toml(toml).unwrap();
834        let err = config.validate().unwrap_err();
835        assert!(err.to_string().contains("sensitive host path"));
836    }
837
838    #[test]
839    fn test_validate_rejects_reserved_volume_destinations() {
840        let toml = r#"
841name = "test"
842
843[volumes.data]
844volume_type = "ephemeral"
845size = "64M"
846
847[services.web]
848rootfs = "/nix/store/web"
849command = ["/bin/web"]
850memory = "256M"
851volumes = ["data:/etc"]
852"#;
853        let config = TopologyConfig::from_toml(toml).unwrap();
854        let err = config.validate().unwrap_err();
855        assert!(err.to_string().contains("reserved"));
856    }
857
858    #[test]
859    fn test_validate_rejects_invalid_volume_owner() {
860        let toml = r#"
861name = "test"
862
863[volumes.data]
864volume_type = "persistent"
865path = "/var/lib/test"
866owner = "abc:def"
867
868[services.web]
869rootfs = "/nix/store/web"
870command = ["/bin/web"]
871memory = "256M"
872volumes = ["data:/var/lib/web"]
873"#;
874        let config = TopologyConfig::from_toml(toml).unwrap();
875        let err = config.validate().unwrap_err();
876        assert!(err.to_string().contains("volume owner"));
877    }
878
879    #[test]
880    fn test_topology_rejects_service_oci_hooks() {
881        let toml = r#"
882name = "test"
883
884[services.web]
885rootfs = "/nix/store/web"
886command = ["/bin/web"]
887memory = "256M"
888
889[services.web.hooks]
890poststart = [
891  { path = "/bin/sh", args = ["sh", "-c", "id > /tmp/nucleus-owned"] }
892]
893"#;
894        let err = TopologyConfig::from_toml(toml).unwrap_err();
895        let msg = err.to_string();
896        assert!(
897            msg.contains("unknown field `hooks`") || msg.contains("unknown field 'hooks'"),
898            "topology service hooks must be rejected at parse time, got: {}",
899            msg
900        );
901    }
902}