//! `bxctl sanitize check` (M5b spec, section 6.6): which of the sanitizer's rules a text breaks, //! and what each would do. It calls `bxcore::sanitize` directly, as `memoryd` and `loopd` do, so //! it needs no daemon, answers as they would, and writes nothing. //! //! Nothing of the checked text is printed: a refusal shows a secret's prefix from `PREFIXES`, a //! character as `U+XXXX` or a tag as its token id, and a flag names its rule only. Every byte //! printed is fixed text or a number, so a key, a tag or a control character never reaches the //! terminal, and no escaping is needed. use std::io::Write; use bxcore::sanitize::{Refusal, TAGS, flag, refuse}; use crate::cli::{Command, UsageError}; /// The prefixes of `bxcore`'s secret rules (`refuse.rs`), and the start of a private-key line: /// what a refusal shows of a secret, followed by `…`. No prefix is the start of another. const PREFIXES: &[&str] = &[ "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "github_pat_", "glpat-", "sk-", "xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-", "AKIA", "ASIA", "-----BEGIN", ]; /// `bxctl sanitize check [TEXT]`: `args` are the words after `sanitize`. `["check"]` gives /// `Command::SanitizeCheck { text: None }` (the text is read from standard input); /// `["check", text]` gives `Some(text.clone())`, whatever the text looks like, `-` and `--x` /// included. Anything else is `Err(UsageError)`. One `match` on `args`, with slice patterns and /// guards: `[check] if check == "check" => …`. pub fn parse_sanitize(args: &[String]) -> Result { match args { [check] if check == "check" => Ok(Command::SanitizeCheck { text: None }), [check, text] if check == "check" => Ok(Command::SanitizeCheck { text: Some(text.clone()), }), _ => Err(UsageError), } } /// The lines `bxctl sanitize check` prints for `text`, in this order, each only if it fires: the /// first refuse rule (only the first: memory answers with that one), the first flag rule, and the /// number of tags `neutralise` would rewrite. No line: nothing fires. pub fn report(text: &str) -> Vec { let mut lines = Vec::new(); if let Some(refusal) = refuse(text) { lines.push(format!( "refuse {:<18} {} at {}", refusal.rule, what(text, &refusal), refusal.at )); } if let Some(flagged) = flag(text) { lines.push(format!("flag {}", flagged.rule)); } let tags = tag_count(text); if tags > 0 { let noun = if tags == 1 { "tag" } else { "tags" }; lines.push(format!("neutralise {tags} {noun}")); } lines } /// Writes each line of `report(text)` to `out`, with a line feed after each. True if any rule /// fired, that is, if there was a line. pub fn run(text: &str, out: &mut dyn Write) -> std::io::Result { let lines = report(text); for line in &lines { writeln!(out, "{line}")?; } Ok(!lines.is_empty()) } /// What a refusal shows of the text at `refusal.at`: a character rule its code point, the tag /// rule the tag's token id, any other rule (the secrets) the prefix and `…`. fn what(text: &str, refusal: &Refusal) -> String { match refusal.rule { "control" | "invisible" | "private-use" | "noncharacter" => code_point(text, refusal.at), "tag" => token(text, refusal.at), _ => format!("{}…", prefix(text, refusal.at)), } } /// The character that starts at byte `at` of `text`, as `U+` and at least four upper-case hex /// digits (`format!("U+{:04X}", u32::from(c))`: `U+0007`, `U+202E`, `U+10FFFF`). `"…"` if no /// character starts there. fn code_point(text: &str, at: usize) -> String { match text.get(at..).and_then(|rest| rest.chars().next()) { Some(c) => format!("U+{:04X}", u32::from(c)), None => "…".to_string(), } } /// The listed tag that starts at byte `at` of `text`, as `token ` and its id (`token 248068`): /// the first tag in `TAGS` whose `text` the rest of `text` (`text.get(at..)`, `""` for `None`) /// starts with. `"…"` if none does. fn token(text: &str, at: usize) -> String { match text .get(at..) .and_then(|rest| TAGS.iter().find(|tag| rest.starts_with(tag.text))) { Some(tag) => format!("token {}", tag.id), None => "…".to_string(), } } /// The first entry of `PREFIXES` that the rest of `text` from byte `at` (`text.get(at..)`, `""` /// for `None`) starts with, or `""` if none does. fn prefix(text: &str, at: usize) -> &'static str { text.get(at..) .and_then(|rest| PREFIXES.iter().find(|prefix| rest.starts_with(**prefix))) .copied() .unwrap_or_default() } /// How many tags `neutralise` would rewrite: for each tag in `TAGS`, the number of times its /// `text` occurs in `text` (`str::matches` and `count`), summed. Tags never overlap, so this is /// every occurrence, each counted once. fn tag_count(text: &str) -> usize { TAGS.iter().map(|tag| text.matches(tag.text).count()).sum() }