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#[derive(Debug, Clone)]
18pub struct RecentPeersFileStore {
19 path: PathBuf,
20 expected_local_npub: String,
21 expected_scope: String,
22}
23
24impl RecentPeersFileStore {
25 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 pub fn path(&self) -> &Path {
49 &self.path
50 }
51
52 pub fn local_npub(&self) -> &str {
54 &self.expected_local_npub
55 }
56
57 pub fn scope(&self) -> &str {
59 &self.expected_scope
60 }
61
62 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 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 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#[derive(Debug)]
173pub enum RecentPeersFileError {
174 Io {
176 operation: &'static str,
177 path: PathBuf,
178 source: io::Error,
179 },
180 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}