looprs 0.4.0

Concise coding assistant REPL — core library
Documentation
use regex::Regex;
use std::sync::OnceLock;

const DEFAULT_PREVIEW_LEN: usize = 4000;

// ANSI terminal escape bytes.
const ESC: u8 = 0x1b;
const BEL: u8 = 0x07;
const CSI_FINAL_LOW: u8 = 0x40;
const CSI_FINAL_HIGH: u8 = 0x7e;

pub fn preview_len() -> usize {
    std::env::var("LOOPRS_PREVIEW_LEN")
        .ok()
        .and_then(|s| s.parse::<usize>().ok())
        .filter(|n| *n > 0)
        .unwrap_or(DEFAULT_PREVIEW_LEN)
}

pub fn allow_raw_output() -> bool {
    if !cfg!(debug_assertions) {
        return false;
    }

    let explicitly_enabled = std::env::var("LOOPRS_ALLOW_RAW_OUTPUT")
        .ok()
        .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));

    if !explicitly_enabled {
        return false;
    }

    let env = std::env::var("LOOPRS_ENV")
        .unwrap_or_default()
        .to_ascii_lowercase();

    !matches!(env.as_str(), "prod" | "production")
}

pub fn strip_ansi(input: &str) -> String {
    // Remove common ANSI CSI sequences (e.g., ESC[...m)
    let bytes = input.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;

    while i < bytes.len() {
        if bytes[i] == ESC {
            if i + 1 >= bytes.len() {
                break;
            }

            match bytes[i + 1] {
                b'[' => {
                    i += 2;
                    while i < bytes.len() {
                        let b = bytes[i];
                        i += 1;
                        if (CSI_FINAL_LOW..=CSI_FINAL_HIGH).contains(&b) {
                            break;
                        }
                    }
                    continue;
                }
                b']' => {
                    i += 2;
                    while i < bytes.len() {
                        if bytes[i] == BEL {
                            i += 1;
                            break;
                        }
                        if bytes[i] == ESC && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
                            i += 2;
                            break;
                        }
                        i += 1;
                    }
                    continue;
                }
                b'P' | b'_' | b'^' | b'X' => {
                    i += 2;
                    while i < bytes.len() {
                        if bytes[i] == ESC && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
                            i += 2;
                            break;
                        }
                        i += 1;
                    }
                    continue;
                }
                _ => {
                    i += 2;
                    continue;
                }
            }
        }

        out.push(bytes[i]);
        i += 1;
    }

    String::from_utf8_lossy(&out).to_string()
}

pub fn sanitize_for_console(input: &str) -> String {
    if allow_raw_output() {
        return input.to_string();
    }

    let no_ansi = strip_ansi(input);
    redact(&no_ansi)
}

pub fn sanitize_preview_for_console(input: &str) -> String {
    let sanitized = sanitize_for_console(input);
    truncate_chars(&sanitized, preview_len())
}

fn truncate_chars(s: &str, max_chars: usize) -> String {
    if s.chars().count() <= max_chars {
        return s.to_string();
    }

    let truncated: String = s.chars().take(max_chars).collect();
    format!(
        "{truncated}... [truncated {} chars]",
        s.chars().count().saturating_sub(max_chars)
    )
}

fn redact(input: &str) -> String {
    static PEM_RE: OnceLock<Regex> = OnceLock::new();
    static SK_RE: OnceLock<Regex> = OnceLock::new();
    static URL_CREDS_RE: OnceLock<Regex> = OnceLock::new();
    static AUTH_RE: OnceLock<Regex> = OnceLock::new();
    static JWT_RE: OnceLock<Regex> = OnceLock::new();
    static PROVIDER_TOKEN_RE: OnceLock<Regex> = OnceLock::new();
    static KV_RE: OnceLock<Regex> = OnceLock::new();

    let pem_re = PEM_RE.get_or_init(|| {
        Regex::new(
            r"(?s)-----BEGIN (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----.*?-----END (?:[A-Z0-9 ]*PRIVATE KEY|OPENSSH PRIVATE KEY|PGP PRIVATE KEY BLOCK)-----",
        )
            .expect("pem regex")
    });
    let sk_re = SK_RE.get_or_init(|| Regex::new(r"\bsk-[A-Za-z0-9_\-]{10,}\b").expect("sk regex"));
    let url_creds_re = URL_CREDS_RE
        .get_or_init(|| Regex::new(r"(https?://)([^/\s@]+)@").expect("url creds regex"));
    let auth_re = AUTH_RE.get_or_init(|| {
        Regex::new(r"(?im)(\bauthorization\b\s*[:=]\s*)([^\r\n]+)").expect("auth regex")
    });
    let jwt_re = JWT_RE.get_or_init(|| {
        Regex::new(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b")
            .expect("jwt regex")
    });
    let provider_token_re = PROVIDER_TOKEN_RE.get_or_init(|| {
        Regex::new(
            r"\b(?:ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9\-]{10,}|AKIA[0-9A-Z]{16})\b",
        )
        .expect("provider token regex")
    });
    let kv_re = KV_RE.get_or_init(|| {
        Regex::new(
            r#"(?im)(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|secret|password|passwd|pwd|client[_-]?secret)\b\s*[:=]\s*)(?:\"[^\"\r\n]*\"|'[^'\r\n]*'|[^\r\n,}]+)"#,
        )
        .expect("kv regex")
    });

    let mut s = input.to_string();
    s = pem_re.replace_all(&s, "[REDACTED PRIVATE KEY]").to_string();
    s = url_creds_re.replace_all(&s, "$1[REDACTED]@").to_string();
    s = auth_re.replace_all(&s, "$1[REDACTED]").to_string();
    s = sk_re.replace_all(&s, "sk-[REDACTED]").to_string();
    s = jwt_re.replace_all(&s, "[REDACTED JWT]").to_string();
    s = provider_token_re
        .replace_all(&s, "[REDACTED TOKEN]")
        .to_string();
    s = kv_re.replace_all(&s, "$1[REDACTED]").to_string();

    s
}

#[cfg(kani)]
mod kani_proofs {
    use super::*;

    /// Prove that strip_ansi never indexes out of bounds for any
    /// 8-byte input. The state machine must always terminate with
    /// `i` within `[0, len]`.
    #[kani::proof]
    #[kani::unwind(12)]
    fn strip_ansi_no_oob() {
        // Symbolic 8-byte input — small enough for bounded model checking
        let len: usize = kani::any();
        kani::assume(len <= 8);
        let mut buf = [0u8; 8];
        for i in 0..len {
            buf[i] = kani::any();
        }
        let s = match std::str::from_utf8(&buf[..len]) {
            Ok(s) => s,
            Err(_) => return, // skip non-UTF8
        };
        // Must not panic
        let result = strip_ansi(s);
        // Output must not contain ESC
        assert!(!result.as_bytes().iter().any(|&b| b == ESC));
    }

    /// Prove truncate_chars never overflows or panics.
    #[kani::proof]
    #[kani::unwind(10)]
    fn truncate_chars_no_overflow() {
        let max: usize = kani::any();
        kani::assume(max <= 5);
        // Use a small fixed string
        let s = "abcde";
        let result = truncate_chars(s, max);
        if max >= 5 {
            assert!(result.len() == s.len());
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    // ── Property tests ──────────────────────────────────────────────────

    proptest! {
        #[test]
        fn strip_ansi_never_panics(s in "\\PC*") {
            let _ = strip_ansi(&s);
        }

        #[test]
        fn strip_ansi_is_idempotent(s in "\\PC*") {
            let once = strip_ansi(&s);
            let twice = strip_ansi(&once);
            prop_assert_eq!(&once, &twice);
        }

        #[test]
        fn strip_ansi_output_contains_no_esc(s in "\\PC*") {
            let out = strip_ansi(&s);
            prop_assert!(
                !out.contains('\x1b'),
                "output still contains ESC: {:?}", out
            );
        }

        #[test]
        fn redact_is_idempotent(s in "\\PC{0,500}") {
            let once = redact(&s);
            let twice = redact(&once);
            prop_assert_eq!(&once, &twice);
        }

        #[test]
        fn redact_never_panics(s in "\\PC{0,500}") {
            let _ = redact(&s);
        }

        #[test]
        fn truncate_respects_limit(s in "\\PC{0,200}", max in 0usize..300) {
            let out = truncate_chars(&s, max);
            if s.chars().count() <= max {
                prop_assert_eq!(&out, &s);
            } else {
                // Truncated output starts with first `max` chars
                let prefix: String = s.chars().take(max).collect();
                prop_assert!(out.starts_with(&prefix));
                prop_assert!(out.contains("[truncated"));
            }
        }

        #[test]
        fn sanitize_for_console_never_panics(s in "\\PC{0,500}") {
            let _ = sanitize_for_console(&s);
        }
    }

    // ── Unit tests ──────────────────────────────────────────────────────

    #[test]
    fn redacts_sk_tokens() {
        let s = "token sk-abcdefghijklmnopqrstuvwxyz12345 end";
        let out = sanitize_for_console(s);
        assert!(out.contains("sk-[REDACTED]"));
        assert!(!out.contains("sk-abcdefghijklmnopqrstuvwxyz"));
    }

    #[test]
    fn redacts_key_value_pairs() {
        let s = "OPENAI_API_KEY=sk-abcdef1234567890\nauthorization: Bearer abcdef\n";
        let out = sanitize_for_console(s);
        assert!(out.to_lowercase().contains("api_key"));
        assert!(out.contains("[REDACTED]"));
    }

    #[test]
    fn redacts_pem_private_key() {
        let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
        let out = sanitize_for_console(s);
        assert_eq!(out, "[REDACTED PRIVATE KEY]");
    }

    #[test]
    fn redacts_url_credentials() {
        let s = "https://user:pass@example.com/path";
        let out = sanitize_for_console(s);
        assert_eq!(out, "https://[REDACTED]@example.com/path");
    }

    #[test]
    fn strips_ansi_sequences() {
        let s = "\u{1b}[31mred\u{1b}[0m";
        let out = strip_ansi(s);
        assert_eq!(out, "red");
    }

    #[test]
    fn strips_osc_sequences() {
        let s = "before\u{1b}]0;window-title\u{7}after";
        let out = strip_ansi(s);
        assert_eq!(out, "beforeafter");
    }

    #[test]
    fn redacts_authorization_header() {
        let s = "Authorization: Bearer super-secret-token";
        let out = sanitize_for_console(s);
        assert_eq!(out, "Authorization: [REDACTED]");
    }

    #[test]
    fn redacts_jwt_tokens() {
        let s = "jwt eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.abcdefghijklmno1234567890.pqrstuvwxyzABCDE1234567890";
        let out = sanitize_for_console(s);
        assert!(out.contains("[REDACTED JWT]"));
    }

    #[test]
    fn redacts_provider_tokens() {
        let s = "ghp_abcdefghijklmnopqrstuvwxyz123456 and AKIA1234567890ABCDEF";
        let out = sanitize_for_console(s);
        assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyz123456"));
        assert!(!out.contains("AKIA1234567890ABCDEF"));
        assert!(out.contains("[REDACTED TOKEN]"));
    }
}