Skip to main content

doido_storage/providers/
disk.rs

1//! The local-filesystem [`Service`] — the default backend, analogous to Rails'
2//! `Disk` service.
3//!
4//! Objects live under `root/<k0..2>/<k2..4>/<key>`, the same two-level fan-out
5//! Rails uses to keep directories small. Disk has no native access URL, so
6//! [`Service::url`] returns `None` and files are served through the app's signed
7//! [`crate::serving`] routes.
8
9use crate::error::StorageError;
10use crate::service::Service;
11use doido_core::Result;
12use std::path::{Path, PathBuf};
13
14/// A [`Service`] backed by a directory on the local filesystem.
15pub struct DiskService {
16    name: String,
17    root: PathBuf,
18    public: bool,
19}
20
21impl DiskService {
22    /// Create a disk service rooted at `root`, named `name`.
23    pub fn new(name: impl Into<String>, root: impl Into<PathBuf>) -> Self {
24        Self {
25            name: name.into(),
26            root: root.into(),
27            public: false,
28        }
29    }
30
31    /// Mark objects as publicly readable (affects URL generation only).
32    pub fn public(mut self, public: bool) -> Self {
33        self.public = public;
34        self
35    }
36
37    /// The sharded path for `key`, validating it can't escape the root.
38    fn path_for(&self, key: &str) -> Result<PathBuf> {
39        if key.is_empty() || key.contains('/') || key.contains('\\') || key.contains("..") {
40            return Err(StorageError::Backend(format!("invalid storage key {key:?}")).into());
41        }
42        let (a, b) = shards(key);
43        Ok(self.root.join(a).join(b).join(key))
44    }
45}
46
47/// Two two-character shard directories derived from the key (padded for short keys).
48fn shards(key: &str) -> (String, String) {
49    let padded = format!("{key:_<4}");
50    (padded[0..2].to_string(), padded[2..4].to_string())
51}
52
53#[async_trait::async_trait]
54impl Service for DiskService {
55    fn name(&self) -> &str {
56        &self.name
57    }
58
59    fn public(&self) -> bool {
60        self.public
61    }
62
63    async fn upload(&self, key: &str, data: Vec<u8>, _content_type: Option<&str>) -> Result<()> {
64        let path = self.path_for(key)?;
65        if let Some(parent) = path.parent() {
66            tokio::fs::create_dir_all(parent)
67                .await
68                .map_err(StorageError::from)?;
69        }
70        tokio::fs::write(&path, data)
71            .await
72            .map_err(StorageError::from)?;
73        Ok(())
74    }
75
76    async fn download(&self, key: &str) -> Result<Vec<u8>> {
77        let path = self.path_for(key)?;
78        match tokio::fs::read(&path).await {
79            Ok(bytes) => Ok(bytes),
80            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81                Err(StorageError::NotFound(key.to_string()).into())
82            }
83            Err(e) => Err(StorageError::from(e).into()),
84        }
85    }
86
87    async fn delete(&self, key: &str) -> Result<()> {
88        let path = self.path_for(key)?;
89        match tokio::fs::remove_file(&path).await {
90            Ok(()) => Ok(()),
91            // Idempotent: a missing object is not an error.
92            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
93            Err(e) => Err(StorageError::from(e).into()),
94        }
95    }
96
97    async fn exists(&self, key: &str) -> Result<bool> {
98        let path = self.path_for(key)?;
99        Ok(tokio::fs::try_exists(&path).await.unwrap_or(false))
100    }
101
102    async fn size(&self, key: &str) -> Result<u64> {
103        let path = self.path_for(key)?;
104        let meta = tokio::fs::metadata(&path).await.map_err(|e| {
105            if e.kind() == std::io::ErrorKind::NotFound {
106                StorageError::NotFound(key.to_string())
107            } else {
108                StorageError::from(e)
109            }
110        })?;
111        Ok(meta.len())
112    }
113}
114
115/// The absolute root path (useful for tests and diagnostics).
116impl DiskService {
117    pub fn root(&self) -> &Path {
118        &self.root
119    }
120}