Skip to main content

gradatum_storage/
file.rs

1//! `FileStorage` — implémentation OpenDAL filesystem du trait `Storage`.
2//!
3//! ## Fonctionnement
4//!
5//! Délègue toutes les opérations I/O à un `opendal::Operator` configuré avec
6//! le backend `services::Fs`. La racine (root) est fixée à la construction.
7//!
8//! ## Caveat C11
9//!
10//! `FileStorage::new()` appelle `ensure_local_filesystem(root)` **avant** de
11//! construire l'`Operator`. Si le chemin réside sur NFS, la construction échoue
12//! avec `StorageError::Core(GradatumError::VaultOnNfs)`.
13//!
14//! ## Sécurité — guard path traversal (E-29)
15//!
16//! OpenDAL Fs 0.51 ne rejette pas les composants `..` nativement. Chaque opération
17//! appelle `validate_relative_path()` en entrée — defense in depth obligatoire pour
18//! le contrat "Storage abstraction confinée" (backends S3/GCS networked non implémentés).
19//! Spec : §11 E-29.
20//!
21//! ## Features
22//!
23//! Cette implémentation est disponible via la feature `fs` (activée par défaut).
24
25use std::path::{Path, PathBuf};
26
27use async_trait::async_trait;
28use opendal::{services, EntryMode, Operator};
29use tracing::instrument;
30
31use crate::error::StorageError;
32use crate::storage_trait::{Storage, StorageEntry};
33
34/// Implémentation `Storage` via le backend filesystem OpenDAL.
35///
36/// Thread-safe — `Operator` est `Clone + Send + Sync` en interne.
37pub struct FileStorage {
38    /// Opérateur OpenDAL configuré sur la racine.
39    op: Operator,
40    /// Chemin absolu de la racine (conservé pour diagnostic et `root()`).
41    root: PathBuf,
42}
43
44/// Valide qu'un chemin relatif ne contient pas de composant `..` ni de chemin absolu.
45///
46/// OpenDAL Fs 0.51 ne rejette pas les composants `..` nativement — ce guard
47/// est la seule barrière contre un path traversal hors du root configuré.
48///
49/// # Erreurs
50///
51/// - `StorageError::InvalidPath` — chemin absolu (commence par `/`) ou contient `..`.
52fn validate_relative_path(path: &str) -> Result<(), StorageError> {
53    // Refuser les chemins absolus (commençant par `/`).
54    if path.starts_with('/') {
55        return Err(StorageError::InvalidPath(PathBuf::from(path)));
56    }
57    // Rejeter tout composant `..` pour empêcher le path traversal hors du root configuré.
58    // Pourquoi explicite plutôt que confier à OpenDAL : FsBackend::read/write/etc. fait
59    // `root.join(path)` sans canonicalization post-join — `../x` accède hors root.
60    for component in std::path::Path::new(path).components() {
61        if component == std::path::Component::ParentDir {
62            return Err(StorageError::InvalidPath(PathBuf::from(path)));
63        }
64    }
65    Ok(())
66}
67
68impl FileStorage {
69    /// Construit un `FileStorage` enraciné à `root`.
70    ///
71    /// ## Caveat C11
72    ///
73    /// Appelle `ensure_local_filesystem(root)` en premier. Retourne
74    /// `Err(StorageError::Core(GradatumError::VaultOnNfs))` si NFS détecté.
75    ///
76    /// # Erreurs
77    ///
78    /// - `StorageError::Core(VaultOnNfs)` — chemin sur NFS.
79    /// - `StorageError::InvalidPath` — chemin non convertible en UTF-8.
80    /// - `StorageError::OpenDal` — échec de construction de l'`Operator`.
81    pub fn new(root: &Path) -> Result<Self, StorageError> {
82        // Caveat C11 BLOQUANT — vérifier NFS avant toute construction.
83        crate::nfs_check::ensure_local_filesystem(root)?;
84
85        let root_str = root
86            .to_str()
87            .ok_or_else(|| StorageError::InvalidPath(root.to_path_buf()))?;
88
89        let builder = services::Fs::default().root(root_str);
90        let op = Operator::new(builder)
91            .map_err(|e| StorageError::OpenDal(e.to_string()))?
92            .finish();
93
94        Ok(Self {
95            op,
96            root: root.to_path_buf(),
97        })
98    }
99
100    /// Retourne le chemin absolu de la racine configurée.
101    #[must_use]
102    pub fn root(&self) -> &Path {
103        &self.root
104    }
105}
106
107#[async_trait]
108impl Storage for FileStorage {
109    /// Lit le contenu d'un fichier au chemin relatif `path`.
110    ///
111    /// `path` est relatif à la racine du storage.
112    ///
113    /// # Erreurs
114    ///
115    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
116    #[instrument(skip(self), fields(path))]
117    async fn read(&self, path: &str) -> Result<Vec<u8>, StorageError> {
118        validate_relative_path(path)?;
119        self.op
120            .read(path)
121            .await
122            .map(|buf| buf.to_vec())
123            .map_err(|e| {
124                if e.kind() == opendal::ErrorKind::NotFound {
125                    StorageError::NotFound(path.to_owned())
126                } else {
127                    StorageError::OpenDal(e.to_string())
128                }
129            })
130    }
131
132    /// Écrit `content` au chemin relatif `path`.
133    ///
134    /// Les répertoires intermédiaires sont créés automatiquement par OpenDAL/Fs.
135    ///
136    /// # Erreurs
137    ///
138    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
139    #[instrument(skip(self, content), fields(path, bytes = content.len()))]
140    async fn write(&self, path: &str, content: &[u8]) -> Result<(), StorageError> {
141        validate_relative_path(path)?;
142        self.op
143            .write(path, content.to_vec())
144            .await
145            .map_err(|e| StorageError::OpenDal(e.to_string()))
146    }
147
148    /// Supprime le fichier au chemin relatif `path`.
149    ///
150    /// # Erreurs
151    ///
152    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
153    #[instrument(skip(self), fields(path))]
154    async fn delete(&self, path: &str) -> Result<(), StorageError> {
155        validate_relative_path(path)?;
156        self.op.delete(path).await.map_err(|e| {
157            if e.kind() == opendal::ErrorKind::NotFound {
158                StorageError::NotFound(path.to_owned())
159            } else {
160                StorageError::OpenDal(e.to_string())
161            }
162        })
163    }
164
165    /// Liste les entrées dont le chemin commence par `prefix`.
166    ///
167    /// Retourne une liste plate (non-récursive par entrée mais scan récursif du prefix).
168    /// Les répertoires sont inclus si retournés par le backend.
169    ///
170    /// # Erreurs
171    ///
172    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
173    #[instrument(skip(self), fields(prefix))]
174    async fn list(&self, prefix: &str) -> Result<Vec<StorageEntry>, StorageError> {
175        validate_relative_path(prefix)?;
176        // `list_with(prefix).recursive(true)` pour scan récursif (équivalent find).
177        // Sans `.recursive(true)`, seul le niveau immédiat est retourné.
178        let entries = self
179            .op
180            .list_with(prefix)
181            .recursive(true)
182            .await
183            .map_err(|e| StorageError::OpenDal(e.to_string()))?;
184
185        let result = entries
186            .into_iter()
187            .map(|e| {
188                let meta = e.metadata();
189                let is_dir = matches!(meta.mode(), EntryMode::DIR);
190                let size = if is_dir { 0 } else { meta.content_length() };
191                let last_modified = meta.last_modified().map(|dt| dt.timestamp_millis());
192                StorageEntry {
193                    path: e.path().to_owned(),
194                    size,
195                    last_modified,
196                    is_dir,
197                }
198            })
199            .collect();
200
201        Ok(result)
202    }
203
204    /// Retourne les métadonnées de l'objet à `path`.
205    ///
206    /// # Erreurs
207    ///
208    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
209    #[instrument(skip(self), fields(path))]
210    async fn stat(&self, path: &str) -> Result<StorageEntry, StorageError> {
211        validate_relative_path(path)?;
212        let meta = self.op.stat(path).await.map_err(|e| {
213            if e.kind() == opendal::ErrorKind::NotFound {
214                StorageError::NotFound(path.to_owned())
215            } else {
216                StorageError::OpenDal(e.to_string())
217            }
218        })?;
219
220        let is_dir = matches!(meta.mode(), EntryMode::DIR);
221        let size = if is_dir { 0 } else { meta.content_length() };
222        let last_modified = meta.last_modified().map(|dt| dt.timestamp_millis());
223
224        Ok(StorageEntry {
225            path: path.to_owned(),
226            size,
227            last_modified,
228            is_dir,
229        })
230    }
231
232    /// Retourne `true` si un objet existe à `path`, `false` sinon.
233    ///
234    /// # Erreurs
235    ///
236    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
237    #[instrument(skip(self), fields(path))]
238    async fn exists(&self, path: &str) -> Result<bool, StorageError> {
239        validate_relative_path(path)?;
240        self.op
241            .exists(path)
242            .await
243            .map_err(|e| StorageError::OpenDal(e.to_string()))
244    }
245
246    /// Crée le répertoire à `path` (idempotent).
247    ///
248    /// Délègue à `Operator::create_dir` — opération native OpenDAL.
249    /// Le chemin doit se terminer par `/` (exigence OpenDAL).
250    ///
251    /// # Erreurs
252    ///
253    /// - `StorageError::InvalidPath` — chemin absolu ou contenant `..` (E-29).
254    #[instrument(skip(self), fields(path))]
255    async fn create_dir(&self, path: &str) -> Result<(), StorageError> {
256        validate_relative_path(path)?;
257        self.op
258            .create_dir(path)
259            .await
260            .map_err(|e| StorageError::OpenDal(e.to_string()))
261    }
262}