Skip to main content

soma_infra/
filesystem.rs

1use std::path::{Component, Path, PathBuf};
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{HostId, HostRecord, TopologyRevision};
6use tokio_util::sync::CancellationToken;
7
8use crate::{InfraError, InfraResult};
9
10const MAX_PREVIEW_BYTES: usize = 16 * 1024 * 1024;
11const MAX_HASH_BYTES: u64 = 1024 * 1024 * 1024 * 1024;
12
13/// Closed read policy for one filesystem inspector.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct FileReadPolicy {
16    roots: Vec<PathBuf>,
17    max_preview_bytes: usize,
18    max_hash_bytes: u64,
19}
20
21impl FileReadPolicy {
22    /// Creates a read policy with absolute normalized roots.
23    pub fn new<I, P>(roots: I) -> InfraResult<Self>
24    where
25        I: IntoIterator<Item = P>,
26        P: Into<PathBuf>,
27    {
28        let mut roots = roots
29            .into_iter()
30            .map(Into::into)
31            .map(validate_absolute_path)
32            .collect::<InfraResult<Vec<_>>>()?;
33        roots.sort();
34        roots.dedup();
35        if roots.is_empty() {
36            return Err(InfraError::InvalidRequest {
37                domain: "filesystem",
38                message: "at least one read root is required".into(),
39            });
40        }
41        Ok(Self {
42            roots,
43            max_preview_bytes: 1024 * 1024,
44            max_hash_bytes: 1024 * 1024 * 1024,
45        })
46    }
47
48    /// Sets the maximum preview bytes retained in memory.
49    pub fn with_preview_limit(mut self, bytes: usize) -> InfraResult<Self> {
50        if bytes == 0 || bytes > MAX_PREVIEW_BYTES {
51            return Err(InfraError::InvalidRequest {
52                domain: "filesystem",
53                message: format!("preview limit must be 1-{MAX_PREVIEW_BYTES} bytes"),
54            });
55        }
56        self.max_preview_bytes = bytes;
57        Ok(self)
58    }
59
60    /// Sets the maximum file size admitted for hashing.
61    pub fn with_hash_limit(mut self, bytes: u64) -> InfraResult<Self> {
62        if bytes == 0 || bytes > MAX_HASH_BYTES {
63            return Err(InfraError::InvalidRequest {
64                domain: "filesystem",
65                message: format!("hash limit must be 1-{MAX_HASH_BYTES} bytes"),
66            });
67        }
68        self.max_hash_bytes = bytes;
69        Ok(self)
70    }
71
72    /// Returns admitted roots in deterministic order.
73    pub fn roots(&self) -> impl Iterator<Item = &Path> {
74        self.roots.iter().map(PathBuf::as_path)
75    }
76
77    /// Returns the preview byte limit.
78    #[must_use]
79    pub const fn max_preview_bytes(&self) -> usize {
80        self.max_preview_bytes
81    }
82
83    /// Returns the hash byte limit.
84    #[must_use]
85    pub const fn max_hash_bytes(&self) -> u64 {
86        self.max_hash_bytes
87    }
88
89    #[cfg(any(feature = "linux-filesystem", feature = "process-driver", test))]
90    pub(crate) fn resolve(&self, path: &Path) -> InfraResult<(PathBuf, PathBuf)> {
91        let path = validate_absolute_path(path.to_path_buf())?;
92        let root = self
93            .roots
94            .iter()
95            .filter(|root| {
96                path == **root || root.as_os_str() == "/" || path.strip_prefix(root).is_ok()
97            })
98            .max_by_key(|root| root.components().count())
99            .cloned()
100            .ok_or_else(|| InfraError::PathOutsideRoots(path.clone()))?;
101        let relative = path
102            .strip_prefix(&root)
103            .map_err(|_| InfraError::PathOutsideRoots(path.clone()))?
104            .to_path_buf();
105        Ok((root, relative))
106    }
107}
108
109/// Read-only filesystem object kind.
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum FileKind {
113    /// Regular file.
114    File,
115    /// Directory.
116    Directory,
117}
118
119/// Typed filesystem metadata.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct FileMetadata {
122    /// Target host.
123    pub host: HostId,
124    /// Exact topology revision.
125    pub topology_revision: TopologyRevision,
126    /// Requested absolute path.
127    pub path: PathBuf,
128    /// Object kind.
129    pub kind: FileKind,
130    /// File length in bytes, or zero for directories.
131    pub size_bytes: u64,
132    /// Whether the current metadata marks the object read-only.
133    pub readonly: bool,
134    /// Last-modified time in Unix milliseconds when available.
135    pub modified_unix_millis: Option<i64>,
136}
137
138/// Bounded file preview.
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct FilePreview {
141    /// File metadata.
142    pub metadata: FileMetadata,
143    /// Retained content prefix.
144    pub content: Vec<u8>,
145    /// Whether the file exceeded the preview limit.
146    pub truncated: bool,
147}
148
149/// SHA-256 file digest.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct FileHash {
152    /// File metadata.
153    pub metadata: FileMetadata,
154    /// Lowercase SHA-256 digest.
155    pub sha256: String,
156    /// Number of bytes hashed.
157    pub bytes_hashed: u64,
158}
159
160/// Product-neutral filesystem inspection engine.
161#[async_trait]
162pub trait FilesystemInspector: Send + Sync {
163    /// Returns metadata for one admitted path.
164    async fn stat(
165        &self,
166        host: &HostRecord,
167        path: &Path,
168        cancellation: &CancellationToken,
169    ) -> InfraResult<FileMetadata>;
170
171    /// Reads a bounded prefix of one admitted regular file.
172    async fn read(
173        &self,
174        host: &HostRecord,
175        path: &Path,
176        cancellation: &CancellationToken,
177    ) -> InfraResult<FilePreview>;
178
179    /// Hashes one admitted regular file within the policy size limit.
180    async fn hash(
181        &self,
182        host: &HostRecord,
183        path: &Path,
184        cancellation: &CancellationToken,
185    ) -> InfraResult<FileHash>;
186}
187
188fn validate_absolute_path(path: PathBuf) -> InfraResult<PathBuf> {
189    if !path.is_absolute()
190        || path
191            .components()
192            .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
193    {
194        Err(InfraError::InvalidRequest {
195            domain: "filesystem",
196            message: format!("path must be absolute and normalized: {}", path.display()),
197        })
198    } else {
199        Ok(path)
200    }
201}
202
203#[cfg(test)]
204#[path = "filesystem_tests.rs"]
205mod tests;