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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum HostExecCommand {
19 Cat,
21 Head,
23 Tail,
25 Grep,
27 Rg,
29 Ls,
31 Tree,
33 Wc,
35 Uniq,
37 Diff,
39 Stat,
41 File,
43 Du,
45 Df,
47 Pwd,
49 Hostname,
51 Uptime,
53 Whoami,
55}
56
57impl HostExecCommand {
58 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 #[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#[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 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 #[must_use]
159 pub fn operation_id(&self) -> &OperationId {
160 &self.operation_id
161 }
162 #[must_use]
164 pub fn operation(&self) -> &OperationName {
165 &self.operation
166 }
167 #[must_use]
169 pub const fn command(&self) -> HostExecCommand {
170 self.command
171 }
172 #[must_use]
174 pub fn args(&self) -> &[String] {
175 &self.args
176 }
177 #[must_use]
179 pub fn working_dir(&self) -> Option<&Path> {
180 self.working_dir.as_deref()
181 }
182 #[must_use]
184 pub const fn deadline(&self) -> Timestamp {
185 self.deadline
186 }
187 #[must_use]
189 pub const fn max_stdout_bytes(&self) -> usize {
190 MAX_OUTPUT_BYTES
191 }
192 #[must_use]
194 pub const fn max_stderr_bytes(&self) -> usize {
195 MAX_OUTPUT_BYTES
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201pub struct HostExecReceipt {
202 pub host: HostId,
204 pub topology_revision: TopologyRevision,
206 pub command: HostExecCommand,
208 pub args: Vec<String>,
210 pub working_dir: Option<PathBuf>,
212 pub stdout: String,
214 pub stderr: String,
216 pub exit_code: Option<i32>,
218 pub truncated: bool,
220 pub encoding_lossy: bool,
222 pub send_state: MutationSendState,
224}
225
226#[async_trait]
228pub trait HostExecMutator: Send + Sync {
229 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;