Skip to main content

a3s_box_runtime/network/
store.rs

1//! Persistent storage for network configurations.
2//!
3//! Networks are stored as JSON in `~/.a3s/networks.json` with atomic writes
4//! (write to tmp file, then rename) to prevent corruption.
5
6use a3s_box_core::error::{BoxError, Result};
7use a3s_box_core::network::NetworkConfig;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11/// Persistent store for network configurations.
12#[derive(Debug)]
13pub struct NetworkStore {
14    /// Path to the JSON file.
15    path: PathBuf,
16}
17
18/// Serializable wrapper for the networks file.
19#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
20struct NetworksFile {
21    networks: HashMap<String, NetworkConfig>,
22}
23
24impl NetworkStore {
25    /// Create a new store at the given path.
26    pub fn new(path: impl Into<PathBuf>) -> Self {
27        Self { path: path.into() }
28    }
29
30    /// Create a store at the default location (`~/.a3s/networks.json`).
31    pub fn default_path() -> Result<Self> {
32        let home = a3s_box_core::dirs_home();
33        Ok(Self::new(home.join("networks.json")))
34    }
35
36    /// Load all networks from disk.
37    pub fn load(&self) -> Result<HashMap<String, NetworkConfig>> {
38        if !self.path.exists() {
39            return Ok(HashMap::new());
40        }
41
42        let data = std::fs::read_to_string(&self.path).map_err(|e| {
43            BoxError::NetworkError(format!(
44                "failed to read networks file {}: {}",
45                self.path.display(),
46                e
47            ))
48        })?;
49
50        // A corrupt/old-schema networks file must not brick the runtime: quarantine
51        // it and start from an empty set (create repopulates) rather than failing
52        // every network operation. Mirrors the boxes.json hardening.
53        let file: NetworksFile = match serde_json::from_str(&data) {
54            Ok(f) => f,
55            Err(e) => {
56                let preserved = crate::store_io::quarantine_label(&self.path);
57                tracing::warn!(
58                    "networks file {} is corrupt ({e}); preserved a copy at {preserved} \
59                     and started from an empty network set",
60                    self.path.display(),
61                );
62                return Ok(HashMap::new());
63            }
64        };
65
66        Ok(file.networks)
67    }
68
69    /// Save all networks to disk (atomic write).
70    pub fn save(&self, networks: &HashMap<String, NetworkConfig>) -> Result<()> {
71        // Ensure parent directory exists
72        if let Some(parent) = self.path.parent() {
73            std::fs::create_dir_all(parent).map_err(|e| {
74                BoxError::NetworkError(format!(
75                    "failed to create directory {}: {}",
76                    parent.display(),
77                    e
78                ))
79            })?;
80        }
81
82        let file = NetworksFile {
83            networks: networks.clone(),
84        };
85
86        let json = serde_json::to_string_pretty(&file)
87            .map_err(|e| BoxError::NetworkError(format!("failed to serialize networks: {}", e)))?;
88
89        // Atomic write: write to tmp, then rename
90        let tmp_path = self.path.with_extension("json.tmp");
91        std::fs::write(&tmp_path, &json).map_err(|e| {
92            BoxError::NetworkError(format!(
93                "failed to write tmp file {}: {}",
94                tmp_path.display(),
95                e
96            ))
97        })?;
98
99        std::fs::rename(&tmp_path, &self.path).map_err(|e| {
100            BoxError::NetworkError(format!(
101                "failed to rename {} → {}: {}",
102                tmp_path.display(),
103                self.path.display(),
104                e
105            ))
106        })?;
107
108        Ok(())
109    }
110
111    /// Get a single network by name.
112    pub fn get(&self, name: &str) -> Result<Option<NetworkConfig>> {
113        let networks = self.load()?;
114        Ok(networks.get(name).cloned())
115    }
116
117    /// Run `f` against the full networks map under the **cross-process write
118    /// lock** (load-fresh → mutate → save). Use this to make a
119    /// get-modify-update sequence atomic — e.g. allocate-an-IP-then-connect —
120    /// so concurrent boots cannot assign duplicate IPs/MACs or silently lose
121    /// each other's endpoints. The map is saved only if `f` returns `Ok`.
122    ///
123    /// The lock is held across the whole load/mutate/save; `save` is itself
124    /// lock-free, so there is no re-entrant `flock` (which would self-deadlock).
125    pub fn with_write_lock<F, R, E>(&self, f: F) -> std::result::Result<R, E>
126    where
127        F: FnOnce(&mut HashMap<String, NetworkConfig>) -> std::result::Result<R, E>,
128        E: From<BoxError>,
129    {
130        let _lock = crate::file_lock::FileLock::acquire(&self.path).map_err(|e| {
131            E::from(BoxError::NetworkError(format!(
132                "failed to lock networks file {}: {e}",
133                self.path.display()
134            )))
135        })?;
136        let mut networks = self.load().map_err(E::from)?;
137        let r = f(&mut networks)?;
138        self.save(&networks).map_err(E::from)?;
139        Ok(r)
140    }
141
142    /// Create a new network. Returns error if name already exists.
143    pub fn create(&self, config: NetworkConfig) -> Result<()> {
144        self.with_write_lock(|networks| {
145            if networks.contains_key(&config.name) {
146                return Err(BoxError::NetworkError(format!(
147                    "network '{}' already exists",
148                    config.name
149                )));
150            }
151            networks.insert(config.name.clone(), config);
152            Ok(())
153        })
154    }
155
156    /// Remove a network by name. Returns the removed config or error if not found.
157    pub fn remove(&self, name: &str) -> Result<NetworkConfig> {
158        self.with_write_lock(|networks| {
159            let config = networks
160                .remove(name)
161                .ok_or_else(|| BoxError::NetworkError(format!("network '{}' not found", name)))?;
162
163            if !config.endpoints.is_empty() {
164                // Returning Err skips the save, so the in-memory removal is not
165                // persisted — the network stays intact.
166                return Err(BoxError::NetworkError(format!(
167                    "network '{}' has {} connected endpoint(s); disconnect them first or use --force",
168                    name,
169                    config.endpoints.len()
170                )));
171            }
172            // Remove stale switch sockets while the same cross-process lock
173            // still fences recreation of this network name. Cleaning after
174            // releasing the lock could delete a newly-created network's peers.
175            #[cfg(unix)]
176            if let Some(home) = self.path.parent() {
177                super::cleanup_bridge_socket_dir(home, name);
178            }
179            Ok(config)
180        })
181    }
182
183    /// List all network names.
184    pub fn list(&self) -> Result<Vec<NetworkConfig>> {
185        let networks = self.load()?;
186        Ok(networks.into_values().collect())
187    }
188
189    /// Update a network in-place (used for connect/disconnect).
190    ///
191    /// Prefer [`with_write_lock`](Self::with_write_lock) for a
192    /// get-modify-update sequence: `update` re-loads under the lock, but a
193    /// caller that read the network *before* calling `update` decided its
194    /// mutation on a possibly-stale snapshot.
195    pub fn update(&self, config: &NetworkConfig) -> Result<()> {
196        self.with_write_lock(|networks| {
197            if !networks.contains_key(&config.name) {
198                return Err(BoxError::NetworkError(format!(
199                    "network '{}' not found",
200                    config.name
201                )));
202            }
203            networks.insert(config.name.clone(), config.clone());
204            Ok(())
205        })
206    }
207
208    /// Get the store file path.
209    pub fn path(&self) -> &Path {
210        &self.path
211    }
212}
213
214impl a3s_box_core::traits::NetworkStoreBackend for NetworkStore {
215    fn get(&self, name: &str) -> Result<Option<NetworkConfig>> {
216        self.get(name)
217    }
218
219    fn create(&self, config: NetworkConfig) -> Result<()> {
220        self.create(config)
221    }
222
223    fn remove(&self, name: &str) -> Result<NetworkConfig> {
224        self.remove(name)
225    }
226
227    fn list(&self) -> Result<Vec<NetworkConfig>> {
228        self.list()
229    }
230
231    fn update(&self, config: &NetworkConfig) -> Result<()> {
232        self.update(config)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    fn temp_store() -> (tempfile::TempDir, NetworkStore) {
241        let dir = tempfile::tempdir().unwrap();
242        let store = NetworkStore::new(dir.path().join("networks.json"));
243        (dir, store)
244    }
245
246    #[test]
247    fn test_load_empty() {
248        let (_dir, store) = temp_store();
249        let networks = store.load().unwrap();
250        assert!(networks.is_empty());
251    }
252
253    #[test]
254    fn test_create_and_load() {
255        let (_dir, store) = temp_store();
256        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
257        store.create(net).unwrap();
258
259        let networks = store.load().unwrap();
260        assert_eq!(networks.len(), 1);
261        assert!(networks.contains_key("mynet"));
262    }
263
264    #[test]
265    fn test_create_duplicate() {
266        let (_dir, store) = temp_store();
267        let net1 = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
268        let net2 = NetworkConfig::new("mynet", "10.89.0.0/24").unwrap();
269
270        store.create(net1).unwrap();
271        assert!(store.create(net2).is_err());
272    }
273
274    #[test]
275    fn test_get_existing() {
276        let (_dir, store) = temp_store();
277        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
278        store.create(net).unwrap();
279
280        let found = store.get("mynet").unwrap();
281        assert!(found.is_some());
282        assert_eq!(found.unwrap().name, "mynet");
283    }
284
285    #[test]
286    fn test_get_nonexistent() {
287        let (_dir, store) = temp_store();
288        let found = store.get("nope").unwrap();
289        assert!(found.is_none());
290    }
291
292    #[test]
293    fn test_remove() {
294        let (_dir, store) = temp_store();
295        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
296        store.create(net).unwrap();
297
298        let removed = store.remove("mynet").unwrap();
299        assert_eq!(removed.name, "mynet");
300
301        let networks = store.load().unwrap();
302        assert!(networks.is_empty());
303    }
304
305    #[cfg(unix)]
306    #[test]
307    fn remove_cleans_the_network_peer_switch_directory() {
308        let (dir, store) = temp_store();
309        let network_name = "switch-cleanup";
310        store
311            .create(NetworkConfig::new(network_name, "10.88.0.0/24").unwrap())
312            .unwrap();
313        let switch_dir = crate::network::bridge_socket_dir(dir.path(), network_name);
314        std::fs::create_dir_all(&switch_dir).unwrap();
315        std::fs::write(switch_dir.join("stale.sock"), b"stale").unwrap();
316
317        store.remove(network_name).unwrap();
318
319        assert!(!switch_dir.exists());
320    }
321
322    #[test]
323    fn test_remove_nonexistent() {
324        let (_dir, store) = temp_store();
325        assert!(store.remove("nope").is_err());
326    }
327
328    #[test]
329    fn test_remove_with_endpoints() {
330        let (_dir, store) = temp_store();
331        let mut net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
332        net.connect("box-1", "web").unwrap();
333        store.create(net).unwrap();
334
335        // Should fail because endpoints are connected
336        assert!(store.remove("mynet").is_err());
337    }
338
339    #[test]
340    fn test_list() {
341        let (_dir, store) = temp_store();
342        store
343            .create(NetworkConfig::new("net1", "10.88.0.0/24").unwrap())
344            .unwrap();
345        store
346            .create(NetworkConfig::new("net2", "10.89.0.0/24").unwrap())
347            .unwrap();
348
349        let list = store.list().unwrap();
350        assert_eq!(list.len(), 2);
351    }
352
353    #[test]
354    fn test_update() {
355        let (_dir, store) = temp_store();
356        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
357        store.create(net).unwrap();
358
359        // Connect a box
360        let mut net = store.get("mynet").unwrap().unwrap();
361        net.connect("box-1", "web").unwrap();
362        store.update(&net).unwrap();
363
364        // Verify persistence
365        let loaded = store.get("mynet").unwrap().unwrap();
366        assert_eq!(loaded.endpoints.len(), 1);
367    }
368
369    #[test]
370    fn test_update_nonexistent() {
371        let (_dir, store) = temp_store();
372        let net = NetworkConfig::new("nope", "10.88.0.0/24").unwrap();
373        assert!(store.update(&net).is_err());
374    }
375
376    #[test]
377    fn test_atomic_write() {
378        let (_dir, store) = temp_store();
379        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
380        store.create(net).unwrap();
381
382        // Verify the file exists and is valid JSON
383        let data = std::fs::read_to_string(store.path()).unwrap();
384        let _: serde_json::Value = serde_json::from_str(&data).unwrap();
385
386        // Verify no tmp file left behind
387        let tmp = store.path().with_extension("json.tmp");
388        assert!(!tmp.exists());
389    }
390
391    #[test]
392    fn test_creates_parent_directory() {
393        let dir = tempfile::tempdir().unwrap();
394        let store = NetworkStore::new(dir.path().join("subdir").join("networks.json"));
395
396        let net = NetworkConfig::new("mynet", "10.88.0.0/24").unwrap();
397        store.create(net).unwrap();
398
399        assert!(store.path().exists());
400    }
401
402    #[test]
403    fn concurrent_connects_allocate_distinct_ips() {
404        use std::collections::HashSet;
405        use std::sync::Arc;
406
407        let dir = tempfile::tempdir().unwrap();
408        let store = Arc::new(NetworkStore::new(dir.path().join("networks.json")));
409        store
410            .create(NetworkConfig::new("dev", "10.88.0.0/24").unwrap())
411            .unwrap();
412
413        // Many threads connect concurrently. with_write_lock must serialize the
414        // load → allocate → save so every box gets a distinct IP and no endpoint
415        // is lost — the bug allocated duplicate IPs and dropped endpoints.
416        let handles: Vec<_> = (0..16)
417            .map(|i| {
418                let store = Arc::clone(&store);
419                std::thread::spawn(move || {
420                    store
421                        .with_write_lock(|nets| {
422                            nets.get_mut("dev")
423                                .unwrap()
424                                .connect(&format!("box-{i}"), &format!("name-{i}"))
425                                .map_err(BoxError::NetworkError)
426                        })
427                        .unwrap()
428                        .ip_address
429                })
430            })
431            .collect();
432
433        let ips: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
434        let unique: HashSet<_> = ips.iter().collect();
435        assert_eq!(
436            unique.len(),
437            ips.len(),
438            "concurrent connects must allocate distinct IPs (got {ips:?})"
439        );
440        assert_eq!(
441            store.get("dev").unwrap().unwrap().endpoints.len(),
442            16,
443            "every concurrent endpoint must be persisted (no lost writes)"
444        );
445    }
446
447    #[test]
448    fn corrupt_networks_file_is_quarantined_not_fatal() {
449        let dir = tempfile::tempdir().unwrap();
450        let path = dir.path().join("networks.json");
451        std::fs::write(&path, "{ not valid json").unwrap();
452        let store = NetworkStore::new(path.clone());
453
454        // load() must succeed (empty) instead of erroring every network op.
455        assert!(store.load().unwrap().is_empty());
456        // The corrupt file is preserved as a timestamped sibling, not lost.
457        let quarantined = std::fs::read_dir(dir.path())
458            .unwrap()
459            .filter_map(|e| e.ok())
460            .any(|e| {
461                e.file_name()
462                    .to_string_lossy()
463                    .contains("networks.json.corrupt-")
464            });
465        assert!(quarantined, "corrupt networks.json must be quarantined");
466    }
467}