diff --git a/GLOSSARY.md b/GLOSSARY.md index 37c4b81be..facf4e938 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -315,6 +315,9 @@ _Avoid_: completion email, alert ### Console +**Cron job**: +An entry in a machine account's operating-system crontab that mentions thirdshift. Its recorded schedule and command are shown even when its **Launch directory** or Command kind cannot be recognized, and a displayed entry need not offer a **Console** start choice. + **Console**: The web page a machine serves that shows the serving OS account's **Commands**, opening on its **Inbox**, with each one's child **Ticket runs**, their stages and their logs, and from which a live Command can be stopped and an Inbox row given a **Dismissal**. The **User config** can include readable **Other-account Commands** on that machine, and the Commands of every machine in its **Fleet**, which it can stop and dismiss as their own Consoles can. Each machine serves its own Console, behind a sign-in, and every view and action it offers is also a CLI command. _Avoid_: dashboard, floor, UI, web UI diff --git a/docs/code-navigation.md b/docs/code-navigation.md index f2a698ea4..2d2d1a1cc 100644 --- a/docs/code-navigation.md +++ b/docs/code-navigation.md @@ -80,7 +80,7 @@ Start with the issue's terms in `GLOSSARY.md`, then use this index to find their | Theme, hidden sections, Pause motion | `src/config/console.rs` — built-in names and restricted User config writes; `src/console/look.rs` — CLI/HTTP look actions; `src/console/assets/themes.css` — tokens; `src/console/assets/look.js` — picker and browser motion preference | `tests/console/look.rs`, `console/tests/inbox.test.cjs` | | Custom Theme, Theme import/export, custom fonts | `src/config/console/themes.rs` — portable format, defaults, validation, folder and confined font reads; `src/console/look.rs` — shared saves and Console log; `src/console/assets/look.js` — JSON editor | `tests/console/look.rs`, `console/tests/custom-themes.test.cjs`; format in `console/README.md` | | Console installation, proxy site, system service | `src/console/install.rs` — printed Linux installation script; `src/config/console.rs` — address and port | `tests/console_install.rs` | -| Console Cron jobs, crontab inventory | `src/cronjobs.rs` — read-only account crontab and CLI; `src/console/cronjobs.rs`, `src/console/cronjobs.html` — page rendering | `tests/console/cronjobs.rs`, `console/tests/cronjobs.test.cjs` | +| Cron job, Command kind, crontab inventory | `src/cronjobs.rs` — read-only account crontab and CLI; `src/cronjobs/command.rs` — owned `Interpretation`, recognition and independent Command-kind/Launch/start facts; `src/launch_directories.rs` — filesystem/origin resolution; `src/console/start_controls.rs` — supported Pass start choices; `src/console/cronjobs.rs`, `src/console/cronjobs.html` — page rendering | `src/cronjobs/command.rs` interface behavior tables; `tests/console/cronjobs.rs`, `tests/console/start.rs`, `console/tests/cronjobs.test.cjs`, `console/tests/start.test.cjs` | | Pass last-fired time | `src/launch.rs` — shared Pass entry; `src/logs/recording.rs` — atomic per-kind start replacement; `src/logs/pass_starts.rs` — saved times and shared age display; `src/logs/reading.rs` — per-repository inventory shared by status and Cron jobs | `tests/activity_log.rs`, `tests/status.rs`, `tests/console/cronjobs.rs` | | Stop | `src/logs/reading.rs` — `retained_work` reload by captured record; `src/logs/reading/selection.rs` — `Selection::stop_target`; `src/logs/stop.rs` — shared request, signaling, waiting and inspection; `src/console/stop.rs` — confirmation and authenticated Console action; `src/claim.rs` — Stop endings | `src/logs/reading/tests.rs`, `tests/stop.rs`, `tests/console/stop.rs`, `console/tests/stop.test.cjs`; inline Claim and Stop tests | | Dismissal | `src/logs/dismiss.rs` — shared CLI/Console dismissal and take-back using `Evaluation::into_target`, record-side `*.dismiss.json` files, `Captured` normalizes legacy and accumulated kinds for one read per owning path; `src/console/dismiss.rs` — authenticated Dismiss/Undo form posts; `src/console/peek.rs` and `src/console/peek.html` — Command details actions and CLI forms; `src/logs/reading/inbox.rs` — target visibility before collapse (including explicit History Undo), captured coverage, return on an uncovered kind, pending Finding after a dismissed Failed row, waiting exit, Resolved retention from the dismissal and collapse partition; `src/logs/status.rs` and `src/logs/status/inbox.rs` — text, JSON (`dismissal.kinds`) and Console rows | `tests/dismiss.rs` — health transitions, live endings, Security findings, hidden History membership and Spec-wide kind coverage; `src/logs/reading/tests.rs`, `tests/console/dismiss.rs` | diff --git a/src/console/start_controls.rs b/src/console/start_controls.rs index 72ca1cf5b..86bc82876 100644 --- a/src/console/start_controls.rs +++ b/src/console/start_controls.rs @@ -178,16 +178,20 @@ pub(super) async fn options() -> axum::response::Response { /// Preserve the cron Pass's CLI options, while the API still decides admission. pub(super) fn passes(config: &UserConfig, jobs: &[crate::cronjobs::CronJob]) -> Result> { let directories = crate::launch_directories::load()?; + let home = crate::config::home()?; let mut passes = Vec::new(); for job in jobs { - let Some((path, arguments)) = crate::launch_directories::cron_arguments(&job.command) + let Some(facts) = crate::cronjobs::Interpretation::new(&job.command).into_launch(&home) else { continue; }; + let Ok(path) = facts.directory.canonicalize() else { + continue; + }; let Some(directory) = directories.directories.iter().find(|d| d.path == path) else { continue; }; - let Ok(parsed) = args::parse(&arguments) else { + let Some(parsed) = facts.command else { continue; }; let (mut request, flags, security) = match parsed { diff --git a/src/cronjobs.rs b/src/cronjobs.rs index f34750c9b..c147864c4 100644 --- a/src/cronjobs.rs +++ b/src/cronjobs.rs @@ -7,7 +7,9 @@ use serde::{Deserialize, Serialize}; use crate::config::UserConfig; use crate::logs::pass_starts::{self, PassStart}; +mod command; pub(crate) mod fleet; +pub(crate) use command::{Interpretation, LaunchFacts}; #[derive(Serialize, Deserialize)] pub(crate) struct CronJobs { @@ -89,48 +91,6 @@ where Ok(serde_json::from_value(value).unwrap_or(None)) } -#[expect( - clippy::wildcard_enum_match_arm, - reason = "inventory recognizes work Commands only; inspection and administration commands have unknown kind" -)] -fn command_kind(command: &str) -> Option { - use crate::args::{self, Command as Parsed}; - let words = crate::launch_directories::shell_words(shell_command(command))?; - let invocation = match words.as_slice() { - [cd, path, and, rest @ ..] if cd == "cd" && and == "&&" && !path.contains(['$', '`']) => { - rest - } - rest => rest, - }; - let (executable, arguments) = invocation.split_first()?; - if executable.contains(['$', '`']) - || std::path::Path::new(executable).file_name()? != "thirdshift" - { - return None; - } - if arguments - .iter() - .any(|word| matches!(word.as_str(), "&&" | "||" | ";" | "|" | "&")) - { - return None; - } - let arguments = crate::launch_directories::literal_cli_arguments(arguments)?; - if arguments.is_empty() { - return None; - } - Some(match args::parse(&arguments).ok()? { - Parsed::Pickup(_) => CommandKind::Pickup, - Parsed::Secure(_) => CommandKind::Security, - Parsed::Architect(_) => CommandKind::Architect, - Parsed::Triage(_) => CommandKind::Triage, - Parsed::Fix(_) => CommandKind::Fix, - Parsed::Review(_) => CommandKind::Review, - Parsed::Process(args::ProcessAction::Run(_)) => CommandKind::Process, - // An Issue URL can select a Spec run or Ticket run; labels are not read here. - _ => return None, - }) -} - /// One Fleet report field. A control character or Unicode line separator would /// start another line or a terminal control sequence, so one machine's schedule /// or error could look like another machine's entry. JSON keeps the original text. @@ -168,7 +128,9 @@ pub(crate) fn load() -> Result { let mut jobs = jobs()?; for job in &mut jobs { job.repository = repository_label( - crate::launch_directories::from_cron(&job.command, &home) + Interpretation::new(&job.command) + .into_launch(&home) + .and_then(crate::launch_directories::from_facts) .map(|directory| directory.repository), ); } @@ -249,32 +211,15 @@ fn entry(line: &str) -> Option { schedule.push(&command[..end]); command = command[end..].trim_start(); } - // Keep stdin text for display, but don't match it as a command. - let mentions_thirdshift = shell_command(command) - .split(|c: char| c.is_whitespace() || "'\";&|()<>".contains(c)) - .any(|word| word.rsplit('/').next() == Some("thirdshift")); - mentions_thirdshift.then(|| CronJob { + let interpreted = Interpretation::new(command); + interpreted.recognized().then(|| CronJob { schedule: schedule.join(" "), command: command.into(), repository: None, - command_kind: command_kind(command), + command_kind: interpreted.command_kind(), }) } -/// Cron sends everything after its first unescaped % to stdin, even in quotes. -pub(crate) fn shell_command(command: &str) -> &str { - let mut escaped = false; - let command_end = command - .char_indices() - .find_map(|(index, character)| { - let separator = character == '%' && !escaped; - escaped = character == '\\' && !escaped; - separator.then_some(index) - }) - .unwrap_or(command.len()); - &command[..command_end] -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/cronjobs/command.rs b/src/cronjobs/command.rs new file mode 100644 index 000000000..c0a1d795f --- /dev/null +++ b/src/cronjobs/command.rs @@ -0,0 +1,533 @@ +//! Literal Cron job interpretation, without filesystem reads or shell execution. +use std::path::{Path, PathBuf}; + +use crate::{args, config}; + +use super::CommandKind; + +/// Independent inventory and Launch projections of one command, tokenized once. +pub(crate) struct Interpretation { + recognized: bool, + kind: Option, + launch: Option, +} + +struct LiteralLaunch { + directory: String, + output_log: Option, + command: Option, +} + +pub(crate) struct LaunchFacts { + pub directory: PathBuf, + pub output_log: Option, + /// A parsed candidate; Console still decides which Commands it can start. + pub command: Option, +} + +impl Interpretation { + pub(crate) fn new(command: &str) -> Self { + let command = shell_command(command); + // Inclusion is lexical, so failed shell/CLI parsing cannot hide an entry. + let recognized = command + .split(|c: char| c.is_whitespace() || "'\";&|()<>".contains(c)) + .any(|word| word.rsplit('/').next() == Some("thirdshift")); + let Some(words) = shell_words(command) else { + return Self { + recognized, + kind: None, + launch: None, + }; + }; + let initial = initial_directory(&words); + let invocation = initial.map_or(words.as_slice(), |(_, rest)| rest); + let launch = initial + .and_then(|(path, rest)| launch_invocation(rest).map(|selected| (path, selected))); + let selected = launch.map_or(invocation, |(_, selected)| selected); + let parsed = selected + .split_first() + .and_then(|(_, arguments)| literal_cli_arguments(arguments)) + .and_then(|arguments| args::parse(&arguments).ok()); + let kind = if selected == invocation && is_plain_invocation(invocation) { + parsed.as_ref().and_then(command_kind) + } else { + None + }; + let launch = launch.map(|(directory, selected)| LiteralLaunch { + directory: directory.into(), + output_log: selected + .windows(2) + .find(|pair| matches!(pair[0].as_str(), ">>" | ">" | "1>" | "1>>" | "&>" | "&>>")) + .map(|pair| pair[1].clone()) + .filter(|log| !log.contains(['$', '`'])), + command: parsed, + }); + Self { + recognized, + kind, + launch, + } + } + + pub(crate) fn recognized(&self) -> bool { + self.recognized + } + + pub(crate) fn command_kind(&self) -> Option { + self.kind + } + + /// Expand only literal paths, using the requesting machine's supplied home. + pub(crate) fn into_launch(self, home: &Path) -> Option { + let launch = self.launch?; + let directory = config::expand_home(&launch.directory, home)?; + let output_log = launch + .output_log + .map(|log| config::expand_home(&log, home).unwrap_or_else(|| directory.join(log))); + Some(LaunchFacts { + directory, + output_log, + command: launch.command, + }) + } +} + +fn initial_directory(words: &[String]) -> Option<(&str, &[String])> { + let [cd, path, and, rest @ ..] = words else { + return None; + }; + (cd == "cd" && and == "&&" && !path.contains(['$', '`'])).then_some((path.as_str(), rest)) +} + +fn is_plain_invocation(words: &[String]) -> bool { + let Some((executable, arguments)) = words.split_first() else { + return false; + }; + !executable.contains(['$', '`']) + && Path::new(executable) + .file_name() + .is_some_and(|name| name == "thirdshift") + && !arguments + .iter() + .any(|word| matches!(word.as_str(), "&&" | "||" | ";" | "|" | "&")) +} + +#[expect( + clippy::wildcard_enum_match_arm, + reason = "inventory recognizes work Commands only; inspection and administration commands have unknown kind" +)] +fn command_kind(command: &args::Command) -> Option { + use args::Command as Parsed; + Some(match command { + Parsed::Pickup(_) => CommandKind::Pickup, + Parsed::Secure(_) => CommandKind::Security, + Parsed::Architect(_) => CommandKind::Architect, + Parsed::Triage(_) => CommandKind::Triage, + Parsed::Fix(_) => CommandKind::Fix, + Parsed::Review(_) => CommandKind::Review, + Parsed::Process(args::ProcessAction::Run(_)) => CommandKind::Process, + // An Issue URL can select a Spec run or Ticket run; labels are not read here. + _ => return None, + }) +} + +fn launch_invocation(rest: &[String]) -> Option<&[String]> { + rest.split(|word| matches!(word.as_str(), "&&" | ";" | "||")) + // A later directory change makes the initial cd unreliable. Leave + // these compounds display-only rather than infer the wrong checkout. + .take_while(|part| { + let executable = part.iter().map(String::as_str).find(|word| { + !word.contains('=') && !matches!(*word, "command" | "builtin" | "--" | "-p" | "!") + }); + !matches!( + executable, + Some("cd" | "pushd" | "popd" | "eval" | "source" | ".") + ) + }) + .find(|part| { + let executable = if part.first().is_some_and(|word| { + Path::new(word) + .file_name() + .is_some_and(|name| name == "env") + }) { + // Retain launcher words in the candidate: starting it must not + // silently discard the environment used by the Cron job. + part.iter().skip(1).find(|word| !word.contains('=')) + } else { + part.first() + }; + executable.is_some_and(|word| { + Path::new(word) + .file_name() + .is_some_and(|name| name == "thirdshift") + }) + }) +} + +/// Cron sends everything after its first unescaped % to stdin, even in quotes. +fn shell_command(command: &str) -> &str { + let mut escaped = false; + let command_end = command + .char_indices() + .find_map(|(index, character)| { + let separator = character == '%' && !escaped; + escaped = character == '\\' && !escaped; + separator.then_some(index) + }) + .unwrap_or(command.len()); + &command[..command_end] +} + +// Keep literal CLI words before shell redirects; expansions remain unknown. +fn literal_cli_arguments(words: &[String]) -> Option> { + let arguments: Vec<_> = words + .iter() + .take_while(|word| { + let operator = word.trim_start_matches(|c: char| c.is_ascii_digit()); + !matches!( + operator, + ">" | ">>" | "&>" | "&>>" | "<" | ">&" | "<&" | "#" + ) + }) + .cloned() + .collect(); + if arguments.iter().any(|word| word.contains(['$', '`'])) { + return None; + } + Some(arguments) +} + +// shlex splits shell words, but leaves operators attached to those words. +// Separate unquoted operators first, preserving quotes and escaped characters. +fn shell_words(command: &str) -> Option> { + let mut spaced = String::new(); + let mut quote = None; + let mut escaped = false; + let mut chars = command.chars().peekable(); + while let Some(character) = chars.next() { + if escaped { + spaced.push(character); + escaped = false; + continue; + } + if character == '\\' && quote != Some('\'') { + spaced.push(character); + escaped = true; + continue; + } + if quote == Some(character) { + quote = None; + } else if quote.is_none() && matches!(character, '\'' | '"') { + quote = Some(character); + } else if quote.is_none() && "&|;<>".contains(character) { + // An attached IO number belongs to its redirect; standalone + // numbers remain CLI arguments (parallel 2 >> log). + let word_start = spaced.rfind(char::is_whitespace).map_or(0, |at| at + 1); + let word = &spaced[word_start..]; + let io_number = matches!(character, '<' | '>') + && !word.is_empty() + && word.bytes().all(|byte| byte.is_ascii_digit()); + if !io_number { + spaced.push(' '); + } + spaced.push(character); + if chars.peek() == Some(&character) + || matches!(character, '>' | '<') && chars.peek() == Some(&'&') + { + spaced.push(chars.next()?); + } else if character == '&' && chars.peek() == Some(&'>') { + spaced.push(chars.next()?); + if chars.peek() == Some(&'>') { + spaced.push(chars.next()?); + } + } + spaced.push(' '); + continue; + } + spaced.push(character); + } + shlex::split(&spaced) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn directory_changing_launch_forms_leave_later_commands_unresolved() { + for change in [ + "cd /other", + "pushd /other", + "popd", + "command cd /other", + "command -p -- cd /other", + "builtin cd /other", + "CDPATH=.. cd other", + "! cd /other", + "eval 'cd /other'", + "source /change-directory.sh", + ". /change-directory.sh", + ] { + let text = format!("cd /repo && {change} && thirdshift pickup"); + let interpreted = Interpretation::new(&text); + assert!(interpreted.recognized(), "{text}"); + assert!(interpreted.command_kind().is_none(), "{text}"); + assert!( + interpreted.into_launch(Path::new("/home/test")).is_none(), + "{text}" + ); + } + } + + #[test] + fn literal_launch_facts_preserve_cron_separators_paths_and_stdout_logs() { + let home = Path::new("/home/test"); + for (text, directory, log, kind, candidate) in [ + ( + "cd /repo&&thirdshift pickup parallel 2>>cron.log 2>&1", + Some("/repo"), + None, + None, + false, + ), + ( + "cd /repo&&thirdshift pickup parallel 2 >>cron.log 2>&1", + Some("/repo"), + Some("/repo/cron.log"), + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup parallel 2 2>>errors.log", + Some("/repo"), + None, + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup parallel 2 2>&1", + Some("/repo"), + None, + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup parallel 2 &>>~/cron.log", + Some("/repo"), + Some("/home/test/cron.log"), + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup parallel 2 1>/tmp/cron.log", + Some("/repo"), + Some("/tmp/cron.log"), + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup parallel 2 >'$LOG'", + Some("/repo"), + None, + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup parallel 2 >'`log`'", + Some("/repo"), + None, + Some("Pickup run"), + true, + ), + ( + "cd relative && thirdshift pickup", + None, + None, + Some("Pickup run"), + false, + ), + ( + "cd ~someone/repo && thirdshift pickup", + None, + None, + Some("Pickup run"), + false, + ), + ( + "cd '$HOME/repo' && thirdshift pickup", + None, + None, + None, + false, + ), + ( + "cd /repo && thirdshift pickup%stdin && cd elsewhere", + Some("/repo"), + None, + Some("Pickup run"), + true, + ), + ( + "cd /repo && thirdshift pickup >'quoted%log'", + None, + None, + None, + false, + ), + ( + r"cd /repo && thirdshift pickup >escaped\%log", + Some("/repo"), + Some("/repo/escaped%log"), + Some("Pickup run"), + true, + ), + ( + r"cd /repo && thirdshift pickup >escaped\\%stdin", + Some("/repo"), + Some("/repo/escaped\\"), + Some("Pickup run"), + true, + ), + ] { + let interpreted = Interpretation::new(text); + assert!(interpreted.recognized(), "{text}"); + assert_eq!( + interpreted + .command_kind() + .map(|kind| kind.to_string()) + .as_deref(), + kind, + "{text}" + ); + let facts = interpreted.into_launch(home); + assert_eq!( + facts.as_ref().map(|facts| facts.directory.as_path()), + directory.map(Path::new), + "{text}" + ); + assert_eq!( + facts.as_ref().and_then(|facts| facts.output_log.as_deref()), + log.map(Path::new), + "{text}" + ); + let parsed = facts.and_then(|facts| facts.command); + if candidate { + let Some(args::Command::Pickup(pass)) = parsed else { + panic!("expected Pickup: {text}"); + }; + if text.contains("parallel 2") { + assert_eq!(pass.flags.parallel.map(|n| n.get()), Some(2), "{text}"); + } + } else { + assert!(parsed.is_none(), "{text}"); + } + } + } + + #[test] + fn interpretation_preserves_independent_cron_job_facts() { + let home = std::path::Path::new("/home/test"); + // Command kind and a parsed start candidate are independent projections. + for (text, shown, kind, directory, candidate) in [ + ("thirdshift pickup", true, Some("Pickup run"), None, false), + ( + "cd ~/repo && thirdshift pickup", + true, + Some("Pickup run"), + Some("/home/test/repo"), + true, + ), + ( + "cd ~/repo && env X=1 thirdshift pickup", + true, + None, + Some("/home/test/repo"), + false, + ), + ( + "cd ~/repo && thirdshift status --json", + true, + None, + Some("/home/test/repo"), + true, + ), + ( + "cd ~/repo && thirdshift pickup --unknown", + true, + None, + Some("/home/test/repo"), + false, + ), + ( + "cd ~/repo && thirdshift pickup base '$BASE'", + true, + None, + Some("/home/test/repo"), + false, + ), + ( + "cd ~/repo && cd /elsewhere && thirdshift pickup", + true, + None, + None, + false, + ), + ( + "cd ~/repo && thirdshift pickup && echo done", + true, + None, + Some("/home/test/repo"), + true, + ), + ( + "cd ~/repo && thirdshift fix https://github.com/acme/widgets/pull/7", + true, + Some("Fix run"), + Some("/home/test/repo"), + true, + ), + ( + "cd ~/repo && thirdshift review https://github.com/acme/widgets/pull/7", + true, + Some("Review run"), + Some("/home/test/repo"), + true, + ), + ( + "cd ~/repo && thirdshift triage", + true, + Some("Triage run"), + Some("/home/test/repo"), + true, + ), + ("cd '~/repo && thirdshift pickup", true, None, None, false), + ("backup%thirdshift pickup", false, None, None, false), + ] { + let interpreted = Interpretation::new(text); + assert_eq!(interpreted.recognized(), shown, "{text}"); + assert_eq!( + interpreted + .command_kind() + .map(|kind| kind.to_string()) + .as_deref(), + kind, + "{text}" + ); + let facts = interpreted.into_launch(home); + assert_eq!( + facts.as_ref().map(|facts| facts.directory.as_path()), + directory.map(std::path::Path::new), + "{text}" + ); + assert_eq!( + facts.as_ref().is_some_and(|facts| facts.command.is_some()), + candidate, + "{text}" + ); + assert!( + facts.is_none_or(|facts| facts.output_log.is_none()), + "{text}" + ); + } + } +} diff --git a/src/launch_directories.rs b/src/launch_directories.rs index 3122ca896..7c49ba0ad 100644 --- a/src/launch_directories.rs +++ b/src/launch_directories.rs @@ -36,7 +36,10 @@ pub(crate) fn load() -> Result { }; let mut directories = BTreeMap::new(); for job in jobs { - if let Some(directory) = from_cron(&job.command, &home) { + if let Some(directory) = cronjobs::Interpretation::new(&job.command) + .into_launch(&home) + .and_then(from_facts) + { directories .entry(directory.path.clone()) .or_insert(directory); @@ -65,157 +68,13 @@ fn resolve(path: &Path) -> Result { }) } -// Parse literal shell words only. Never execute cron text to discover a path. -pub(crate) fn from_cron(command: &str, home: &Path) -> Option { - let invocation = cron_invocation(command, home)?; - let mut directory = resolve(&invocation.directory).ok()?; - directory.output_log = invocation.output_log; +/// Resolve literal facts supplied by Cron job interpretation; never run Cron text. +pub(crate) fn from_facts(facts: cronjobs::LaunchFacts) -> Option { + let mut directory = resolve(&facts.directory).ok()?; + directory.output_log = facts.output_log; Some(directory) } -struct CronInvocation { - directory: PathBuf, - words: Vec, - output_log: Option, -} - -fn cron_invocation(command: &str, home: &Path) -> Option { - let words = shell_words(cronjobs::shell_command(command))?; - let [cd, path, and, rest @ ..] = words.as_slice() else { - return None; - }; - if cd != "cd" || and != "&&" || path.contains(['$', '`']) { - return None; - } - let thirdshift = rest - .split(|word| matches!(word.as_str(), "&&" | ";" | "||")) - // A later directory change makes the initial cd unreliable. Leave - // these compounds display-only rather than infer the wrong checkout. - .take_while(|part| { - let executable = part.iter().map(String::as_str).find(|word| { - !word.contains('=') && !matches!(*word, "command" | "builtin" | "--" | "-p" | "!") - }); - !matches!( - executable, - Some("cd" | "pushd" | "popd" | "eval" | "source" | ".") - ) - }) - .find(|part| { - let executable = if part.first().is_some_and(|word| { - Path::new(word) - .file_name() - .is_some_and(|name| name == "env") - }) { - // A literal env wrapper still uses this cd directory. Keep the - // wrapper words below so Run now cannot discard its environment. - part.iter().skip(1).find(|word| !word.contains('=')) - } else { - part.first() - }; - executable.is_some_and(|word| { - Path::new(word) - .file_name() - .is_some_and(|name| name == "thirdshift") - }) - })?; - let path = config::expand_home(path, home)?; - let output_log = thirdshift - .windows(2) - .find(|pair| matches!(pair[0].as_str(), ">>" | ">" | "1>" | "1>>" | "&>" | "&>>")) - .and_then(|pair| { - let log = &pair[1]; - if log.contains(['$', '`']) { - return None; - } - config::expand_home(log, home).or_else(|| Some(path.join(log))) - }); - Some(CronInvocation { - directory: path, - words: thirdshift.iter().skip(1).cloned().collect(), - output_log, - }) -} - -/// A literal cron invocation that can also be represented by the start API. -/// Shell expressions remain display-only; no cron text is ever executed here. -pub(crate) fn cron_arguments(command: &str) -> Option<(PathBuf, Vec)> { - let home = config::home().ok()?; - let invocation = cron_invocation(command, &home)?; - let arguments = literal_cli_arguments(&invocation.words)?; - Some((invocation.directory.canonicalize().ok()?, arguments)) -} - -/// Keep literal CLI words before shell redirects; expansions remain unknown. -pub(crate) fn literal_cli_arguments(words: &[String]) -> Option> { - let arguments: Vec<_> = words - .iter() - .take_while(|word| { - let operator = word.trim_start_matches(|c: char| c.is_ascii_digit()); - !matches!( - operator, - ">" | ">>" | "&>" | "&>>" | "<" | ">&" | "<&" | "#" - ) - }) - .cloned() - .collect(); - if arguments.iter().any(|word| word.contains(['$', '`'])) { - return None; - } - Some(arguments) -} - -// shlex splits shell words, but leaves operators attached to those words. -// Separate unquoted operators first, preserving quotes and escaped characters. -pub(crate) fn shell_words(command: &str) -> Option> { - let mut spaced = String::new(); - let mut quote = None; - let mut escaped = false; - let mut chars = command.chars().peekable(); - while let Some(character) = chars.next() { - if escaped { - spaced.push(character); - escaped = false; - continue; - } - if character == '\\' && quote != Some('\'') { - spaced.push(character); - escaped = true; - continue; - } - if quote == Some(character) { - quote = None; - } else if quote.is_none() && matches!(character, '\'' | '"') { - quote = Some(character); - } else if quote.is_none() && "&|;<>".contains(character) { - // A contiguous IO number belongs to the redirect (2>&1); a - // standalone number remains a CLI argument (parallel 2 >> log). - let word_start = spaced.rfind(char::is_whitespace).map_or(0, |at| at + 1); - let word = &spaced[word_start..]; - let io_number = matches!(character, '<' | '>') - && !word.is_empty() - && word.bytes().all(|byte| byte.is_ascii_digit()); - if !io_number { - spaced.push(' '); - } - spaced.push(character); - if chars.peek() == Some(&character) - || matches!(character, '>' | '<') && chars.peek() == Some(&'&') - { - spaced.push(chars.next()?); - } else if character == '&' && chars.peek() == Some(&'>') { - spaced.push(chars.next()?); - if chars.peek() == Some(&'>') { - spaced.push(chars.next()?); - } - } - spaced.push(' '); - continue; - } - spaced.push(character); - } - shlex::split(&spaced) -} - pub fn show(json: bool) -> Result { let inventory = load()?; if json { diff --git a/tests/console/cronjobs.rs b/tests/console/cronjobs.rs index 489c2ac32..23b38fd6d 100644 --- a/tests/console/cronjobs.rs +++ b/tests/console/cronjobs.rs @@ -39,6 +39,153 @@ fn checkout(home: &Path, name: &str, origin: &str) -> std::path::PathBuf { path } +#[test] +fn cron_job_projections_agree_without_conflating_kind_directory_and_start() { + let home = fixture(); + let repo = checkout(home.path(), "repo", "https://github.com/acme/widgets.git"); + checkout( + home.path(), + "elsewhere", + "https://gitlab.com/acme/widgets.git", + ); + fs::write(home.path().join("crontab"), "").unwrap(); + let mut start = command(home.path()); + start.args(["console", "--no-sign-in"]); + let console = Console::start_command(start); + for (text, kind, resolved, offered) in [ + ("thirdshift pickup", Some("Pickup run"), false, false), + ( + "cd ~/repo && thirdshift pickup", + Some("Pickup run"), + true, + true, + ), + ("cd ~/repo && env X=1 thirdshift pickup", None, true, false), + ( + "cd ~/repo && /usr/bin/env X=1 thirdshift pickup", + None, + true, + false, + ), + ("cd ~/repo && thirdshift status --json", None, true, false), + ( + "cd ~/repo && thirdshift pickup --unknown", + None, + true, + false, + ), + ( + "cd ~/repo && thirdshift pickup base '$BASE'", + None, + true, + false, + ), + ( + "cd ~/repo && thirdshift pickup base '`branch`'", + None, + true, + false, + ), + ( + "cd ~/missing && thirdshift pickup", + Some("Pickup run"), + false, + false, + ), + ( + "cd ~/elsewhere && thirdshift pickup", + Some("Pickup run"), + false, + false, + ), + ( + "cd ~/repo && command -p -- cd /elsewhere && thirdshift pickup", + None, + false, + false, + ), + ( + "cd ~/repo && echo ready && thirdshift pickup && echo done", + None, + true, + true, + ), + ( + "cd ~/repo && thirdshift pickup && echo done", + None, + true, + true, + ), + ( + "cd ~/repo && thirdshift fix https://github.com/acme/widgets/pull/7", + Some("Fix run"), + true, + false, + ), + ( + "cd ~/repo && thirdshift review https://github.com/acme/widgets/pull/7", + Some("Review run"), + true, + false, + ), + ( + "cd ~/repo && thirdshift triage", + Some("Triage run"), + true, + false, + ), + ("cd '~/repo && thirdshift pickup", None, false, false), + ] { + fs::write(home.path().join("crontab"), format!("@daily {text}\n")).unwrap(); + let inventory = command(home.path()) + .args(["cronjobs", "--json"]) + .output() + .unwrap(); + assert!(inventory.status.success(), "{inventory:?}"); + let inventory: Value = serde_json::from_slice(&inventory.stdout).unwrap(); + assert_eq!( + inventory["jobs"], + json!([{ + "schedule": "@daily", "command": text, "command_kind": kind, + "repository": resolved.then_some("acme/widgets"), + }]), + "{text}" + ); + assert_eq!( + api::json(console.get("/api/v1/cronjobs")), + inventory, + "{text}" + ); + let directories = command(home.path()) + .args(["launch-directories", "--json"]) + .output() + .unwrap(); + assert!(directories.status.success(), "{directories:?}"); + let directories: Value = serde_json::from_slice(&directories.stdout).unwrap(); + let expected = if resolved { + json!({"directories": [{"repository": "acme/widgets", "path": repo}]}) + } else { + json!({"directories": []}) + }; + assert_eq!(directories, expected, "{text}"); + assert_eq!( + api::json(console.get("/api/v1/launch-directories")), + expected, + "{text}" + ); + let options = api::json(console.get("/api/v1/start-options")); + let passes = options["passes"].as_array().unwrap(); + assert_eq!(passes.len(), usize::from(offered), "{text}: {options}"); + if offered { + assert_eq!(passes[0]["command"], text); + let request: Value = + serde_json::from_str(passes[0]["request"].as_str().unwrap()).unwrap(); + assert_eq!(request["command"], "pickup"); + assert_eq!(request["repository"], "acme/widgets"); + } + } +} + #[test] fn cronjobs_classifies_literal_commands_without_executing_them() { let home = fixture();