Skip to main content

dbx_tools_auth/storage/
file.rs

1use std::{
2    collections::HashMap,
3    fs::OpenOptions,
4    path::{Path, PathBuf},
5    time::{Duration, Instant},
6};
7
8use async_trait::async_trait;
9use fs4::fs_std::FileExt;
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use tokio::io::AsyncWriteExt;
13
14use super::{CredentialStore, FileLayout, StorageLock};
15use crate::{Error, Result, Token};
16
17const TOKEN_CACHE_VERSION: u8 = 1;
18
19#[derive(Serialize, Deserialize)]
20struct TokenCache {
21    version: u8,
22    #[serde(default)]
23    tokens: HashMap<String, serde_json::Value>,
24}
25
26impl Default for TokenCache {
27    fn default() -> Self {
28        Self {
29            version: TOKEN_CACHE_VERSION,
30            tokens: HashMap::new(),
31        }
32    }
33}
34
35pub struct FileStore {
36    root: PathBuf,
37    token_cache: PathBuf,
38    layout: FileLayout,
39}
40
41impl FileStore {
42    pub fn new(root: PathBuf) -> Result<Self> {
43        Self::with_layout(root, FileLayout::Single)
44    }
45
46    pub fn with_layout(root: PathBuf, layout: FileLayout) -> Result<Self> {
47        std::fs::create_dir_all(&root)?;
48        set_private_directory(&root)?;
49        Ok(Self {
50            token_cache: root.join("token-cache.json"),
51            root,
52            layout,
53        })
54    }
55
56    fn credential_store(&self, key: &str) -> Result<Self> {
57        Self::new(self.root.join(key_hash(key)))
58    }
59
60    fn cache_lock_path(&self) -> PathBuf {
61        self.root.join("token-cache.lock")
62    }
63
64    async fn acquire_cache_lock(&self) -> Result<FileLock> {
65        acquire_lock(
66            self.cache_lock_path(),
67            "token-cache.json".to_owned(),
68            Duration::from_secs(30),
69        )
70        .await
71    }
72
73    async fn read_cache(&self) -> Result<TokenCache> {
74        match tokio::fs::read(&self.token_cache).await {
75            Ok(raw) => {
76                let cache: TokenCache = serde_json::from_slice(&raw)?;
77                if cache.version != TOKEN_CACHE_VERSION {
78                    return Err(Error::Storage(format!(
79                        "token cache needs version {TOKEN_CACHE_VERSION}, got {}",
80                        cache.version
81                    )));
82                }
83                Ok(cache)
84            }
85            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(TokenCache::default()),
86            Err(error) => Err(error.into()),
87        }
88    }
89
90    async fn write_cache(&self, cache: &TokenCache) -> Result<()> {
91        let temporary = self
92            .root
93            .join(format!(".token-cache-{}.tmp", uuid::Uuid::new_v4()));
94        let raw = serde_json::to_vec_pretty(cache)?;
95        let mut options = tokio::fs::OpenOptions::new();
96        options.create_new(true).write(true);
97        #[cfg(unix)]
98        options.mode(0o600);
99        let mut file = options.open(&temporary).await?;
100        file.write_all(&raw).await?;
101        file.sync_all().await?;
102        drop(file);
103        set_private_file(&temporary)?;
104        tokio::fs::rename(&temporary, &self.token_cache).await?;
105        Ok(())
106    }
107}
108
109async fn acquire_lock(path: PathBuf, name: String, timeout: Duration) -> Result<FileLock> {
110    if let Some(parent) = path.parent() {
111        std::fs::create_dir_all(parent)?;
112        set_private_directory(parent)?;
113    }
114    tokio::task::spawn_blocking(move || {
115        let file = OpenOptions::new()
116            .create(true)
117            .truncate(false)
118            .read(true)
119            .write(true)
120            .open(path)?;
121        let deadline = Instant::now() + timeout;
122        loop {
123            if file.try_lock_exclusive()? {
124                return Ok(FileLock(file));
125            }
126            if Instant::now() >= deadline {
127                return Err(Error::LockTimeout(name));
128            }
129            std::thread::sleep(Duration::from_millis(50));
130        }
131    })
132    .await
133    .map_err(|error| Error::Storage(format!("file lock task failed: {error}")))?
134}
135
136pub(crate) struct FileLock(std::fs::File);
137impl StorageLock for FileLock {}
138
139impl Drop for FileLock {
140    fn drop(&mut self) {
141        let _ = fs4::fs_std::FileExt::unlock(&self.0);
142    }
143}
144
145#[async_trait]
146impl CredentialStore for FileStore {
147    async fn load(&self, profile: &str) -> Result<Option<Token>> {
148        if self.layout == FileLayout::PerCredential {
149            return self.credential_store(profile)?.load(profile).await;
150        }
151        let _lock = self.acquire_cache_lock().await?;
152        self.read_cache()
153            .await?
154            .tokens
155            .remove(profile)
156            .map(|value| serde_json::from_value(value).map_err(Into::into))
157            .transpose()
158    }
159
160    async fn save(&self, profile: &str, token: &Token) -> Result<()> {
161        if self.layout == FileLayout::PerCredential {
162            return self.credential_store(profile)?.save(profile, token).await;
163        }
164        let _lock = self.acquire_cache_lock().await?;
165        let mut cache = self.read_cache().await?;
166        cache
167            .tokens
168            .insert(profile.to_owned(), serde_json::to_value(token)?);
169        self.write_cache(&cache).await
170    }
171
172    async fn delete(&self, profile: &str) -> Result<()> {
173        if self.layout == FileLayout::PerCredential {
174            return self.credential_store(profile)?.delete(profile).await;
175        }
176        let _lock = self.acquire_cache_lock().await?;
177        let mut cache = self.read_cache().await?;
178        cache.tokens.remove(profile);
179        self.write_cache(&cache).await
180    }
181
182    async fn lock(&self, profile: &str, timeout: Duration) -> Result<Box<dyn StorageLock>> {
183        if self.layout == FileLayout::PerCredential {
184            return self.credential_store(profile)?.lock(profile, timeout).await;
185        }
186        Ok(Box::new(
187            acquire_lock(
188                self.root.join("token-cache.refresh.lock"),
189                "token-cache.json".to_owned(),
190                timeout,
191            )
192            .await?,
193        ))
194    }
195
196    fn name(&self) -> &'static str {
197        "file"
198    }
199}
200
201fn key_hash(profile: &str) -> String {
202    format!("{:x}", Sha256::digest(profile.as_bytes()))
203}
204
205#[cfg(unix)]
206fn set_private_file(path: &Path) -> Result<()> {
207    use std::os::unix::fs::PermissionsExt;
208    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
209    Ok(())
210}
211
212#[cfg(not(unix))]
213fn set_private_file(_path: &Path) -> Result<()> {
214    Ok(())
215}
216
217#[cfg(unix)]
218fn set_private_directory(path: &Path) -> Result<()> {
219    use std::os::unix::fs::PermissionsExt;
220    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
221    Ok(())
222}
223
224#[cfg(not(unix))]
225fn set_private_directory(_path: &Path) -> Result<()> {
226    Ok(())
227}