yolop 0.12.1

Yolop — a terminal coding agent built on everruns-runtime
use rand::RngExt;
use std::backtrace::Backtrace;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

const MAX_CRASH_REPORTS: usize = 5;
const MAX_PANIC_MESSAGE_BYTES: usize = 8 * 1024;
const MAX_REPORT_BYTES: usize = 256 * 1024;

pub(crate) struct CrashReporter {
    path: Option<PathBuf>,
    written: Arc<AtomicBool>,
}

impl CrashReporter {
    pub(crate) fn install() -> Self {
        let written = Arc::new(AtomicBool::new(false));
        let path = crash_report_dir()
            .and_then(|dir| prepare_crash_dir(&dir).then_some(dir))
            .map(|dir| {
                let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%SZ");
                let suffix = rand::rng().random::<u32>();
                dir.join(format!("{timestamp}-{suffix:08x}-crash.log"))
            });

        if let Some(path) = path.clone() {
            let hook_written = Arc::clone(&written);
            let crash_dir = path.parent().map(Path::to_path_buf);
            let previous = std::panic::take_hook();
            std::panic::set_hook(Box::new(move |info| {
                if hook_written
                    .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
                    .is_ok()
                {
                    if write_panic_report(&path, info).is_err() {
                        hook_written.store(false, Ordering::SeqCst);
                    } else if let Some(dir) = crash_dir.as_deref() {
                        prune_old_reports(dir, MAX_CRASH_REPORTS);
                    }
                }
                previous(info);
            }));
        }

        Self { path, written }
    }

    pub(crate) fn report_path(&self) -> Option<&Path> {
        self.written
            .load(Ordering::SeqCst)
            .then_some(self.path.as_deref())
            .flatten()
    }

    #[cfg(test)]
    pub(crate) fn disabled() -> Self {
        Self {
            path: None,
            written: Arc::new(AtomicBool::new(false)),
        }
    }
}

fn crash_report_dir() -> Option<PathBuf> {
    #[cfg(test)]
    if let Some(path) = std::env::var_os("YOLOP_TEST_CRASH_DIR") {
        return Some(PathBuf::from(path));
    }
    dirs::data_dir().map(|dir| dir.join("yolop").join("crashes"))
}

fn prepare_crash_dir(dir: &Path) -> bool {
    if std::fs::create_dir_all(dir).is_err() {
        return false;
    }
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        if std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).is_err() {
            return false;
        }
    }
    prune_old_reports(dir, MAX_CRASH_REPORTS);
    true
}

fn write_panic_report(path: &Path, info: &std::panic::PanicHookInfo<'_>) -> std::io::Result<()> {
    let mut payload = if let Some(message) = info.payload().downcast_ref::<&str>() {
        (*message).to_string()
    } else if let Some(message) = info.payload().downcast_ref::<String>() {
        message.clone()
    } else {
        "non-string panic payload".to_string()
    };
    truncate_utf8(&mut payload, MAX_PANIC_MESSAGE_BYTES);
    let location = info
        .location()
        .map(|location| {
            format!(
                "{}:{}:{}",
                location.file(),
                location.line(),
                location.column()
            )
        })
        .unwrap_or_else(|| "unknown".to_string());
    let thread = std::thread::current()
        .name()
        .unwrap_or("unnamed")
        .to_string();
    let mut body = format!(
        "timestamp: {}\nversion: {}\nthread: {thread}\nlocation: {location}\npanic: {payload}\n\nbacktrace:\n{}\n",
        chrono::Utc::now().to_rfc3339(),
        crate::version::VERSION_DETAILS,
        Backtrace::force_capture(),
    );
    truncate_utf8(&mut body, MAX_REPORT_BYTES);
    write_report(path, body.as_bytes())
}

fn truncate_utf8(value: &mut String, max_bytes: usize) {
    if value.len() <= max_bytes {
        return;
    }
    let boundary = value
        .char_indices()
        .map(|(index, _)| index)
        .take_while(|index| *index <= max_bytes.saturating_sub(''.len_utf8()))
        .last()
        .unwrap_or(0);
    value.truncate(boundary);
    value.push('');
}

fn write_report(path: &Path, body: &[u8]) -> std::io::Result<()> {
    let mut options = OpenOptions::new();
    options.create_new(true).write(true);
    #[cfg(unix)]
    {
        use std::os::unix::fs::OpenOptionsExt;
        options.mode(0o600);
    }
    let mut file = options.open(path)?;
    file.write_all(body)?;
    file.flush()
}

fn prune_old_reports(dir: &Path, keep: usize) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    let mut reports: Vec<PathBuf> = entries
        .filter_map(Result::ok)
        .map(|entry| entry.path())
        .filter(|path| {
            path.is_file()
                && path
                    .file_name()
                    .and_then(|name| name.to_str())
                    .is_some_and(|name| name.ends_with("-crash.log"))
        })
        .collect();
    reports.sort();
    let remove_count = reports.len().saturating_sub(keep);
    for path in reports.into_iter().take(remove_count) {
        let _ = std::fs::remove_file(path);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;

    #[test]
    fn crash_report_is_owner_only() {
        let dir = tempfile::tempdir().expect("crash dir");
        let path = dir.path().join("report-crash.log");
        write_report(&path, b"panic: test\n").expect("write crash report");

        assert_eq!(std::fs::read(&path).expect("read report"), b"panic: test\n");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            assert_eq!(
                std::fs::metadata(path)
                    .expect("report metadata")
                    .permissions()
                    .mode()
                    & 0o777,
                0o600
            );
        }
    }

    #[test]
    fn crash_report_retention_keeps_newest_names() {
        let dir = tempfile::tempdir().expect("crash dir");
        for index in 0..7 {
            let path = dir
                .path()
                .join(format!("2026-07-24T06-44-{index:02}Z-deadbeef-crash.log"));
            write_report(&path, b"report").expect("write report");
        }

        prune_old_reports(dir.path(), 5);

        let mut names: Vec<String> = std::fs::read_dir(dir.path())
            .expect("read crash dir")
            .map(|entry| {
                entry
                    .expect("crash entry")
                    .file_name()
                    .to_string_lossy()
                    .into_owned()
            })
            .collect();
        names.sort();
        assert_eq!(names.len(), 5);
        assert!(names[0].contains("06-44-02Z"));
        assert!(names[4].contains("06-44-06Z"));
    }

    #[test]
    fn crash_report_bounds_unicode_panic_text() {
        let mut message = "а".repeat(MAX_PANIC_MESSAGE_BYTES);
        truncate_utf8(&mut message, MAX_PANIC_MESSAGE_BYTES);

        assert!(message.len() <= MAX_PANIC_MESSAGE_BYTES);
        assert!(message.ends_with(''));
    }

    #[test]
    fn panic_hook_writes_a_crash_report() {
        let dir = tempfile::tempdir().expect("crash dir");
        let output = Command::new(std::env::current_exe().expect("test executable"))
            .args([
                "--exact",
                "crash_report::tests::panic_hook_child",
                "--nocapture",
            ])
            .env("YOLOP_TEST_CRASH_DIR", dir.path())
            .output()
            .expect("run panic child");

        assert!(!output.status.success(), "panic child should fail");
        let reports: Vec<PathBuf> = std::fs::read_dir(dir.path())
            .expect("read crash dir")
            .map(|entry| entry.expect("crash entry").path())
            .collect();
        assert_eq!(reports.len(), 1);
        let report = std::fs::read_to_string(&reports[0]).expect("read crash report");
        assert!(report.contains("panic: crash-report-test"));
        assert!(report.contains("thread: crash_report::tests::panic_hook_child"));
        assert!(report.contains("backtrace:"));
    }

    #[test]
    fn panic_hook_child() {
        if std::env::var_os("YOLOP_TEST_CRASH_DIR").is_none() {
            return;
        }
        let _reporter = CrashReporter::install();
        panic!("crash-report-test");
    }
}