Skip to main content

detsys_ids_client/storage/
json_file.rs

1use std::io::Write;
2use std::path::PathBuf;
3
4use crate::storage::{Storage, StoredProperties};
5use tokio::fs::OpenOptions;
6use tokio::io::AsyncReadExt;
7
8const XDG_PREFIX: &str = "systems.determinate.detsys-ids-client";
9const XDG_STORAGE_FILENAME: &str = "storage.json";
10const NOTES: &[&str] = &[
11    "The IDs in this file are randomly generated UUIDs.",
12    "Determinate Systems uses these IDs to know how many people use our software and how to focus our limited resources for research and development.",
13    "The data here contains no personally identifiable information.",
14    "You can delete this file at any time to create new IDs.",
15    "",
16    "See our privacy policy: https://determinate.systems/policies/privacy",
17    "See our docs on telemetry: https://dtr.mn/telemetry",
18];
19
20#[derive(thiserror::Error, Debug)]
21pub enum Error {
22    #[error("No HOME is available")]
23    NoHome,
24
25    #[error("The storage location has no parent directory")]
26    LocationHasNoParent,
27
28    #[error("Serializing / deserializing failure: {0}")]
29    Json(#[from] serde_json::Error),
30
31    #[error("Loading from storage failed when opening the file `{0}`: {1}")]
32    Open(PathBuf, std::io::Error),
33
34    #[error("Creating the storage file `{0}` failed: {1}")]
35    Create(PathBuf, std::io::Error),
36
37    #[error("Reading from storage at `{0}` failed: {1}")]
38    Read(PathBuf, std::io::Error),
39
40    #[error("Writing storage to `{0}` failed: {1}")]
41    Write(PathBuf, std::io::Error),
42
43    #[error(transparent)]
44    Persist(#[from] tempfile::PersistError),
45
46    #[error(transparent)]
47    Join(#[from] tokio::task::JoinError),
48}
49
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51struct WrappedStorage {
52    notes: Vec<String>,
53    body: StoredProperties,
54}
55
56pub struct JsonFile {
57    location: PathBuf,
58    directory: PathBuf,
59}
60
61impl JsonFile {
62    #[allow(dead_code)]
63    #[tracing::instrument]
64    pub fn new(location: PathBuf) -> Option<Self> {
65        Some(Self {
66            directory: location.parent()?.to_owned(),
67            location,
68        })
69    }
70
71    pub async fn try_default() -> Result<Self, Error> {
72        let xdg_dirs = xdg::BaseDirectories::with_prefix(XDG_PREFIX);
73
74        let file = xdg_dirs
75            .place_state_file(XDG_STORAGE_FILENAME)
76            .map_err(|e| {
77                match xdg_dirs
78                    .get_state_file(XDG_STORAGE_FILENAME)
79                    .ok_or(Error::NoHome)
80                {
81                    Ok(loc) => Error::Create(loc, e),
82                    Err(e) => e,
83                }
84            })?;
85
86        Self::new(file).ok_or(Error::LocationHasNoParent)
87    }
88}
89
90impl Storage for JsonFile {
91    type Error = Error;
92
93    #[tracing::instrument(skip(self))]
94    async fn load(&self) -> Result<Option<StoredProperties>, Error> {
95        let mut file = OpenOptions::new()
96            .read(true)
97            .open(&self.location)
98            .await
99            .map_err(|e| Error::Open(self.location.clone(), e))?;
100
101        let mut contents = vec![];
102        file.read_to_end(&mut contents)
103            .await
104            .map_err(|e| Error::Read(self.location.clone(), e))?;
105
106        let wrapped: WrappedStorage = serde_json::from_slice(&contents)?;
107
108        Ok(Some(wrapped.body))
109    }
110
111    #[tracing::instrument(skip(self, props))]
112    async fn store(&mut self, props: StoredProperties) -> Result<(), Error> {
113        let wrapped = WrappedStorage {
114            notes: NOTES.iter().map(|v| String::from(*v)).collect(),
115            body: props,
116        };
117        let json = serde_json::to_string_pretty(&wrapped)?;
118
119        let directory = self.directory.clone();
120        let location = self.location.clone();
121
122        tracing::trace!("Storing properties");
123        tokio::task::spawn_blocking(move || -> Result<(), Error> {
124            let mut tempfile = tempfile::NamedTempFile::new_in(&directory)
125                .map_err(|e| Error::Create(directory.clone(), e))?;
126
127            tempfile
128                .write_all(json.as_bytes())
129                .map_err(|e| Error::Write(tempfile.path().into(), e))?;
130
131            tempfile.persist(&location)?;
132
133            Ok(())
134        })
135        .await??;
136
137        tracing::trace!(location = ?self.location, "Storage persisted");
138
139        Ok(())
140    }
141}
142
143#[cfg(test)]
144mod test {
145    use crate::{
146        AnonymousDistinctId,
147        storage::{Storage, StoredProperties},
148    };
149
150    #[tokio::test]
151    async fn round_trips() {
152        let tempfile = tempfile::NamedTempFile::new().unwrap();
153
154        let mut store = super::JsonFile::new(tempfile.path().into()).unwrap();
155        let identity = StoredProperties {
156            anonymous_distinct_id: AnonymousDistinctId::default(),
157            device_id: "hi".to_string().into(),
158            ..Default::default()
159        };
160
161        store.store(identity.clone()).await.unwrap();
162
163        assert_eq!(identity, store.load().await.unwrap().unwrap());
164    }
165}