openmoat_core/secret/
command.rs1use serde::{Deserialize, Serialize};
6
7pub const MIN_LIFETIME_SECS: u64 = 60;
9pub const MAX_LIFETIME_SECS: u64 = 12 * 60 * 60;
12pub const MIN_REFRESH_BEFORE_SECS: u64 = 10;
15pub const MAX_ARGS: usize = 64;
17
18const SHARED_TEMP: &[&str] = &[
23 "/tmp/",
24 "/private/tmp/",
25 "/var/tmp/",
26 "/private/var/tmp/",
27 "/dev/shm/",
28];
29
30#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct CommandSource {
35 pub run: Vec<String>,
38 pub lifetime: String,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub refresh_before: Option<String>,
45 #[serde(default, skip_serializing_if = "CommandHome::is_private")]
48 pub home: CommandHome,
49}
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum CommandHome {
55 #[default]
58 Private,
59 User,
62}
63
64impl CommandHome {
65 #[must_use]
67 pub fn is_private(&self) -> bool {
68 *self == Self::Private
69 }
70}
71
72impl std::fmt::Debug for CommandSource {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("CommandSource")
75 .field("program", &self.program())
76 .finish_non_exhaustive()
77 }
78}
79
80impl CommandSource {
81 #[must_use]
83 pub fn program(&self) -> &str {
84 self.run.first().map_or("", String::as_str)
85 }
86
87 #[must_use]
89 pub fn lifetime_secs(&self) -> u64 {
90 duration_secs(&self.lifetime).unwrap_or(MIN_LIFETIME_SECS)
91 }
92
93 #[must_use]
96 pub fn shared_temp_dir(path: &str) -> Option<&'static str> {
97 SHARED_TEMP
98 .iter()
99 .copied()
100 .find(|dir| path.starts_with(dir))
101 }
102
103 #[must_use]
106 pub fn refresh_before_secs(&self) -> u64 {
107 self.refresh_before
108 .as_deref()
109 .and_then(duration_secs)
110 .unwrap_or(self.lifetime_secs() / 5)
111 .max(MIN_REFRESH_BEFORE_SECS)
112 }
113}
114
115pub(super) fn check(source: &CommandSource) -> Result<(), String> {
117 let program = source.program();
118 if source.run.is_empty() || source.run.len() > MAX_ARGS {
119 return Err(format!(
120 "source command `run` needs a program and at most {} arguments",
121 MAX_ARGS - 1
122 ));
123 }
124 if source
125 .run
126 .iter()
127 .any(|word| word.chars().any(char::is_control))
128 {
129 return Err("source command `run` holds a control character".into());
130 }
131 if !(crate::paths::is_absolute(program) || program.starts_with("~/")) {
132 return Err(format!(
133 "source command `{program}` must be an absolute path or start with `~/`: a bare \
134 name is looked up in a search path the agent may change"
135 ));
136 }
137 let slashed = program.replace('\\', "/");
138 if slashed.split('/').any(|part| part == ".." || part == ".") {
139 return Err(format!(
140 "source command `{program}` must not name `.` or `..`"
141 ));
142 }
143 if let Some(dir) = CommandSource::shared_temp_dir(&slashed) {
144 return Err(format!(
145 "source command `{program}` is under {dir}, where any user, the agent too, can put \
146 a file"
147 ));
148 }
149 let lifetime = duration_secs(&source.lifetime).ok_or_else(|| {
150 format!(
151 "source command lifetime `{}` is not a duration such as `90s`, `15m` or `1h`",
152 source.lifetime
153 )
154 })?;
155 if !(MIN_LIFETIME_SECS..=MAX_LIFETIME_SECS).contains(&lifetime) {
156 return Err(format!(
157 "source command lifetime `{}` must be from 1m to 12h",
158 source.lifetime
159 ));
160 }
161 if let Some(before) = &source.refresh_before {
162 let secs = duration_secs(before).ok_or_else(|| {
163 format!("source command refresh_before `{before}` is not a duration such as `2m`")
164 })?;
165 if secs < MIN_REFRESH_BEFORE_SECS || secs >= lifetime {
166 return Err(format!(
167 "source command refresh_before `{before}` must be at least 10s and shorter than \
168 the lifetime"
169 ));
170 }
171 }
172 Ok(())
173}
174
175#[must_use]
177pub fn duration_secs(text: &str) -> Option<u64> {
178 let unit = match text.as_bytes().last()? {
179 b's' => 1,
180 b'm' => 60,
181 b'h' => 60 * 60,
182 _ => return None,
183 };
184 let digits = &text[..text.len() - 1];
185 if digits.is_empty() || digits.len() > 6 || !digits.bytes().all(|b| b.is_ascii_digit()) {
186 return None;
187 }
188 digits.parse::<u64>().ok().map(|n| n * unit)
189}