From 1353757ec3d86244f801bf8fe4511676a2a3b9f3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 09:30:04 +0000 Subject: [PATCH 1/2] Fix cron installation safety on qr init Co-authored-by: Aanish Bhirud --- src/main.rs | 52 ++++++++++++----------------- src/shell.rs | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 32 deletions(-) diff --git a/src/main.rs b/src/main.rs index b988cb0..13d362d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -566,40 +566,28 @@ fn restrict_config_permissions(path: &std::path::Path) -> Result<()> { fn install_cron() -> Result<()> { let exe = env::current_exe().context("Could not resolve qr binary path")?; let cron_line = shell::cron_line(&exe); - let current = Command::new("crontab").arg("-l").output(); - - match current { - Ok(output) => { - let existing = String::from_utf8_lossy(&output.stdout); - if existing.contains(&cron_line) { - println!("cron entry already present"); - return Ok(()); - } + let output = Command::new("crontab") + .arg("-l") + .output() + .context("Failed to run `crontab -l`")?; + let existing = shell::crontab_contents_from_list_output(&output)?; + + let Some(merged) = shell::merge_cron_entry(&existing, &cron_line) else { + println!("cron entry already present"); + return Ok(()); + }; - let mut merged = existing.to_string(); - if !merged.ends_with('\n') && !merged.is_empty() { - merged.push('\n'); - } - merged.push_str(&cron_line); - merged.push('\n'); - - let mut child = Command::new("crontab") - .arg("-") - .stdin(std::process::Stdio::piped()) - .spawn() - .context("Failed to update crontab")?; - child.stdin.take().unwrap().write_all(merged.as_bytes())?; - let status = child.wait()?; - if !status.success() { - return Err(anyhow!("crontab rejected the new entry")); - } - println!("installed hourly scan cron"); - } - Err(_) => { - println!("Could not update crontab automatically. Add this entry manually:"); - println!("{}", cron_line); - } + let mut child = Command::new("crontab") + .arg("-") + .stdin(std::process::Stdio::piped()) + .spawn() + .context("Failed to update crontab")?; + child.stdin.take().unwrap().write_all(merged.as_bytes())?; + let status = child.wait()?; + if !status.success() { + return Err(anyhow!("crontab rejected the new entry")); } + println!("installed hourly scan cron"); Ok(()) } diff --git a/src/shell.rs b/src/shell.rs index 66a370d..78b7e2b 100644 --- a/src/shell.rs +++ b/src/shell.rs @@ -1,6 +1,7 @@ use std::{ env, fs, path::{Path, PathBuf}, + process::Output, }; use anyhow::{Context, Result, anyhow}; @@ -165,6 +166,46 @@ pub fn cron_line(binary_path: &Path) -> String { ) } +pub fn merge_cron_entry(existing: &str, new_line: &str) -> Option { + if existing.contains(new_line) { + return None; + } + + let mut merged = existing.to_string(); + if !merged.ends_with('\n') && !merged.is_empty() { + merged.push('\n'); + } + merged.push_str(new_line); + merged.push('\n'); + Some(merged) +} + +pub fn crontab_contents_from_list_output(output: &Output) -> Result { + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + + if is_known_no_crontab_message(&output.stdout) || is_known_no_crontab_message(&output.stderr) { + return Ok(String::new()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("exit status {}", output.status) + }; + Err(anyhow!("`crontab -l` failed: {detail}")) +} + +fn is_known_no_crontab_message(stream: &[u8]) -> bool { + let message = String::from_utf8_lossy(stream); + message.to_ascii_lowercase().contains("no crontab for") +} + fn validate_alias_name(name: &str) -> Result<()> { let mut chars = name.chars(); let valid = match chars.next() { @@ -268,6 +309,17 @@ fn write_lines(path: &Path, lines: &[String]) -> Result<()> { #[cfg(test)] mod tests { use super::*; + #[cfg(unix)] + use std::{os::unix::process::ExitStatusExt, process::Output}; + + #[cfg(unix)] + fn output(status: i32, stdout: &str, stderr: &str) -> Output { + Output { + status: std::process::ExitStatus::from_raw(status << 8), + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + } + } #[test] fn alias_helpers_add_list_and_remove() { @@ -405,4 +457,44 @@ mod tests { let parsed = parse_alias_line("alias e 'echo a=b'").unwrap(); assert_eq!(parsed, ("e".to_string(), "echo a=b".to_string())); } + + #[cfg(unix)] + #[test] + fn merge_cron_entry_adds_new_line() { + let merged = merge_cron_entry("MAILTO=user@example.com\n", "0 * * * * '/tmp/qr' scan") + .expect("new cron entry should be appended"); + assert_eq!( + merged, + "MAILTO=user@example.com\n0 * * * * '/tmp/qr' scan\n" + ); + } + + #[cfg(unix)] + #[test] + fn crontab_list_output_treats_no_crontab_as_empty() { + for stderr in [ + "no crontab for ubuntu\n", + "crontab: no crontab for ubuntu\n", + ] { + let existing = crontab_contents_from_list_output(&output(1, "", stderr)) + .expect("known no-crontab message should map to an empty crontab"); + assert!(existing.is_empty(), "expected empty crontab for {stderr:?}"); + } + } + + #[cfg(unix)] + #[test] + fn crontab_list_output_rejects_other_failures() { + let err = crontab_contents_from_list_output(&output(1, "", "permission denied\n")) + .expect_err("unexpected crontab -l failures must abort installation"); + let message = err.to_string(); + assert!( + message.contains("crontab -l"), + "missing command context: {message}" + ); + assert!( + message.contains("permission denied"), + "missing stderr detail: {message}" + ); + } } From 87c3b2b2619641b48128424598cf419a3ee3888c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 1 Jul 2026 09:49:13 +0000 Subject: [PATCH 2/2] Gracefully fall back when crontab binary is unavailable Co-authored-by: Aanish Bhirud --- src/main.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main.rs b/src/main.rs index 13d362d..2eead2d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -566,10 +566,16 @@ fn restrict_config_permissions(path: &std::path::Path) -> Result<()> { fn install_cron() -> Result<()> { let exe = env::current_exe().context("Could not resolve qr binary path")?; let cron_line = shell::cron_line(&exe); - let output = Command::new("crontab") - .arg("-l") - .output() - .context("Failed to run `crontab -l`")?; + let output = match Command::new("crontab").arg("-l").output() { + Ok(output) => output, + // Cannot spawn `crontab` (binary missing on minimal images, etc.) — nothing + // to overwrite, so fall back to printing the line for manual install. + Err(error) => { + print_manual_cron_hint(&cron_line); + eprintln!("warning: could not run `crontab -l`: {error:#}"); + return Ok(()); + } + }; let existing = shell::crontab_contents_from_list_output(&output)?; let Some(merged) = shell::merge_cron_entry(&existing, &cron_line) else { @@ -592,6 +598,11 @@ fn install_cron() -> Result<()> { Ok(()) } +fn print_manual_cron_hint(cron_line: &str) { + println!("Could not update crontab automatically. Add this entry manually:"); + println!("{cron_line}"); +} + fn print_go_result(result: &GoResult, print_path: bool) -> Result<()> { if print_path { println!("{}", result.path);