Skip to main content

soma_infra/
host_exec.rs

1use std::path::{Component, Path, PathBuf};
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use soma_fleet::{HostId, HostRecord, TopologyRevision};
6use soma_ops::{MutationSendState, OperationId, OperationName, Timestamp};
7use tokio_util::sync::CancellationToken;
8
9use crate::{InfraError, InfraResult, MutationResult};
10
11const MAX_COMMAND_ARGUMENTS: usize = 256;
12const MAX_ARGUMENT_CHARS: usize = 4096;
13const MAX_OUTPUT_BYTES: usize = 96 * 1024;
14
15/// Closed allowlist of host commands admitted by canonical Synapse execution.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum HostExecCommand {
19    /// Concatenate files.
20    Cat,
21    /// Read the beginning of files.
22    Head,
23    /// Read the end of files.
24    Tail,
25    /// Search text with grep.
26    Grep,
27    /// Search text with ripgrep.
28    Rg,
29    /// List filesystem entries.
30    Ls,
31    /// Render a directory tree.
32    Tree,
33    /// Count bytes, words, or lines.
34    Wc,
35    /// Collapse adjacent duplicate lines.
36    Uniq,
37    /// Compare files.
38    Diff,
39    /// Read filesystem metadata.
40    Stat,
41    /// Identify file types.
42    File,
43    /// Summarize filesystem usage.
44    Du,
45    /// Report filesystem capacity.
46    Df,
47    /// Print the working directory.
48    Pwd,
49    /// Print the host name.
50    Hostname,
51    /// Print host uptime.
52    Uptime,
53    /// Print the effective user.
54    Whoami,
55}
56
57impl HostExecCommand {
58    /// Parses one canonical command name.
59    pub fn parse(value: &str) -> InfraResult<Self> {
60        match value {
61            "cat" => Ok(Self::Cat),
62            "head" => Ok(Self::Head),
63            "tail" => Ok(Self::Tail),
64            "grep" => Ok(Self::Grep),
65            "rg" => Ok(Self::Rg),
66            "ls" => Ok(Self::Ls),
67            "tree" => Ok(Self::Tree),
68            "wc" => Ok(Self::Wc),
69            "uniq" => Ok(Self::Uniq),
70            "diff" => Ok(Self::Diff),
71            "stat" => Ok(Self::Stat),
72            "file" => Ok(Self::File),
73            "du" => Ok(Self::Du),
74            "df" => Ok(Self::Df),
75            "pwd" => Ok(Self::Pwd),
76            "hostname" => Ok(Self::Hostname),
77            "uptime" => Ok(Self::Uptime),
78            "whoami" => Ok(Self::Whoami),
79            _ => Err(invalid(format!("host command is not allowlisted: {value}"))),
80        }
81    }
82
83    /// Returns the executable name.
84    #[must_use]
85    pub const fn as_str(self) -> &'static str {
86        match self {
87            Self::Cat => "cat",
88            Self::Head => "head",
89            Self::Tail => "tail",
90            Self::Grep => "grep",
91            Self::Rg => "rg",
92            Self::Ls => "ls",
93            Self::Tree => "tree",
94            Self::Wc => "wc",
95            Self::Uniq => "uniq",
96            Self::Diff => "diff",
97            Self::Stat => "stat",
98            Self::File => "file",
99            Self::Du => "du",
100            Self::Df => "df",
101            Self::Pwd => "pwd",
102            Self::Hostname => "hostname",
103            Self::Uptime => "uptime",
104            Self::Whoami => "whoami",
105        }
106    }
107}
108
109/// One bounded allowlisted host execution request.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct HostExecRequest {
112    operation_id: OperationId,
113    operation: OperationName,
114    command: HostExecCommand,
115    args: Vec<String>,
116    working_dir: Option<PathBuf>,
117    deadline: Timestamp,
118}
119
120impl HostExecRequest {
121    /// Creates a validated request with at most 256 direct arguments.
122    pub fn new(
123        operation_id: OperationId,
124        operation: OperationName,
125        command: HostExecCommand,
126        args: Vec<String>,
127        working_dir: Option<PathBuf>,
128        deadline: Timestamp,
129    ) -> InfraResult<Self> {
130        if args.len() > MAX_COMMAND_ARGUMENTS {
131            return Err(invalid(format!(
132                "host command accepts at most {MAX_COMMAND_ARGUMENTS} arguments"
133            )));
134        }
135        for argument in &args {
136            let count = argument.chars().count();
137            if count == 0 || count > MAX_ARGUMENT_CHARS || argument.as_bytes().contains(&0) {
138                return Err(invalid(
139                    "host command arguments must be 1-4096 characters without NUL",
140                ));
141            }
142        }
143        let working_dir = working_dir.map(validate_absolute_path).transpose()?;
144        if deadline <= Timestamp::now() {
145            return Err(invalid("host command deadline must be in the future"));
146        }
147        Ok(Self {
148            operation_id,
149            operation,
150            command,
151            args,
152            working_dir,
153            deadline,
154        })
155    }
156
157    /// Returns the operation identity.
158    #[must_use]
159    pub fn operation_id(&self) -> &OperationId {
160        &self.operation_id
161    }
162    /// Returns the canonical operation name.
163    #[must_use]
164    pub fn operation(&self) -> &OperationName {
165        &self.operation
166    }
167    /// Returns the allowlisted command.
168    #[must_use]
169    pub const fn command(&self) -> HostExecCommand {
170        self.command
171    }
172    /// Returns positional arguments.
173    #[must_use]
174    pub fn args(&self) -> &[String] {
175        &self.args
176    }
177    /// Returns the optional descriptor-bound working directory.
178    #[must_use]
179    pub fn working_dir(&self) -> Option<&Path> {
180        self.working_dir.as_deref()
181    }
182    /// Returns the absolute deadline.
183    #[must_use]
184    pub const fn deadline(&self) -> Timestamp {
185        self.deadline
186    }
187    /// Returns the stdout byte ceiling.
188    #[must_use]
189    pub const fn max_stdout_bytes(&self) -> usize {
190        MAX_OUTPUT_BYTES
191    }
192    /// Returns the stderr byte ceiling.
193    #[must_use]
194    pub const fn max_stderr_bytes(&self) -> usize {
195        MAX_OUTPUT_BYTES
196    }
197}
198
199/// Completed bounded host execution.
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct HostExecReceipt {
202    /// Target host.
203    pub host: HostId,
204    /// Exact topology revision.
205    pub topology_revision: TopologyRevision,
206    /// Executed command.
207    pub command: HostExecCommand,
208    /// Positional arguments.
209    pub args: Vec<String>,
210    /// Optional descriptor-bound working directory.
211    pub working_dir: Option<PathBuf>,
212    /// Lossy UTF-8 stdout bounded by policy.
213    pub stdout: String,
214    /// Lossy UTF-8 stderr bounded by policy.
215    pub stderr: String,
216    /// Process exit code when available.
217    pub exit_code: Option<i32>,
218    /// Whether either stream exceeded its byte ceiling.
219    pub truncated: bool,
220    /// Whether UTF-8 replacement was required.
221    pub encoding_lossy: bool,
222    /// Backend send state.
223    pub send_state: MutationSendState,
224}
225
226/// Product-neutral bounded host command driver.
227#[async_trait]
228pub trait HostExecMutator: Send + Sync {
229    /// Executes one allowlisted command through a typed launcher.
230    async fn exec_host(
231        &self,
232        host: &HostRecord,
233        request: &HostExecRequest,
234        cancellation: &CancellationToken,
235    ) -> MutationResult<HostExecReceipt>;
236}
237
238fn validate_absolute_path(path: PathBuf) -> InfraResult<PathBuf> {
239    if !path.is_absolute()
240        || path
241            .components()
242            .any(|component| matches!(component, Component::ParentDir | Component::CurDir))
243        || path.to_string_lossy().chars().any(char::is_control)
244    {
245        Err(invalid(format!(
246            "working directory must be absolute and normalized: {}",
247            path.display()
248        )))
249    } else {
250        Ok(path)
251    }
252}
253
254fn invalid(message: impl Into<String>) -> InfraError {
255    InfraError::InvalidRequest {
256        domain: "host-exec",
257        message: message.into(),
258    }
259}
260
261#[cfg(test)]
262#[path = "host_exec_tests.rs"]
263mod tests;