ddns_a/state/
file.rs

1//! File-based state persistence implementation.
2
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::network::AdapterSnapshot;
9
10use super::{LoadResult, StateError, StateStore};
11
12/// Current state file format version.
13///
14/// Increment this when making breaking changes to the format.
15const STATE_FILE_VERSION: u32 = 1;
16
17/// On-disk state file format.
18///
19/// Uses JSON for readability and debugging. The `version` field allows
20/// future format migrations, though the current policy is to treat
21/// incompatible versions as corrupted (no backward compatibility).
22#[derive(Debug, Serialize, Deserialize)]
23struct StateFile {
24    /// Format version for future compatibility.
25    version: u32,
26
27    /// Unix timestamp when the state was saved.
28    /// For debugging purposes only; not used in logic.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    saved_at: Option<String>,
31
32    /// The saved adapter snapshots.
33    snapshots: Vec<AdapterSnapshot>,
34}
35
36impl StateFile {
37    /// Creates a new state file with the given snapshots.
38    fn new(snapshots: &[AdapterSnapshot]) -> Self {
39        Self {
40            version: STATE_FILE_VERSION,
41            saved_at: Some(unix_timestamp_now()),
42            snapshots: snapshots.to_vec(),
43        }
44    }
45}
46
47/// Returns the current Unix timestamp as a string.
48///
49/// Uses Unix timestamp for simplicity and unambiguity in debugging.
50fn unix_timestamp_now() -> String {
51    use std::time::SystemTime;
52
53    let now = SystemTime::now();
54    let duration = now
55        .duration_since(SystemTime::UNIX_EPOCH)
56        .unwrap_or_default();
57
58    format!("{}", duration.as_secs())
59}
60
61/// File-based implementation of [`StateStore`].
62///
63/// Stores adapter snapshots as JSON files with atomic write semantics.
64///
65/// # Atomic Writes
66///
67/// Uses write-to-temp-then-rename pattern to prevent corruption:
68/// 1. Write to `{path}.tmp`
69/// 2. Rename `{path}.tmp` to `{path}`
70///
71/// This ensures the file is either fully written or not written at all.
72#[derive(Debug, Clone)]
73pub struct FileStateStore {
74    path: PathBuf,
75}
76
77impl FileStateStore {
78    /// Creates a new file-based state store at the given path.
79    #[must_use]
80    pub fn new(path: impl Into<PathBuf>) -> Self {
81        Self { path: path.into() }
82    }
83
84    /// Returns the path to the state file.
85    #[must_use]
86    pub fn path(&self) -> &Path {
87        &self.path
88    }
89
90    /// Performs the blocking save operation.
91    ///
92    /// Separated out so it can be wrapped in `spawn_blocking`.
93    fn save_blocking(path: &Path, state: &StateFile) -> Result<(), StateError> {
94        let content = serde_json::to_string_pretty(state).map_err(StateError::Serialize)?;
95
96        // Create parent directory if it doesn't exist
97        if let Some(parent) = path.parent() {
98            if !parent.as_os_str().is_empty() {
99                std::fs::create_dir_all(parent).map_err(StateError::Write)?;
100            }
101        }
102
103        // Append .tmp instead of replacing extension to avoid conflicts
104        // (e.g., state.json -> state.json.tmp, not state.tmp)
105        let temp_path = PathBuf::from(format!("{}.tmp", path.display()));
106
107        // Write to temp file
108        std::fs::write(&temp_path, content).map_err(StateError::Write)?;
109
110        // Atomic rename (on most filesystems)
111        std::fs::rename(&temp_path, path).map_err(StateError::Write)?;
112
113        Ok(())
114    }
115}
116
117impl StateStore for FileStateStore {
118    fn load(&self) -> LoadResult {
119        let content = match std::fs::read_to_string(&self.path) {
120            Ok(c) => c,
121            Err(e) if e.kind() == ErrorKind::NotFound => return LoadResult::NotFound,
122            Err(e) => {
123                return LoadResult::Corrupted {
124                    reason: format!("Failed to read file: {e}"),
125                };
126            }
127        };
128
129        match serde_json::from_str::<StateFile>(&content) {
130            Ok(state) => {
131                // Check version compatibility
132                if state.version != STATE_FILE_VERSION {
133                    return LoadResult::Corrupted {
134                        reason: format!(
135                            "Incompatible version: expected {STATE_FILE_VERSION}, got {}",
136                            state.version
137                        ),
138                    };
139                }
140                LoadResult::Loaded(state.snapshots)
141            }
142            Err(e) => LoadResult::Corrupted {
143                reason: format!("Invalid JSON: {e}"),
144            },
145        }
146    }
147
148    async fn save(&self, snapshots: &[AdapterSnapshot]) -> Result<(), StateError> {
149        let path = self.path.clone();
150        let state = StateFile::new(snapshots);
151
152        // Use spawn_blocking to avoid blocking the async runtime
153        tokio::task::spawn_blocking(move || Self::save_blocking(&path, &state))
154            .await
155            .expect("spawn_blocking task panicked")
156    }
157}