Skip to main content

soma_infra/
filesystem_query.rs

1use std::path::{Path, PathBuf};
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{HostId, HostRecord, TopologyRevision};
6use soma_ops::Timestamp;
7use tokio_util::sync::CancellationToken;
8
9use crate::{FileKind, InfraError, InfraResult};
10
11const MAX_DEPTH: u8 = 20;
12const MAX_RESULTS: u32 = 500;
13const MAX_TAIL_LINES: u32 = 5000;
14
15/// Request for a bounded file or directory read.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17pub struct PathReadRequest {
18    tree: bool,
19    depth: u8,
20    deadline: Timestamp,
21}
22
23impl PathReadRequest {
24    /// Creates a direct path read.
25    #[must_use]
26    pub const fn new(deadline: Timestamp) -> Self {
27        Self {
28            tree: false,
29            depth: 3,
30            deadline,
31        }
32    }
33    /// Enables a bounded directory tree.
34    pub fn with_tree(mut self, depth: u8) -> InfraResult<Self> {
35        if depth == 0 || depth > MAX_DEPTH {
36            return Err(InfraError::InvalidRequest {
37                domain: "filesystem",
38                message: format!("tree depth must be 1-{MAX_DEPTH}"),
39            });
40        }
41        self.tree = true;
42        self.depth = depth;
43        Ok(self)
44    }
45    /// Returns whether tree mode is enabled.
46    #[must_use]
47    pub const fn tree(&self) -> bool {
48        self.tree
49    }
50    /// Returns the tree depth.
51    #[must_use]
52    pub const fn depth(&self) -> u8 {
53        self.depth
54    }
55    /// Returns the deadline.
56    #[must_use]
57    pub const fn deadline(&self) -> Timestamp {
58        self.deadline
59    }
60}
61
62/// Bounded file or directory read.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct PathRead {
65    /// Target host.
66    pub host: HostId,
67    /// Exact topology revision.
68    pub topology_revision: TopologyRevision,
69    /// Requested absolute path.
70    pub path: PathBuf,
71    /// Object kind.
72    pub kind: FileKind,
73    /// File bytes when the target is a regular file.
74    pub content: Vec<u8>,
75    /// Directory entries or tree paths.
76    pub entries: Vec<String>,
77    /// Original file size.
78    pub size_bytes: u64,
79    /// Whether content or entries were truncated.
80    pub truncated: bool,
81}
82
83/// Bounded recursive file search request.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct FileFindRequest {
86    pattern: String,
87    depth: u8,
88    limit: u32,
89    deadline: Timestamp,
90}
91
92impl FileFindRequest {
93    /// Creates a search request.
94    pub fn new(pattern: impl Into<String>, deadline: Timestamp) -> InfraResult<Self> {
95        let pattern = pattern.into();
96        validate_pattern(&pattern)?;
97        Ok(Self {
98            pattern,
99            depth: 10,
100            limit: MAX_RESULTS,
101            deadline,
102        })
103    }
104    /// Sets traversal depth.
105    pub fn with_depth(mut self, depth: u8) -> InfraResult<Self> {
106        if depth == 0 || depth > MAX_DEPTH {
107            return Err(InfraError::InvalidRequest {
108                domain: "filesystem",
109                message: format!("find depth must be 1-{MAX_DEPTH}"),
110            });
111        }
112        self.depth = depth;
113        Ok(self)
114    }
115    /// Sets result limit.
116    pub fn with_limit(mut self, limit: u32) -> InfraResult<Self> {
117        if limit == 0 || limit > MAX_RESULTS {
118            return Err(InfraError::InvalidRequest {
119                domain: "filesystem",
120                message: format!("find limit must be 1-{MAX_RESULTS}"),
121            });
122        }
123        self.limit = limit;
124        Ok(self)
125    }
126    /// Returns the glob pattern.
127    #[must_use]
128    pub fn pattern(&self) -> &str {
129        &self.pattern
130    }
131    /// Returns traversal depth.
132    #[must_use]
133    pub const fn depth(&self) -> u8 {
134        self.depth
135    }
136    /// Returns result limit.
137    #[must_use]
138    pub const fn limit(&self) -> u32 {
139        self.limit
140    }
141    /// Returns deadline.
142    #[must_use]
143    pub const fn deadline(&self) -> Timestamp {
144        self.deadline
145    }
146}
147
148/// Bounded file-search result.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct FileSearch {
151    /// Target host.
152    pub host: HostId,
153    /// Exact topology revision.
154    pub topology_revision: TopologyRevision,
155    /// Search root.
156    pub path: PathBuf,
157    /// Matching absolute paths.
158    pub items: Vec<PathBuf>,
159    /// Whether the result or visit ceiling was reached.
160    pub truncated: bool,
161}
162
163/// Request for a bounded file tail.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165pub struct FileTailRequest {
166    lines: u32,
167    deadline: Timestamp,
168}
169
170impl FileTailRequest {
171    /// Creates a request for the last 100 lines.
172    #[must_use]
173    pub const fn new(deadline: Timestamp) -> Self {
174        Self {
175            lines: 100,
176            deadline,
177        }
178    }
179    /// Sets line count.
180    pub fn with_lines(mut self, lines: u32) -> InfraResult<Self> {
181        if lines == 0 || lines > MAX_TAIL_LINES {
182            return Err(InfraError::InvalidRequest {
183                domain: "filesystem",
184                message: format!("tail lines must be 1-{MAX_TAIL_LINES}"),
185            });
186        }
187        self.lines = lines;
188        Ok(self)
189    }
190    /// Returns line count.
191    #[must_use]
192    pub const fn lines(&self) -> u32 {
193        self.lines
194    }
195    /// Returns deadline.
196    #[must_use]
197    pub const fn deadline(&self) -> Timestamp {
198        self.deadline
199    }
200}
201
202/// Bounded file tail.
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204pub struct FileTail {
205    /// Target host.
206    pub host: HostId,
207    /// Exact topology revision.
208    pub topology_revision: TopologyRevision,
209    /// Requested path.
210    pub path: PathBuf,
211    /// Retained UTF-8 bytes.
212    pub content: Vec<u8>,
213    /// Returned line count.
214    pub line_count: usize,
215    /// Whether the byte window omitted earlier content.
216    pub truncated: bool,
217}
218
219/// Descriptor-confined filesystem queries usable locally or over SSH.
220#[async_trait]
221pub trait FilesystemQueryInspector: Send + Sync {
222    /// Reads a file, directory, or bounded tree.
223    async fn read_path(
224        &self,
225        host: &HostRecord,
226        path: &Path,
227        request: &PathReadRequest,
228        cancellation: &CancellationToken,
229    ) -> InfraResult<PathRead>;
230    /// Finds files recursively beneath one admitted root.
231    async fn find(
232        &self,
233        host: &HostRecord,
234        path: &Path,
235        request: &FileFindRequest,
236        cancellation: &CancellationToken,
237    ) -> InfraResult<FileSearch>;
238    /// Returns the last lines of one admitted regular file.
239    async fn tail(
240        &self,
241        host: &HostRecord,
242        path: &Path,
243        request: &FileTailRequest,
244        cancellation: &CancellationToken,
245    ) -> InfraResult<FileTail>;
246}
247
248fn validate_pattern(value: &str) -> InfraResult<()> {
249    if value.is_empty()
250        || value.starts_with('-')
251        || value.chars().count() > 256
252        || value.chars().any(char::is_control)
253    {
254        Err(InfraError::InvalidRequest {
255            domain: "filesystem",
256            message: "invalid find pattern".into(),
257        })
258    } else {
259        Ok(())
260    }
261}
262
263#[cfg(test)]
264#[path = "filesystem_query_tests.rs"]
265mod tests;