use std::fmt;
use std::path::Path;
use franken_snowflake_core::redact::{redact, secret_spans};
use serde::Serialize;
pub const DEFAULT_CANARY: &str = "FSNOW_CANARY_a1b2c3d4_DO_NOT_EMIT";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Channel {
Stdout,
Stderr,
File(String),
Named(String),
}
impl fmt::Display for Channel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Stdout => f.write_str("stdout"),
Self::Stderr => f.write_str("stderr"),
Self::File(path) => write!(f, "file:{path}"),
Self::Named(name) => f.write_str(name),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum HitKind {
PlantedCanary,
SecretShape,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanaryHit {
pub channel: Channel,
pub kind: HitKind,
pub needle: String,
pub byte_offset: usize,
}
impl fmt::Display for CanaryHit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self.kind {
HitKind::PlantedCanary => "planted canary",
HitKind::SecretShape => "secret shape",
};
write!(
f,
"{} on {} at byte {} ({})",
kind, self.channel, self.byte_offset, self.needle
)
}
}
#[derive(Clone, Debug)]
pub struct CanaryGuard {
planted: Vec<String>,
scan_shapes: bool,
}
impl Default for CanaryGuard {
fn default() -> Self {
Self::new()
}
}
impl CanaryGuard {
#[must_use]
pub fn new() -> Self {
Self {
planted: Vec::new(),
scan_shapes: true,
}
}
#[must_use]
pub fn with_default_canary() -> Self {
let mut guard = Self::new();
guard.plant(DEFAULT_CANARY);
guard
}
pub fn plant(&mut self, canary: impl Into<String>) -> &mut Self {
self.planted.push(canary.into());
self
}
#[must_use]
pub fn scan_shapes(mut self, enabled: bool) -> Self {
self.scan_shapes = enabled;
self
}
#[must_use]
pub fn scan_text(&self, channel: Channel, text: &str) -> Vec<CanaryHit> {
let mut hits = Vec::new();
for canary in &self.planted {
if canary.is_empty() {
continue;
}
for (offset, _) in text.match_indices(canary.as_str()) {
hits.push(CanaryHit {
channel: channel.clone(),
kind: HitKind::PlantedCanary,
needle: canary.clone(),
byte_offset: offset,
});
}
}
if self.scan_shapes {
for (start, end) in secret_spans(text) {
let span = text.get(start..end).unwrap_or(text);
hits.push(CanaryHit {
channel: channel.clone(),
kind: HitKind::SecretShape,
needle: redact(span).into_owned(),
byte_offset: start,
});
}
}
hits
}
pub fn scan_file(&self, path: impl AsRef<Path>) -> Result<Vec<CanaryHit>, CanaryError> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(CanaryError::Io)?;
let text = std::str::from_utf8(&bytes).map_err(|_| CanaryError::Utf8)?;
Ok(self.scan_text(Channel::File(path.display().to_string()), text))
}
pub fn scan(
&self,
stdout: &str,
stderr: &str,
files: &[&Path],
) -> Result<CanaryReport, CanaryError> {
let mut hits = self.scan_text(Channel::Stdout, stdout);
hits.extend(self.scan_text(Channel::Stderr, stderr));
for path in files {
hits.extend(self.scan_file(path)?);
}
Ok(CanaryReport { hits })
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanaryReport {
pub hits: Vec<CanaryHit>,
}
impl CanaryReport {
#[must_use]
pub fn leaked(&self) -> bool {
!self.hits.is_empty()
}
pub fn assert_clean(self) -> Result<(), CanaryLeak> {
if self.hits.is_empty() {
Ok(())
} else {
Err(CanaryLeak { hits: self.hits })
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CanaryLeak {
pub hits: Vec<CanaryHit>,
}
impl fmt::Display for CanaryLeak {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "canary leak guard tripped: {} hit(s)", self.hits.len())?;
for hit in &self.hits {
write!(f, "\n - {hit}")?;
}
Ok(())
}
}
impl std::error::Error for CanaryLeak {}
#[derive(Debug)]
pub enum CanaryError {
Io(std::io::Error),
Utf8,
}
impl fmt::Display for CanaryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(error) => write!(f, "canary scan io error: {error}"),
Self::Utf8 => write!(f, "canary scan target is not valid UTF-8"),
}
}
}
impl std::error::Error for CanaryError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn planted_canary_trips_across_stdout_stderr_and_files()
-> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join("fsnow-harness-canary");
std::fs::create_dir_all(&dir)?;
let receipt = dir.join("receipt.json");
std::fs::write(&receipt, format!("{{\"note\":\"{DEFAULT_CANARY}\"}}"))?;
let guard = CanaryGuard::with_default_canary();
let report = guard.scan(&format!("ok {DEFAULT_CANARY}"), "clean stderr", &[])?;
assert!(report.leaked());
let report = guard.scan("clean", &format!("oops {DEFAULT_CANARY}"), &[])?;
assert!(report.leaked());
let report = guard.scan("clean", "clean", &[receipt.as_path()])?;
assert!(report.leaked());
match report.assert_clean() {
Ok(()) => return Err("file leak should not be clean".into()),
Err(leak) => assert_eq!(leak.hits.len(), 1),
}
let clean = guard.scan("all good", "no secrets", &[])?;
assert!(!clean.leaked());
clean.assert_clean()?;
std::fs::remove_dir_all(&dir)?;
Ok(())
}
#[test]
fn secret_shape_hits_are_stored_redacted() {
let guard = CanaryGuard::new();
let hits = guard.scan_text(
Channel::Stdout,
"leaked token: ghp_0123456789abcdefABCDEF here",
);
let shape: Vec<&CanaryHit> = hits
.iter()
.filter(|hit| hit.kind == HitKind::SecretShape)
.collect();
assert_eq!(shape.len(), 1);
assert!(!shape[0].needle.contains("ghp_0123456789"));
assert!(shape[0].needle.contains("[REDACTED]"));
}
#[test]
fn leaked_pem_private_key_is_flagged_as_a_secret_shape() {
let guard = CanaryGuard::new();
let key_body = "MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAT8leakedKeyMaterial";
let leaked = format!(
"stderr: {}{}\n{key_body}\n{}{} done",
"-----BEGIN ", "PRIVATE KEY-----", "-----END ", "PRIVATE KEY-----"
);
let hits = guard.scan_text(Channel::Stderr, &leaked);
let shape: Vec<&CanaryHit> = hits
.iter()
.filter(|hit| hit.kind == HitKind::SecretShape)
.collect();
assert_eq!(shape.len(), 1, "PEM key must be flagged exactly once");
assert!(!shape[0].needle.contains(key_body));
assert!(shape[0].needle.contains("[REDACTED]"));
}
#[test]
fn disabling_shape_scan_only_keeps_planted_hits() {
let guard = CanaryGuard::with_default_canary().scan_shapes(false);
let hits = guard.scan_text(
Channel::Stderr,
&format!("ghp_0123456789abcdefABCDEF and {DEFAULT_CANARY}"),
);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].kind, HitKind::PlantedCanary);
}
#[test]
fn multiple_shape_hits_carry_distinct_offsets() {
let guard = CanaryGuard::new();
let text = "AKIAIOSFODNN7EXAMPLE then xoxb-abcdEFGH";
let hits = guard.scan_text(Channel::Stdout, text);
let shapes: Vec<&CanaryHit> = hits
.iter()
.filter(|hit| hit.kind == HitKind::SecretShape)
.collect();
assert_eq!(shapes.len(), 2);
assert_ne!(shapes[0].byte_offset, shapes[1].byte_offset);
assert_eq!(shapes[0].byte_offset, 0);
assert_eq!(
shapes[1].byte_offset,
text.find("xoxb-").unwrap_or(usize::MAX)
);
}
#[test]
fn multiple_planted_canaries_are_all_scanned() {
let mut guard = CanaryGuard::new();
guard.plant("CANARY_ONE").plant("CANARY_TWO");
let hits = guard.scan_text(Channel::Stdout, "x CANARY_ONE y CANARY_TWO z");
assert_eq!(
hits.iter()
.filter(|h| h.kind == HitKind::PlantedCanary)
.count(),
2
);
}
#[test]
fn scan_file_rejects_non_utf8() -> Result<(), Box<dyn std::error::Error>> {
let dir = std::env::temp_dir().join("fsnow-harness-canary-utf8");
std::fs::create_dir_all(&dir)?;
let path = dir.join("blob.bin");
std::fs::write(&path, [0xff_u8, 0xfe, 0x00])?;
let guard = CanaryGuard::with_default_canary();
match guard.scan_file(&path) {
Err(CanaryError::Utf8) => {}
other => return Err(format!("expected Utf8 error, got {other:?}").into()),
}
std::fs::remove_dir_all(&dir)?;
Ok(())
}
#[test]
fn leak_display_lists_each_hit() -> Result<(), String> {
let guard = CanaryGuard::with_default_canary();
let report = CanaryReport {
hits: guard.scan_text(Channel::Stdout, DEFAULT_CANARY),
};
let leak = report
.assert_clean()
.err()
.ok_or_else(|| "planted canary must trip".to_owned())?;
let rendered = leak.to_string();
assert!(rendered.contains("planted canary"));
assert!(rendered.contains("stdout"));
Ok(())
}
#[test]
fn fully_clean_channels_pass_assert_clean() -> Result<(), Box<dyn std::error::Error>> {
let guard = CanaryGuard::with_default_canary();
let report = guard.scan("clean stdout output", "clean stderr output", &[])?;
assert!(!report.leaked());
report.assert_clean()?;
Ok(())
}
}