Skip to main content

mvm_core/
dev_network.rs

1use anyhow::{Result, bail};
2use serde::{Deserialize, Serialize};
3
4/// A named dev-mode network with its own bridge and subnet.
5///
6/// Stored as JSON files in `{mvm_share_dir}/networks/<name>.json`.
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8pub struct DevNetwork {
9    /// User-facing network name (e.g. "default", "isolated").
10    pub name: String,
11    /// Linux bridge device name (e.g. "br-mvm-default").
12    pub bridge_name: String,
13    /// Subnet CIDR (e.g. "172.16.0.0/24").
14    pub subnet: String,
15    /// Gateway IP — first usable address (e.g. "172.16.0.1").
16    pub gateway: String,
17    /// RFC 3339 creation timestamp.
18    pub created_at: String,
19}
20
21impl DevNetwork {
22    /// The built-in default network, matching the legacy hardcoded bridge.
23    pub fn default_network() -> Self {
24        Self {
25            name: "default".to_string(),
26            bridge_name: "br-mvm".to_string(),
27            subnet: "172.16.0.0/24".to_string(),
28            gateway: "172.16.0.1".to_string(),
29            created_at: chrono::Utc::now().to_rfc3339(),
30        }
31    }
32
33    /// Create a new named network with an auto-assigned subnet.
34    ///
35    /// `slot` is a 1-based index used to derive a unique 172.16.X.0/24 subnet.
36    /// Slot 0 is reserved for the default network.
37    pub fn new(name: &str, slot: u8) -> Result<Self> {
38        validate_network_name(name)?;
39        if slot == 0 {
40            bail!("slot 0 is reserved for the default network");
41        }
42        Ok(Self {
43            name: name.to_string(),
44            bridge_name: format!("br-mvm-{name}"),
45            subnet: format!("172.16.{slot}.0/24"),
46            gateway: format!("172.16.{slot}.1"),
47            created_at: chrono::Utc::now().to_rfc3339(),
48        })
49    }
50
51    /// CIDR notation for the gateway (e.g. "172.16.0.1/24").
52    pub fn gateway_cidr(&self) -> String {
53        let prefix = self.subnet.split('/').nth(1).unwrap_or("24");
54        format!("{}/{prefix}", self.gateway)
55    }
56}
57
58/// Validate a network name: lowercase alphanumeric + hyphens, 1-63 chars.
59pub fn validate_network_name(name: &str) -> Result<()> {
60    crate::naming::validate_id(name, "network name")
61}
62
63/// Directory where network definitions are stored.
64pub fn networks_dir() -> String {
65    format!("{}/networks", crate::config::mvm_share_dir())
66}
67
68/// Path to a specific network definition file.
69pub fn network_path(name: &str) -> String {
70    format!("{}/{name}.json", networks_dir())
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76
77    #[test]
78    fn test_default_network() {
79        let net = DevNetwork::default_network();
80        assert_eq!(net.name, "default");
81        assert_eq!(net.bridge_name, "br-mvm");
82        assert_eq!(net.subnet, "172.16.0.0/24");
83        assert_eq!(net.gateway, "172.16.0.1");
84        assert!(!net.created_at.is_empty());
85    }
86
87    #[test]
88    fn test_new_network() {
89        let net = DevNetwork::new("isolated", 1).unwrap();
90        assert_eq!(net.name, "isolated");
91        assert_eq!(net.bridge_name, "br-mvm-isolated");
92        assert_eq!(net.subnet, "172.16.1.0/24");
93        assert_eq!(net.gateway, "172.16.1.1");
94    }
95
96    #[test]
97    fn test_new_network_slot_0_rejected() {
98        assert!(DevNetwork::new("bad", 0).is_err());
99    }
100
101    #[test]
102    fn test_gateway_cidr() {
103        let net = DevNetwork::default_network();
104        assert_eq!(net.gateway_cidr(), "172.16.0.1/24");
105
106        let net2 = DevNetwork::new("test", 5).unwrap();
107        assert_eq!(net2.gateway_cidr(), "172.16.5.1/24");
108    }
109
110    #[test]
111    fn test_serde_roundtrip() {
112        let net = DevNetwork::new("mynet", 3).unwrap();
113        let json = serde_json::to_string(&net).unwrap();
114        let parsed: DevNetwork = serde_json::from_str(&json).unwrap();
115        assert_eq!(net, parsed);
116    }
117
118    #[test]
119    fn test_validate_network_name() {
120        assert!(validate_network_name("default").is_ok());
121        assert!(validate_network_name("my-net-1").is_ok());
122        assert!(validate_network_name("").is_err());
123        assert!(validate_network_name("UPPER").is_err());
124        assert!(validate_network_name("-leading").is_err());
125    }
126
127    #[test]
128    fn test_network_path() {
129        let path = network_path("default");
130        assert!(path.ends_with("/networks/default.json"));
131    }
132}