1use crate::ComputeCliError;
4
5const MAX_SELECTOR_BYTES: usize = 64;
6const MAX_DEVICES: usize = 64;
7const MAX_PROFILE_BYTES: usize = 64 * 1024;
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum OutputMode {
12 Text,
14 Json,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum ProfileAction {
21 List,
23 Read,
25 Save,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum ComputeCommand {
32 Help,
34 Devices(Selection),
36 Probe(Selection),
38 Profile(ProfileRequest),
40 Explain(ProfileRequest),
42 Recipe(RecipeRequest),
44 Acceptance(AcceptanceRequest),
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct Selection {
51 pub selector: Option<String>,
53 pub output: OutputMode,
55 pub max_devices: usize,
57}
58
59#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct ProfileRequest {
62 pub action: ProfileAction,
64 pub key: Option<String>,
66 pub adapter: String,
68 pub driver: String,
70 pub backend: String,
72 pub now_tick: u64,
74 pub output: OutputMode,
76 pub max_profile_bytes: usize,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct RecipeRequest {
83 pub id: String,
85 pub output: OutputMode,
87}
88
89#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum AcceptanceAction {
92 Capture,
94 Verify,
96 Import,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct AcceptanceRequest {
103 pub action: AcceptanceAction,
105 pub manifest: Option<String>,
107 pub source: String,
109 pub target: Option<String>,
111 pub output: Option<String>,
113 pub input: Option<String>,
115}
116
117pub fn parse_compute_args(args: &[String]) -> Result<ComputeCommand, ComputeCliError> {
119 let args = strip_verb(args);
120 if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
121 return Ok(ComputeCommand::Help);
122 }
123 match args[0].as_str() {
124 "devices" => Ok(ComputeCommand::Devices(parse_selection(
125 "devices",
126 &args[1..],
127 )?)),
128 "probe" => Ok(ComputeCommand::Probe(parse_selection("probe", &args[1..])?)),
129 "profile" => Ok(ComputeCommand::Profile(parse_profile(&args[1..])?)),
130 "explain" => {
131 let mut request = parse_profile(&args[1..])?;
132 if request.action == ProfileAction::List {
133 request.action = ProfileAction::Read;
134 }
135 Ok(ComputeCommand::Explain(request))
136 }
137 "recipe" => Ok(ComputeCommand::Recipe(parse_recipe(&args[1..])?)),
138 "acceptance" => Ok(ComputeCommand::Acceptance(parse_acceptance(&args[1..])?)),
139 other => Err(ComputeCliError::new(format!(
140 "unknown compute verb: {other}"
141 ))),
142 }
143}
144
145fn strip_verb(args: &[String]) -> &[String] {
146 if args.first().is_some_and(|arg| arg == "compute") {
147 &args[1..]
148 } else {
149 args
150 }
151}
152
153fn parse_selection(verb: &str, args: &[String]) -> Result<Selection, ComputeCliError> {
154 let mut output = OutputMode::Text;
155 let mut selector = None;
156 let mut max_devices = 16;
157 let mut i = 0;
158 while i < args.len() {
159 match args[i].as_str() {
160 "--json" => output = OutputMode::Json,
161 "--max-devices" => {
162 max_devices = bounded_usize(take_value(args, &mut i, "--max-devices")?)?;
163 if max_devices == 0 || max_devices > MAX_DEVICES {
164 return Err(ComputeCliError::new("max-devices is outside policy"));
165 }
166 }
167 value if value.starts_with('-') => {
168 return Err(ComputeCliError::new(format!(
169 "unknown {verb} option: {value}"
170 )));
171 }
172 value => {
173 checked_selector(value)?;
174 if selector.replace(value.to_owned()).is_some() {
175 return Err(ComputeCliError::new(format!(
176 "{verb} accepts at most one selector"
177 )));
178 }
179 }
180 }
181 i += 1;
182 }
183 Ok(Selection {
184 selector,
185 output,
186 max_devices,
187 })
188}
189
190fn parse_profile(args: &[String]) -> Result<ProfileRequest, ComputeCliError> {
191 let mut request = ProfileRequest {
192 action: ProfileAction::List,
193 key: None,
194 adapter: "modeled-adapter".to_owned(),
195 driver: "modeled-driver".to_owned(),
196 backend: "modeled".to_owned(),
197 now_tick: 0,
198 output: OutputMode::Text,
199 max_profile_bytes: 8192,
200 };
201 let mut i = 0;
202 while i < args.len() {
203 match args[i].as_str() {
204 "list" => request.action = ProfileAction::List,
205 "read" => request.action = ProfileAction::Read,
206 "save" => request.action = ProfileAction::Save,
207 "--json" => request.output = OutputMode::Json,
208 "--key" => request.key = Some(checked_value(take_value(args, &mut i, "--key")?)?),
209 "--adapter" => request.adapter = checked_value(take_value(args, &mut i, "--adapter")?)?,
210 "--driver" => request.driver = checked_value(take_value(args, &mut i, "--driver")?)?,
211 "--backend" => request.backend = checked_value(take_value(args, &mut i, "--backend")?)?,
212 "--now" => request.now_tick = bounded_u64(take_value(args, &mut i, "--now")?)?,
213 "--max-profile-bytes" => {
214 request.max_profile_bytes =
215 bounded_usize(take_value(args, &mut i, "--max-profile-bytes")?)?;
216 if request.max_profile_bytes == 0 || request.max_profile_bytes > MAX_PROFILE_BYTES {
217 return Err(ComputeCliError::new("max-profile-bytes is outside policy"));
218 }
219 }
220 value if value.starts_with('-') => {
221 return Err(ComputeCliError::new(format!(
222 "unknown profile option: {value}"
223 )));
224 }
225 value => {
226 return Err(ComputeCliError::new(format!(
227 "unknown profile action: {value}"
228 )));
229 }
230 }
231 i += 1;
232 }
233 Ok(request)
234}
235
236fn parse_acceptance(args: &[String]) -> Result<AcceptanceRequest, ComputeCliError> {
237 let Some(action) = args.first() else {
238 return Err(ComputeCliError::new("acceptance requires an action"));
239 };
240 let mut request = AcceptanceRequest {
241 action: match action.as_str() {
242 "capture" => AcceptanceAction::Capture,
243 "verify" => AcceptanceAction::Verify,
244 "import" => AcceptanceAction::Import,
245 other => {
246 return Err(ComputeCliError::new(format!(
247 "unknown acceptance action: {other}"
248 )));
249 }
250 },
251 manifest: None,
252 source: String::new(),
253 target: None,
254 output: None,
255 input: None,
256 };
257 let mut i = 1;
258 while i < args.len() {
259 match args[i].as_str() {
260 "--manifest" => {
261 request.manifest = Some(checked_path(take_value(args, &mut i, "--manifest")?)?)
262 }
263 "--source" => request.source = checked_hash(take_value(args, &mut i, "--source")?)?,
264 "--target" => {
265 request.target = Some(checked_capability(take_value(args, &mut i, "--target")?)?)
266 }
267 "--output" => {
268 request.output = Some(checked_path(take_value(args, &mut i, "--output")?)?)
269 }
270 value if value.starts_with('-') => {
271 return Err(ComputeCliError::new(format!(
272 "unknown acceptance option: {value}"
273 )));
274 }
275 value => {
276 let path = checked_path(value.to_owned())?;
277 if request.input.replace(path).is_some() {
278 return Err(ComputeCliError::new(
279 "acceptance accepts at most one input artifact",
280 ));
281 }
282 }
283 }
284 i += 1;
285 }
286 if request.source.is_empty() {
287 return Err(ComputeCliError::new("acceptance requires --source"));
288 }
289 match request.action {
290 AcceptanceAction::Capture => {
291 if request.manifest.is_none()
292 || request.target.is_none()
293 || request.output.is_none()
294 || request.input.is_some()
295 {
296 return Err(ComputeCliError::new(
297 "acceptance capture requires --manifest, --target, and --output only",
298 ));
299 }
300 }
301 AcceptanceAction::Verify | AcceptanceAction::Import => {
302 if request.input.is_none()
303 || request.manifest.is_some()
304 || request.target.is_some()
305 || request.output.is_some()
306 {
307 return Err(ComputeCliError::new(
308 "acceptance verify/import requires one input artifact",
309 ));
310 }
311 }
312 }
313 Ok(request)
314}
315
316fn parse_recipe(args: &[String]) -> Result<RecipeRequest, ComputeCliError> {
317 let mut output = OutputMode::Text;
318 let mut id = "inspect-compute-device".to_owned();
319 for arg in args {
320 match arg.as_str() {
321 "--json" => output = OutputMode::Json,
322 value if value.starts_with('-') => {
323 return Err(ComputeCliError::new(format!(
324 "unknown recipe option: {value}"
325 )));
326 }
327 value => id = checked_value(value.to_owned())?,
328 }
329 }
330 if id != "inspect-compute-device" {
331 return Err(ComputeCliError::new(format!(
332 "unknown compute recipe: {id}"
333 )));
334 }
335 Ok(RecipeRequest { id, output })
336}
337
338fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result<String, ComputeCliError> {
339 *i += 1;
340 args.get(*i)
341 .filter(|value| !value.starts_with('-'))
342 .cloned()
343 .ok_or_else(|| ComputeCliError::new(format!("{flag} requires a value")))
344}
345
346fn checked_value(value: String) -> Result<String, ComputeCliError> {
347 checked_selector(&value)?;
348 Ok(value)
349}
350
351fn checked_selector(value: &str) -> Result<(), ComputeCliError> {
352 if value.is_empty() || value.len() > MAX_SELECTOR_BYTES || value.contains("..") {
353 return Err(ComputeCliError::new("selector is outside policy"));
354 }
355 if !value
356 .chars()
357 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.'))
358 {
359 return Err(ComputeCliError::new(
360 "selector contains unsupported characters",
361 ));
362 }
363 Ok(())
364}
365
366fn checked_path(value: String) -> Result<String, ComputeCliError> {
367 if value.is_empty()
368 || value.len() > 240
369 || value.contains('\0')
370 || value.contains('\n')
371 || value.contains('\r')
372 {
373 return Err(ComputeCliError::new("path is outside acceptance policy"));
374 }
375 Ok(value)
376}
377
378fn checked_capability(value: String) -> Result<String, ComputeCliError> {
379 if value.is_empty()
380 || value.len() > MAX_SELECTOR_BYTES
381 || value.contains("..")
382 || !value
383 .chars()
384 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '-' | '_' | '/' | '.'))
385 {
386 return Err(ComputeCliError::new(
387 "target capability is outside acceptance policy",
388 ));
389 }
390 Ok(value)
391}
392
393fn checked_hash(value: String) -> Result<String, ComputeCliError> {
394 if value.len() != 40 || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
395 return Err(ComputeCliError::new("source must be a 40-hex commit"));
396 }
397 Ok(value.to_ascii_lowercase())
398}
399
400fn bounded_usize(value: String) -> Result<usize, ComputeCliError> {
401 value
402 .parse::<usize>()
403 .map_err(|_| ComputeCliError::new("numeric ceiling must be an unsigned integer"))
404}
405
406fn bounded_u64(value: String) -> Result<u64, ComputeCliError> {
407 value
408 .parse::<u64>()
409 .map_err(|_| ComputeCliError::new("tick must be an unsigned integer"))
410}