use anyhow::Result;
pub const SIZE_LIMIT_CHARS: usize = 4000;
pub const SIZE_LIMIT_ENV: &str = "SARA_MEMORY_CHAR_LIMIT";
pub fn size_limit() -> usize {
std::env::var(SIZE_LIMIT_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.filter(|&n| n > 0)
.unwrap_or(SIZE_LIMIT_CHARS)
}
pub fn check_memory_body(text: &str) -> Result<()> {
check_size(text)?;
check_secrets(text)?;
Ok(())
}
pub fn check_size(text: &str) -> Result<()> {
let len = text.chars().count();
let limit = size_limit();
if len > limit {
anyhow::bail!(
"Memory body is {len} characters — that looks like a raw conversation paste, \
not a distilled paragraph ({limit} char limit).\n\
Distill the key insight into one short paragraph first, then run sara learn again.\n\
Raise the limit with {SIZE_LIMIT_ENV}=<n>, or save anyway (not recommended) with --force."
);
}
Ok(())
}
pub fn check_secrets(text: &str) -> Result<()> {
if let Some(reason) = detect_secret(text) {
anyhow::bail!(
"Memory body may contain a secret ({reason}).\n\
Remove the sensitive value before saving, or add --force to skip this check."
);
}
Ok(())
}
pub fn detect_secret(text: &str) -> Option<&'static str> {
let kv_patterns = [
"api_key",
"apikey",
"api-key",
"secret",
"password",
"passwd",
"token",
"private_key",
"privatekey",
"client_secret",
"access_key",
"accesskey",
"auth_token",
"bearer",
"authorization",
];
let lower = text.to_lowercase();
for kw in &kv_patterns {
for sep in ["=", ": ", ":\"", "=\""] {
if lower.contains(&format!("{kw}{sep}")) {
return Some("key=value assignment pattern");
}
}
}
if contains_aws_key(text) {
return Some("AWS-style access key (AKIA…)");
}
if contains_high_entropy_token(text) {
return Some("high-entropy token");
}
None
}
fn contains_aws_key(text: &str) -> bool {
let bytes = text.as_bytes();
for i in 0..bytes.len().saturating_sub(19) {
if bytes[i..i + 4] == *b"AKIA" {
let rest = &bytes[i + 4..i + 20];
if rest.len() == 16
&& rest
.iter()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return true;
}
}
}
false
}
fn contains_high_entropy_token(text: &str) -> bool {
for word in text.split_whitespace() {
let w = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_');
if w.len() < 32 || !w.is_ascii() || is_uuid(w) {
continue;
}
let hex_count = w.bytes().filter(|b| b.is_ascii_hexdigit()).count();
if hex_count * 100 / w.len() > 55 && w.len() >= 32 {
return true;
}
}
false
}
fn is_uuid(s: &str) -> bool {
let b = s.as_bytes();
if b.len() != 36 {
return false;
}
let dashes = [8usize, 13, 18, 23];
for (i, &byte) in b.iter().enumerate() {
if dashes.contains(&i) {
if byte != b'-' {
return false;
}
} else if !byte.is_ascii_hexdigit() {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn size_check_passes_under_threshold() {
assert!(check_size("short text").is_ok());
assert!(check_size(&"x".repeat(SIZE_LIMIT_CHARS)).is_ok());
}
#[test]
fn size_check_fails_over_threshold() {
assert!(check_size(&"x".repeat(SIZE_LIMIT_CHARS + 1)).is_err());
}
#[test]
fn secret_kv_detected() {
assert!(detect_secret("api_key=abc123").is_some());
assert!(detect_secret("password: hunter2").is_some());
assert!(detect_secret("token=\"ghp_something\"").is_some());
}
#[test]
fn secret_aws_key_detected() {
assert!(detect_secret("AKIAIOSFODNN7EXAMPLE").is_some());
}
#[test]
fn secret_high_entropy_detected() {
assert!(detect_secret("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2").is_some());
}
#[test]
fn uuid_not_flagged_as_high_entropy() {
assert!(detect_secret("ref: 831c4d6e-8fcc-4ca5-b516-21bc8236acb0").is_none());
}
#[test]
fn clean_paragraph_passes() {
assert!(
detect_secret(
"Sara uses rusqlite for all DB access. The connection is created once in main."
)
.is_none()
);
}
#[test]
fn check_memory_body_combines_both() {
assert!(check_memory_body("short clean text").is_ok());
assert!(check_memory_body(&"x".repeat(SIZE_LIMIT_CHARS + 1)).is_err());
assert!(check_memory_body("api_key=secret123").is_err());
}
}