Skip to main content

fips_endpoint/
recent_peers_file.rs

1use crate::{RecentPeers, RecentPeersError};
2use std::error::Error;
3use std::ffi::OsString;
4use std::fmt;
5use std::fs::{self, File, OpenOptions};
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9
10static TEMP_FILE_SEQUENCE: AtomicU64 = AtomicU64::new(0);
11
12/// A caller-scoped native file store for recently authenticated FIPS peers.
13///
14/// This type only handles persistence. The caller chooses the path and remains
15/// responsible for cache pruning, peer authorization, and deciding when to
16/// load or save the cache.
17#[derive(Debug, Clone)]
18pub struct RecentPeersFileStore {
19    path: PathBuf,
20    expected_local_npub: String,
21    expected_scope: String,
22}
23
24impl RecentPeersFileStore {
25    /// Bind a file path to one local identity and application scope.
26    pub fn new(
27        path: impl Into<PathBuf>,
28        expected_local_npub: impl Into<String>,
29        expected_scope: impl Into<String>,
30    ) -> Result<Self, RecentPeersFileError> {
31        let path = path.into();
32        let expected = RecentPeers::new(expected_local_npub, expected_scope).map_err(|source| {
33            RecentPeersFileError::Model {
34                operation: "initialize",
35                path: path.clone(),
36                source,
37            }
38        })?;
39
40        Ok(Self {
41            path,
42            expected_local_npub: expected.local_npub,
43            expected_scope: expected.scope,
44        })
45    }
46
47    /// Path supplied by the caller for this store.
48    pub fn path(&self) -> &Path {
49        &self.path
50    }
51
52    /// Local identity to which loaded and saved documents must be bound.
53    pub fn local_npub(&self) -> &str {
54        &self.expected_local_npub
55    }
56
57    /// Application scope to which loaded and saved documents must be bound.
58    pub fn scope(&self) -> &str {
59        &self.expected_scope
60    }
61
62    /// Load and strictly validate the cache.
63    ///
64    /// A missing file produces a new empty cache for the expected identity and
65    /// scope. Malformed or mismatched existing files are returned as errors.
66    pub fn load(&self) -> Result<RecentPeers, RecentPeersFileError> {
67        let json = match fs::read_to_string(&self.path) {
68            Ok(json) => json,
69            Err(source) if source.kind() == io::ErrorKind::NotFound => {
70                return RecentPeers::new(
71                    self.expected_local_npub.clone(),
72                    self.expected_scope.clone(),
73                )
74                .map_err(|source| RecentPeersFileError::Model {
75                    operation: "create missing cache",
76                    path: self.path.clone(),
77                    source,
78                });
79            }
80            Err(source) => {
81                return Err(RecentPeersFileError::Io {
82                    operation: "read",
83                    path: self.path.clone(),
84                    source,
85                });
86            }
87        };
88
89        RecentPeers::from_json(&json, &self.expected_local_npub, &self.expected_scope).map_err(
90            |source| RecentPeersFileError::Model {
91                operation: "decode",
92                path: self.path.clone(),
93                source,
94            },
95        )
96    }
97
98    /// Strictly validate and replace the cache using a same-directory
99    /// temporary file.
100    pub fn save(&self, recent_peers: &RecentPeers) -> Result<(), RecentPeersFileError> {
101        let json = recent_peers
102            .to_json_pretty()
103            .map_err(|source| RecentPeersFileError::Model {
104                operation: "encode",
105                path: self.path.clone(),
106                source,
107            })?;
108
109        // Validate the store binding as well as the document itself. This
110        // prevents accidentally writing another identity or app scope through
111        // a store that points at this cache file.
112        RecentPeers::from_json(&json, &self.expected_local_npub, &self.expected_scope).map_err(
113            |source| RecentPeersFileError::Model {
114                operation: "validate store binding",
115                path: self.path.clone(),
116                source,
117            },
118        )?;
119
120        let parent = self
121            .path
122            .parent()
123            .filter(|parent| !parent.as_os_str().is_empty())
124            .unwrap_or_else(|| Path::new("."));
125        fs::create_dir_all(parent).map_err(|source| RecentPeersFileError::Io {
126            operation: "create parent directory",
127            path: parent.to_path_buf(),
128            source,
129        })?;
130
131        let (temp_path, mut temp_file) =
132            create_temp_file(parent, &self.path).map_err(|source| RecentPeersFileError::Io {
133                operation: "create temporary file",
134                path: self.path.clone(),
135                source,
136            })?;
137        let mut cleanup = TempPath::new(temp_path.clone());
138
139        set_private_permissions(&temp_file).map_err(|source| RecentPeersFileError::Io {
140            operation: "set temporary file permissions",
141            path: temp_path.clone(),
142            source,
143        })?;
144        temp_file
145            .write_all(json.as_bytes())
146            .and_then(|()| temp_file.write_all(b"\n"))
147            .map_err(|source| RecentPeersFileError::Io {
148                operation: "write temporary file",
149                path: temp_path.clone(),
150                source,
151            })?;
152        temp_file
153            .sync_all()
154            .map_err(|source| RecentPeersFileError::Io {
155                operation: "sync temporary file",
156                path: temp_path.clone(),
157                source,
158            })?;
159        drop(temp_file);
160
161        replace_file(&temp_path, &self.path).map_err(|source| RecentPeersFileError::Io {
162            operation: "replace cache file",
163            path: self.path.clone(),
164            source,
165        })?;
166        cleanup.keep();
167        Ok(())
168    }
169}
170
171/// Error loading or saving a [`RecentPeers`] file.
172#[derive(Debug)]
173pub enum RecentPeersFileError {
174    /// Filesystem operation failed.
175    Io {
176        operation: &'static str,
177        path: PathBuf,
178        source: io::Error,
179    },
180    /// The document or expected identity/scope failed model validation.
181    Model {
182        operation: &'static str,
183        path: PathBuf,
184        source: RecentPeersError,
185    },
186}
187
188impl fmt::Display for RecentPeersFileError {
189    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190        match self {
191            Self::Io {
192                operation,
193                path,
194                source,
195            } => write!(
196                formatter,
197                "failed to {operation} recent-peers file '{}': {source}",
198                path.display()
199            ),
200            Self::Model {
201                operation,
202                path,
203                source,
204            } => write!(
205                formatter,
206                "failed to {operation} recent-peers file '{}': {source}",
207                path.display()
208            ),
209        }
210    }
211}
212
213impl Error for RecentPeersFileError {
214    fn source(&self) -> Option<&(dyn Error + 'static)> {
215        match self {
216            Self::Io { source, .. } => Some(source),
217            Self::Model { source, .. } => Some(source),
218        }
219    }
220}
221
222fn create_temp_file(parent: &Path, target: &Path) -> io::Result<(PathBuf, File)> {
223    let target_name = target.file_name().ok_or_else(|| {
224        io::Error::new(
225            io::ErrorKind::InvalidInput,
226            "recent-peers path must name a file",
227        )
228    })?;
229
230    for _ in 0..32 {
231        let sequence = TEMP_FILE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
232        let mut temp_name = OsString::from(".");
233        temp_name.push(target_name);
234        temp_name.push(format!(".{}.{}.tmp", std::process::id(), sequence));
235        let temp_path = parent.join(temp_name);
236
237        let mut options = OpenOptions::new();
238        options.write(true).create_new(true);
239        #[cfg(unix)]
240        {
241            use std::os::unix::fs::OpenOptionsExt;
242            options.mode(0o600);
243        }
244        match options.open(&temp_path) {
245            Ok(file) => return Ok((temp_path, file)),
246            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => continue,
247            Err(source) => return Err(source),
248        }
249    }
250
251    Err(io::Error::new(
252        io::ErrorKind::AlreadyExists,
253        "could not allocate a unique same-directory temporary file",
254    ))
255}
256
257#[cfg(unix)]
258fn set_private_permissions(file: &File) -> io::Result<()> {
259    use std::os::unix::fs::PermissionsExt;
260    file.set_permissions(fs::Permissions::from_mode(0o600))
261}
262
263#[cfg(not(unix))]
264fn set_private_permissions(_file: &File) -> io::Result<()> {
265    Ok(())
266}
267
268#[cfg(not(windows))]
269fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
270    fs::rename(temp_path, target_path)
271}
272
273#[cfg(windows)]
274fn replace_file(temp_path: &Path, target_path: &Path) -> io::Result<()> {
275    if target_path.try_exists()? {
276        fs::remove_file(target_path)?;
277    }
278    fs::rename(temp_path, target_path)
279}
280
281struct TempPath {
282    path: PathBuf,
283    keep: bool,
284}
285
286impl TempPath {
287    fn new(path: PathBuf) -> Self {
288        Self { path, keep: false }
289    }
290
291    fn keep(&mut self) {
292        self.keep = true;
293    }
294}
295
296impl Drop for TempPath {
297    fn drop(&mut self) {
298        if !self.keep {
299            let _ = fs::remove_file(&self.path);
300        }
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307    use crate::{Identity, RecentPeer, RecentPeerEndpoint, RecentPeerTransport};
308    use std::time::{SystemTime, UNIX_EPOCH};
309
310    static TEST_DIR_SEQUENCE: AtomicU64 = AtomicU64::new(0);
311
312    #[test]
313    fn missing_file_returns_empty_cache_for_expected_context() {
314        let scratch = ScratchDir::new();
315        let path = scratch.path().join("nested/recent-peers.json");
316        let local_npub = Identity::generate().npub();
317        let store = RecentPeersFileStore::new(&path, &local_npub, "iris-drive").unwrap();
318
319        let loaded = store.load().unwrap();
320
321        assert_eq!(loaded.local_npub(), local_npub);
322        assert_eq!(loaded.scope(), "iris-drive");
323        assert!(loaded.peers.is_empty());
324        assert!(!path.exists());
325    }
326
327    #[test]
328    fn round_trip_creates_parent_and_replaces_file() {
329        let scratch = ScratchDir::new();
330        let path = scratch.path().join("nested/recent-peers.json");
331        let local_npub = Identity::generate().npub();
332        let remote_npub = Identity::generate().npub();
333        let store = RecentPeersFileStore::new(&path, &local_npub, "nostr-vpn").unwrap();
334        let mut expected = RecentPeers::new(&local_npub, "nostr-vpn").unwrap();
335        expected.peers.insert(
336            remote_npub,
337            RecentPeer {
338                last_authenticated_at_ms: 123,
339                endpoints: vec![RecentPeerEndpoint {
340                    transport: RecentPeerTransport::Udp,
341                    addr: "198.51.100.42:2121".to_string(),
342                    last_authenticated_at_ms: 123,
343                }],
344            },
345        );
346
347        store.save(&expected).unwrap();
348        assert_eq!(store.load().unwrap(), expected);
349        assert!(path.parent().unwrap().is_dir());
350
351        expected
352            .peers
353            .values_mut()
354            .next()
355            .unwrap()
356            .last_authenticated_at_ms = 456;
357        store.save(&expected).unwrap();
358        assert_eq!(store.load().unwrap(), expected);
359
360        #[cfg(unix)]
361        {
362            use std::os::unix::fs::PermissionsExt;
363            assert_eq!(
364                fs::metadata(&path).unwrap().permissions().mode() & 0o777,
365                0o600
366            );
367        }
368    }
369
370    #[test]
371    fn load_rejects_wrong_scope() {
372        let scratch = ScratchDir::new();
373        let path = scratch.path().join("recent-peers.json");
374        let local_npub = Identity::generate().npub();
375        let original = RecentPeersFileStore::new(&path, &local_npub, "iris-chat").unwrap();
376        original
377            .save(&RecentPeers::new(&local_npub, "iris-chat").unwrap())
378            .unwrap();
379        let wrong_scope = RecentPeersFileStore::new(&path, &local_npub, "iris-drive").unwrap();
380
381        let error = wrong_scope.load().unwrap_err();
382
383        assert!(matches!(
384            error,
385            RecentPeersFileError::Model {
386                source: RecentPeersError::ScopeMismatch { .. },
387                ..
388            }
389        ));
390    }
391
392    struct ScratchDir {
393        path: PathBuf,
394    }
395
396    impl ScratchDir {
397        fn new() -> Self {
398            let sequence = TEST_DIR_SEQUENCE.fetch_add(1, Ordering::Relaxed);
399            let timestamp = SystemTime::now()
400                .duration_since(UNIX_EPOCH)
401                .expect("system clock should be after the Unix epoch")
402                .as_nanos();
403            let path = std::env::temp_dir().join(format!(
404                "fips-endpoint-recent-peers-{}-{timestamp}-{sequence}",
405                std::process::id()
406            ));
407            fs::create_dir_all(&path).unwrap();
408            Self { path }
409        }
410
411        fn path(&self) -> &Path {
412            &self.path
413        }
414    }
415
416    impl Drop for ScratchDir {
417        fn drop(&mut self) {
418            let _ = fs::remove_dir_all(&self.path);
419        }
420    }
421}