burble_fs/
lib.rs

1//! Bluetooth LE file system storage backend.
2
3use std::io::{Cursor, Write};
4use std::path::{Path, PathBuf};
5use std::{fs, io};
6
7use tracing::{debug, error, warn};
8
9use burble::le::Addr;
10use burble::{gatt, smp};
11
12/// Security database stored in a file system directory.
13#[derive(Clone, Debug)]
14pub struct KeyStore(Dir);
15
16impl KeyStore {
17    const NAME: &'static str = "keys";
18
19    /// Creates or opens a security database store in the specified root
20    /// directory.
21    #[inline(always)]
22    #[must_use]
23    pub fn open(root: impl AsRef<Path>) -> Self {
24        Self(Dir::open(root, Self::NAME))
25    }
26
27    /// Creates or opens a security database store in the current user's local
28    /// data directory.
29    ///
30    /// # Panics
31    ///
32    /// Panics if it cannot determine the user directory.
33    #[inline(always)]
34    #[must_use]
35    pub fn per_user(app: impl AsRef<Path>) -> Self {
36        Self(Dir::per_user(app, Self::NAME))
37    }
38}
39
40impl burble::PeerStore for KeyStore {
41    type Value = smp::Keys;
42
43    #[inline(always)]
44    fn save(&self, peer: Addr, v: &Self::Value) -> bool {
45        self.0.save(peer, v)
46    }
47
48    #[inline(always)]
49    fn load(&self, peer: Addr) -> Option<Self::Value> {
50        self.0.load(peer)
51    }
52
53    #[inline(always)]
54    fn remove(&self, peer: Addr) {
55        self.0.remove(peer);
56    }
57
58    #[inline(always)]
59    fn clear(&self) {
60        self.0.clear();
61    }
62}
63
64/// GATT server database stored in a file system directory.
65#[derive(Clone, Debug)]
66pub struct GattServerStore(Dir);
67
68impl GattServerStore {
69    const NAME: &'static str = "gatts";
70
71    /// Creates or opens a GATT server database store in the specified root
72    /// directory.
73    #[inline(always)]
74    #[must_use]
75    pub fn open(root: impl AsRef<Path>) -> Self {
76        Self(Dir::open(root, Self::NAME))
77    }
78
79    /// Creates or opens a GATT server database store in the current user's
80    /// local data directory.
81    ///
82    /// # Panics
83    ///
84    /// Panics if it cannot determine the user directory.
85    #[inline(always)]
86    #[must_use]
87    pub fn per_user(app: impl AsRef<Path>) -> Self {
88        Self(Dir::per_user(app, Self::NAME))
89    }
90}
91
92impl burble::PeerStore for GattServerStore {
93    type Value = gatt::Cache;
94
95    #[inline(always)]
96    fn save(&self, peer: Addr, v: &Self::Value) -> bool {
97        self.0.save(peer, v)
98    }
99
100    #[inline(always)]
101    fn load(&self, peer: Addr) -> Option<Self::Value> {
102        self.0.load(peer)
103    }
104
105    #[inline(always)]
106    fn remove(&self, peer: Addr) {
107        self.0.remove(peer);
108    }
109
110    #[inline(always)]
111    fn clear(&self) {
112        self.0.clear();
113    }
114}
115
116/// Database in a file system directory.
117#[derive(Clone, Debug)]
118#[repr(transparent)]
119struct Dir(PathBuf);
120
121impl Dir {
122    const FILE_NAME_FMT: &'static str = "P-001122334455";
123
124    /// Creates or opens a database store in the specified root directory.
125    #[inline(always)]
126    #[must_use]
127    fn open(root: impl AsRef<Path>, name: impl AsRef<Path>) -> Self {
128        Self(root.as_ref().join(name))
129    }
130
131    /// Creates or opens a database store in the current user's local data
132    /// directory.
133    ///
134    /// # Panics
135    ///
136    /// Panics if it cannot determine the user directory.
137    #[must_use]
138    fn per_user(app: impl AsRef<Path>, name: impl AsRef<Path>) -> Self {
139        let dir = dirs::data_local_dir()
140            .expect("user directory not available")
141            .join(app.as_ref())
142            .join(name);
143        Self(dir)
144    }
145
146    /// Saves peer data to the file system.
147    fn save(&self, peer: Addr, v: &impl serde::ser::Serialize) -> bool {
148        let s = serde_json::to_string_pretty(v).expect("failed to serialize peer data");
149        if let Err(e) = fs::create_dir_all(&self.0) {
150            warn!(
151                "Failed to create database directory: {} ({e})",
152                self.0.display()
153            );
154        }
155        let path = self.path(peer);
156        // TODO: Make atomic?
157        match fs::File::create(&path)
158            .and_then(|mut f| f.write_all(s.as_bytes()).and_then(|_| f.sync_data()))
159        {
160            Ok(_) => {
161                debug!("Wrote: {}", path.display());
162                true
163            }
164            Err(e) => {
165                error!("Failed to write: {} ({e})", path.display());
166                false
167            }
168        }
169    }
170
171    /// Loads peer data from the file system.
172    fn load<T: serde::de::DeserializeOwned>(&self, peer: Addr) -> Option<T> {
173        let path = self.path(peer);
174        let s = match fs::read_to_string(&path) {
175            Ok(s) => s,
176            Err(e) if matches!(e.kind(), io::ErrorKind::NotFound) => return None,
177            Err(e) => {
178                error!("Failed to read: {} ({e})", path.display());
179                return None;
180            }
181        };
182        serde_json::from_str(&s)
183            .map_err(|e| {
184                error!("Invalid file contents: {} ({e})", path.display());
185                Err::<T, ()>(())
186            })
187            .ok()
188    }
189
190    /// Removes peer data from the file system.
191    fn remove(&self, peer: Addr) {
192        let path = self.path(peer);
193        match fs::remove_file(&path) {
194            Ok(_) => {}
195            Err(e) if matches!(e.kind(), io::ErrorKind::NotFound) => {}
196            Err(e) => error!("Failed to remove: {} ({e})", path.display()),
197        }
198    }
199
200    /// Removes all peer data from the file system.
201    fn clear(&self) {
202        match fs::remove_dir_all(&self.0) {
203            Ok(_) => {}
204            Err(e) if matches!(e.kind(), io::ErrorKind::NotFound) => {}
205            Err(e) => error!("Failed to remove: {} ({e})", self.0.display()),
206        }
207    }
208
209    /// Returns the key file path for the specified peer address.
210    fn path(&self, peer: Addr) -> PathBuf {
211        let (raw, typ) = match peer {
212            Addr::Public(ref raw) => (raw.as_le_bytes(), 'P'),
213            Addr::Random(ref raw) => (raw.as_le_bytes(), 'R'),
214        };
215        let mut buf = Cursor::new([0_u8; Self::FILE_NAME_FMT.len()]);
216        write!(
217            buf,
218            "{typ}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}",
219            raw[5], raw[4], raw[3], raw[2], raw[1], raw[0]
220        )
221        .expect("key file name overflow");
222        // SAFETY: `buf` contains a valid UTF-8 string
223        (self.0).join(unsafe { std::str::from_utf8_unchecked(buf.get_ref()) })
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use tempfile::Builder;
230
231    use burble::le::RawAddr;
232    use burble::PeerStore;
233
234    use super::*;
235
236    #[test]
237    fn save_load() {
238        const PEER: Addr =
239            Addr::Public(RawAddr::from_le_bytes([0x55, 0x44, 0x33, 0x22, 0x11, 0x00]));
240        let tmp = (Builder::new().prefix(concat!("burble-test-")).tempdir()).unwrap();
241        let db = KeyStore(Dir(tmp.path().to_path_buf()));
242        let keys = smp::Keys::test();
243        assert!(db.save(PEER, &keys));
244        assert!(tmp.path().join(Dir::FILE_NAME_FMT).exists());
245        assert_eq!(db.load(PEER).unwrap(), keys);
246    }
247}