1use a3s_box_core::error::{BoxError, Result};
7use a3s_box_core::network::NetworkConfig;
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug)]
13pub struct NetworkStore {
14 path: PathBuf,
16}
17
18#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
20struct NetworksFile {
21 networks: HashMap<String, NetworkConfig>,
22}
23
24impl NetworkStore {
25 pub fn new(path: impl Into<PathBuf>) -> Self {
27 Self { path: path.into() }
28 }
29
30 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 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 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 pub fn save(&self, networks: &HashMap<String, NetworkConfig>) -> Result<()> {
71 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 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 pub fn get(&self, name: &str) -> Result<Option<NetworkConfig>> {
113 let networks = self.load()?;
114 Ok(networks.get(name).cloned())
115 }
116
117 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 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 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 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 #[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 pub fn list(&self) -> Result<Vec<NetworkConfig>> {
185 let networks = self.load()?;
186 Ok(networks.into_values().collect())
187 }
188
189 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 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 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 let mut net = store.get("mynet").unwrap().unwrap();
361 net.connect("box-1", "web").unwrap();
362 store.update(&net).unwrap();
363
364 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 let data = std::fs::read_to_string(store.path()).unwrap();
384 let _: serde_json::Value = serde_json::from_str(&data).unwrap();
385
386 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 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 assert!(store.load().unwrap().is_empty());
456 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}