1use std::fs::{File, Metadata};
2use std::io::{Read, Seek, SeekFrom};
3use std::os::fd::OwnedFd;
4use std::path::{Path, PathBuf};
5use std::time::SystemTime;
6
7use async_trait::async_trait;
8use rustix::fs::{Mode, OFlags, ResolveFlags, open, openat2};
9use sha2::{Digest, Sha256};
10use soma_fleet::{HostEndpoint, HostRecord};
11use tokio_util::sync::CancellationToken;
12
13use crate::{
14 FileHash, FileKind, FileMetadata, FilePreview, FileReadPolicy, FilesystemInspector, InfraError,
15 InfraResult,
16};
17
18#[derive(Debug, Clone)]
20pub struct LinuxFilesystemInspector {
21 policy: FileReadPolicy,
22}
23
24impl LinuxFilesystemInspector {
25 #[must_use]
27 pub fn new(policy: FileReadPolicy) -> Self {
28 Self { policy }
29 }
30
31 #[must_use]
33 pub fn policy(&self) -> &FileReadPolicy {
34 &self.policy
35 }
36}
37
38#[async_trait]
39impl FilesystemInspector for LinuxFilesystemInspector {
40 async fn stat(
41 &self,
42 host: &HostRecord,
43 path: &Path,
44 cancellation: &CancellationToken,
45 ) -> InfraResult<FileMetadata> {
46 ensure_local(host)?;
47 ensure_not_cancelled(cancellation)?;
48 let policy = self.policy.clone();
49 let path = path.to_path_buf();
50 let host = host.clone();
51 run_blocking(cancellation, move || {
52 let bound = bind_path(&policy, &path, OFlags::PATH | OFlags::CLOEXEC)?;
53 metadata_for(
54 &host,
55 &path,
56 &bound
57 .file
58 .metadata()
59 .map_err(|error| fs_error("stat", &path, error))?,
60 )
61 })
62 .await
63 }
64
65 async fn read(
66 &self,
67 host: &HostRecord,
68 path: &Path,
69 cancellation: &CancellationToken,
70 ) -> InfraResult<FilePreview> {
71 ensure_local(host)?;
72 ensure_not_cancelled(cancellation)?;
73 let policy = self.policy.clone();
74 let path = path.to_path_buf();
75 let host = host.clone();
76 let operation_cancellation = cancellation.clone();
77 run_blocking(cancellation, move || {
78 let mut bound = bind_path(
79 &policy,
80 &path,
81 OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
82 )?;
83 let metadata = bound
84 .file
85 .metadata()
86 .map_err(|error| fs_error("read", &path, error))?;
87 let typed = metadata_for(&host, &path, &metadata)?;
88 if typed.kind != FileKind::File {
89 return Err(InfraError::Filesystem {
90 operation: "read",
91 path,
92 message: "path is not a regular file".into(),
93 });
94 }
95 let limit = policy.max_preview_bytes();
96 let mut content = Vec::with_capacity(limit.min(8192));
97 let mut buffer = [0_u8; 64 * 1024];
98 while content.len() <= limit {
99 ensure_not_cancelled(&operation_cancellation)?;
100 let remaining = limit.saturating_add(1).saturating_sub(content.len());
101 let read_limit = remaining.min(buffer.len());
102 let read = bound
103 .file
104 .read(&mut buffer[..read_limit])
105 .map_err(|error| fs_error("read", &typed.path, error))?;
106 if read == 0 {
107 break;
108 }
109 content.extend_from_slice(&buffer[..read]);
110 }
111 let truncated = content.len() > limit;
112 content.truncate(limit);
113 Ok(FilePreview {
114 metadata: typed,
115 content,
116 truncated,
117 })
118 })
119 .await
120 }
121
122 async fn hash(
123 &self,
124 host: &HostRecord,
125 path: &Path,
126 cancellation: &CancellationToken,
127 ) -> InfraResult<FileHash> {
128 ensure_local(host)?;
129 ensure_not_cancelled(cancellation)?;
130 let policy = self.policy.clone();
131 let path = path.to_path_buf();
132 let host = host.clone();
133 let operation_cancellation = cancellation.clone();
134 run_blocking(cancellation, move || {
135 let mut bound = bind_path(
136 &policy,
137 &path,
138 OFlags::RDONLY | OFlags::NONBLOCK | OFlags::CLOEXEC,
139 )?;
140 let metadata = bound
141 .file
142 .metadata()
143 .map_err(|error| fs_error("hash", &path, error))?;
144 let typed = metadata_for(&host, &path, &metadata)?;
145 if typed.kind != FileKind::File {
146 return Err(InfraError::Filesystem {
147 operation: "hash",
148 path,
149 message: "path is not a regular file".into(),
150 });
151 }
152 if typed.size_bytes > policy.max_hash_bytes() {
153 return Err(InfraError::InvalidRequest {
154 domain: "filesystem",
155 message: format!(
156 "file is {} bytes; hash limit is {}",
157 typed.size_bytes,
158 policy.max_hash_bytes()
159 ),
160 });
161 }
162 bound
163 .file
164 .seek(SeekFrom::Start(0))
165 .map_err(|error| fs_error("hash", &typed.path, error))?;
166 let (sha256, bytes_hashed) = hash_reader(
167 &mut bound.file,
168 &typed.path,
169 policy.max_hash_bytes(),
170 &operation_cancellation,
171 )?;
172 Ok(FileHash {
173 metadata: typed,
174 sha256,
175 bytes_hashed,
176 })
177 })
178 .await
179 }
180}
181
182fn hash_reader(
183 reader: &mut impl Read,
184 path: &Path,
185 max_bytes: u64,
186 cancellation: &CancellationToken,
187) -> InfraResult<(String, u64)> {
188 let mut hasher = Sha256::new();
189 let mut buffer = [0_u8; 64 * 1024];
190 let mut bytes_hashed = 0_u64;
191 loop {
192 ensure_not_cancelled(cancellation)?;
193 let read_limit_u64 = max_bytes
194 .saturating_sub(bytes_hashed)
195 .saturating_add(1)
196 .min(buffer.len() as u64);
197 let read_limit = usize::try_from(read_limit_u64).unwrap_or(buffer.len());
198 let read = reader
199 .read(&mut buffer[..read_limit])
200 .map_err(|error| fs_error("hash", path, error))?;
201 if read == 0 {
202 break;
203 }
204 let next_bytes = bytes_hashed.saturating_add(read as u64);
205 if next_bytes > max_bytes {
206 return Err(InfraError::InvalidRequest {
207 domain: "filesystem",
208 message: format!("file grew beyond hash limit of {max_bytes} bytes"),
209 });
210 }
211 bytes_hashed = next_bytes;
212 hasher.update(&buffer[..read]);
213 }
214 let digest = hasher.finalize();
215 let digest = digest.iter().map(|byte| format!("{byte:02x}")).collect();
216 Ok((digest, bytes_hashed))
217}
218
219struct BoundFile {
220 file: File,
221}
222
223fn bind_path(policy: &FileReadPolicy, path: &Path, target_flags: OFlags) -> InfraResult<BoundFile> {
224 let (root, relative) = policy.resolve(path)?;
225 let slash: OwnedFd = open(
226 "/",
227 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
228 Mode::empty(),
229 )
230 .map_err(|error| fs_error("open-root", path, error))?;
231 let root_relative = root.strip_prefix("/").unwrap_or(root.as_path());
232 let root_fd = openat2(
233 &slash,
234 if root_relative.as_os_str().is_empty() {
235 Path::new(".")
236 } else {
237 root_relative
238 },
239 OFlags::RDONLY | OFlags::DIRECTORY | OFlags::CLOEXEC,
240 Mode::empty(),
241 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
242 )
243 .map_err(|error| fs_error("open-root", path, error))?;
244 let target = if relative.as_os_str().is_empty() {
245 Path::new(".")
246 } else {
247 relative.as_path()
248 };
249 let fd = openat2(
250 &root_fd,
251 target,
252 target_flags,
253 Mode::empty(),
254 ResolveFlags::BENEATH | ResolveFlags::NO_SYMLINKS | ResolveFlags::NO_MAGICLINKS,
255 )
256 .map_err(|error| fs_error("open", path, error))?;
257 Ok(BoundFile { file: fd.into() })
258}
259
260fn metadata_for(host: &HostRecord, path: &Path, metadata: &Metadata) -> InfraResult<FileMetadata> {
261 let kind = if metadata.is_file() {
262 FileKind::File
263 } else if metadata.is_dir() {
264 FileKind::Directory
265 } else {
266 return Err(InfraError::Filesystem {
267 operation: "stat",
268 path: path.to_path_buf(),
269 message: "path is neither a regular file nor a directory".into(),
270 });
271 };
272 let modified_unix_millis = metadata.modified().ok().and_then(system_time_millis);
273 Ok(FileMetadata {
274 host: host.id().clone(),
275 topology_revision: host.revision().clone(),
276 path: path.to_path_buf(),
277 kind,
278 size_bytes: if kind == FileKind::File {
279 metadata.len()
280 } else {
281 0
282 },
283 readonly: metadata.permissions().readonly(),
284 modified_unix_millis,
285 })
286}
287
288fn system_time_millis(value: SystemTime) -> Option<i64> {
289 let duration = value.duration_since(SystemTime::UNIX_EPOCH).ok()?;
290 i64::try_from(duration.as_millis()).ok()
291}
292
293fn ensure_local(host: &HostRecord) -> InfraResult<()> {
294 if matches!(host.endpoint(), HostEndpoint::Local) {
295 Ok(())
296 } else {
297 Err(InfraError::UnsupportedTarget {
298 domain: "filesystem",
299 host: host.id().clone(),
300 })
301 }
302}
303
304fn ensure_not_cancelled(cancellation: &CancellationToken) -> InfraResult<()> {
305 if cancellation.is_cancelled() {
306 Err(soma_fleet::FleetError::Cancelled.into())
307 } else {
308 Ok(())
309 }
310}
311
312async fn run_blocking<T, F>(cancellation: &CancellationToken, operation: F) -> InfraResult<T>
313where
314 T: Send + 'static,
315 F: FnOnce() -> InfraResult<T> + Send + 'static,
316{
317 let task = tokio::task::spawn_blocking(operation);
318 tokio::select! {
319 () = cancellation.cancelled() => Err(soma_fleet::FleetError::Cancelled.into()),
320 result = task => result.map_err(|error| InfraError::Filesystem {
321 operation: "join",
322 path: PathBuf::new(),
323 message: error.to_string(),
324 })?,
325 }
326}
327
328fn fs_error(operation: &'static str, path: &Path, error: impl std::fmt::Display) -> InfraError {
329 InfraError::Filesystem {
330 operation,
331 path: path.to_path_buf(),
332 message: error.to_string(),
333 }
334}
335
336#[cfg(test)]
337#[path = "linux_filesystem_tests.rs"]
338mod tests;