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#[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 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 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 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 pub fn roots(&self) -> impl Iterator<Item = &Path> {
74 self.roots.iter().map(PathBuf::as_path)
75 }
76
77 #[must_use]
79 pub const fn max_preview_bytes(&self) -> usize {
80 self.max_preview_bytes
81 }
82
83 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum FileKind {
113 File,
115 Directory,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct FileMetadata {
122 pub host: HostId,
124 pub topology_revision: TopologyRevision,
126 pub path: PathBuf,
128 pub kind: FileKind,
130 pub size_bytes: u64,
132 pub readonly: bool,
134 pub modified_unix_millis: Option<i64>,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct FilePreview {
141 pub metadata: FileMetadata,
143 pub content: Vec<u8>,
145 pub truncated: bool,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct FileHash {
152 pub metadata: FileMetadata,
154 pub sha256: String,
156 pub bytes_hashed: u64,
158}
159
160#[async_trait]
162pub trait FilesystemInspector: Send + Sync {
163 async fn stat(
165 &self,
166 host: &HostRecord,
167 path: &Path,
168 cancellation: &CancellationToken,
169 ) -> InfraResult<FileMetadata>;
170
171 async fn read(
173 &self,
174 host: &HostRecord,
175 path: &Path,
176 cancellation: &CancellationToken,
177 ) -> InfraResult<FilePreview>;
178
179 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;