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}