vsec 0.0.1

Detect secrets and in Rust codebases
Documentation
// src/analysis/constant_finder.rs

use std::path::PathBuf;

use syn::visit::Visit;
use syn::{Expr, File, Item, ItemConst, ItemMod, ItemStatic};

use crate::models::Visibility;

/// A constant or static found during analysis
#[derive(Debug, Clone)]
pub struct FoundConstant {
    /// Name of the constant
    pub name: String,

    /// String value (if extractable)
    pub value: Option<String>,

    /// Whether it's a static (vs const)
    pub is_static: bool,

    /// Whether it's mutable (static mut)
    pub is_mutable: bool,

    /// Visibility
    pub visibility: Visibility,

    /// Line number
    pub line: u32,

    /// Module path (e.g., "foo::bar")
    pub module_path: Vec<String>,
}

/// Finds constant and static definitions in a Rust file
pub struct ConstantFinder {
    /// Collected constants
    constants: Vec<FoundConstant>,

    /// Current module path
    module_path: Vec<String>,

    /// File being analyzed
    file_path: PathBuf,
}

impl ConstantFinder {
    pub fn new(file_path: PathBuf) -> Self {
        Self {
            constants: Vec::new(),
            module_path: Vec::new(),
            file_path,
        }
    }

    /// Find all constants in a file
    pub fn find(file_path: PathBuf, file: &File) -> Vec<FoundConstant> {
        let mut finder = Self::new(file_path);
        finder.visit_file(file);
        finder.constants
    }

    /// Extract string value from an expression
    pub fn extract_string_value(expr: &Expr) -> Option<String> {
        match expr {
            Expr::Lit(lit) => match &lit.lit {
                syn::Lit::Str(s) => Some(s.value()),
                syn::Lit::ByteStr(s) => String::from_utf8(s.value()).ok(),
                _ => None,
            },
            Expr::Reference(r) => Self::try_decode_referenced_value(&r.expr),
            Expr::Macro(mac) => {
                if mac
                    .mac
                    .path
                    .segments
                    .last()
                    .map(|s| s.ident == "vec")
                    .unwrap_or(false)
                {
                    Self::try_decode_vec_macro(&mac.mac.tokens)
                } else {
                    None
                }
            }
            Expr::Call(call) => {
                let func_name = quote::quote!(#call.func).to_string();
                if func_name.contains("from_utf8") {
                    call.args.first().and_then(Self::extract_string_value)
                } else {
                    None
                }
            }
            Expr::Group(g) => Self::extract_string_value(&g.expr),
            Expr::Paren(p) => Self::extract_string_value(&p.expr),
            _ => None,
        }
    }

    /// Try to decode byte/char arrays
    fn try_decode_referenced_value(expr: &Expr) -> Option<String> {
        if let Expr::Array(arr) = expr {
            // Try to decode as byte values
            let bytes: Option<Vec<u8>> = arr
                .elems
                .iter()
                .map(|e| {
                    if let Expr::Lit(lit) = e {
                        match &lit.lit {
                            syn::Lit::Int(i) => i.base10_parse::<u8>().ok(),
                            syn::Lit::Byte(b) => Some(b.value()),
                            _ => None,
                        }
                    } else {
                        None
                    }
                })
                .collect();

            if let Some(bytes) = bytes {
                if let Ok(s) = String::from_utf8(bytes) {
                    return Some(s);
                }
            }

            // Try to decode as char values
            let chars: Option<String> = arr
                .elems
                .iter()
                .map(|e| {
                    if let Expr::Lit(lit) = e {
                        if let syn::Lit::Char(c) = &lit.lit {
                            return Some(c.value());
                        }
                    }
                    None
                })
                .collect();

            return chars;
        }
        None
    }

    /// Try to decode vec![...] with byte literals
    fn try_decode_vec_macro(tokens: &proc_macro2::TokenStream) -> Option<String> {
        use syn::parse::Parser;
        use syn::{Expr, ExprLit, Lit};

        let parser = syn::punctuated::Punctuated::<Expr, syn::Token![,]>::parse_terminated;
        let exprs: syn::punctuated::Punctuated<Expr, syn::Token![,]> =
            match parser.parse2(tokens.clone()) {
                Ok(exprs) => exprs,
                Err(_) => return None,
            };

        let bytes: Option<Vec<u8>> = exprs
            .iter()
            .map(|expr| {
                if let Expr::Lit(ExprLit {
                    lit: Lit::Int(int), ..
                }) = expr
                {
                    int.base10_parse::<u8>().ok()
                } else if let Expr::Lit(ExprLit {
                    lit: Lit::Byte(b), ..
                }) = expr
                {
                    Some(b.value())
                } else {
                    None
                }
            })
            .collect();

        bytes.and_then(|b| String::from_utf8(b).ok())
    }

    /// Get the collected constants
    pub fn constants(&self) -> &[FoundConstant] {
        &self.constants
    }

    /// Take ownership of results
    pub fn into_constants(self) -> Vec<FoundConstant> {
        self.constants
    }
}

impl<'ast> Visit<'ast> for ConstantFinder {
    fn visit_item_const(&mut self, node: &'ast ItemConst) {
        let value = Self::extract_string_value(&node.expr);
        let visibility = Visibility::from_syn(&node.vis);

        self.constants.push(FoundConstant {
            name: node.ident.to_string(),
            value,
            is_static: false,
            is_mutable: false,
            visibility,
            line: node.ident.span().start().line as u32,
            module_path: self.module_path.clone(),
        });

        syn::visit::visit_item_const(self, node);
    }

    fn visit_item_static(&mut self, node: &'ast ItemStatic) {
        let value = Self::extract_string_value(&node.expr);
        let visibility = Visibility::from_syn(&node.vis);

        self.constants.push(FoundConstant {
            name: node.ident.to_string(),
            value,
            is_static: true,
            is_mutable: !matches!(node.mutability, syn::StaticMutability::None),
            visibility,
            line: node.ident.span().start().line as u32,
            module_path: self.module_path.clone(),
        });

        syn::visit::visit_item_static(self, node);
    }

    fn visit_item_mod(&mut self, node: &'ast ItemMod) {
        let name = node.ident.to_string();
        self.module_path.push(name);

        syn::visit::visit_item_mod(self, node);

        self.module_path.pop();
    }

    fn visit_item(&mut self, item: &'ast Item) {
        // Skip impl blocks since they can't contain top-level constants
        match item {
            Item::Impl(_) => {}
            _ => syn::visit::visit_item(self, item),
        }
    }
}

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

    #[test]
    fn test_find_const() {
        let code = r#"
            const API_KEY: &str = "secret123";
            const NUMBER: i32 = 42;
        "#;

        let file: syn::File = syn::parse_str(code).unwrap();
        let constants = ConstantFinder::find(PathBuf::from("test.rs"), &file);

        assert_eq!(constants.len(), 2);
        assert_eq!(constants[0].name, "API_KEY");
        assert_eq!(constants[0].value, Some("secret123".to_string()));
        assert!(!constants[0].is_static);
    }

    #[test]
    fn test_find_static() {
        let code = r#"
            static DB_URL: &str = "localhost";
            static mut COUNTER: i32 = 0;
        "#;

        let file: syn::File = syn::parse_str(code).unwrap();
        let constants = ConstantFinder::find(PathBuf::from("test.rs"), &file);

        assert_eq!(constants.len(), 2);
        assert_eq!(constants[0].name, "DB_URL");
        assert!(constants[0].is_static);
        assert!(!constants[0].is_mutable);
        assert!(constants[1].is_mutable);
    }

    #[test]
    fn test_module_path() {
        let code = r#"
            mod outer {
                const A: &str = "a";
                mod inner {
                    const B: &str = "b";
                }
            }
        "#;

        let file: syn::File = syn::parse_str(code).unwrap();
        let constants = ConstantFinder::find(PathBuf::from("test.rs"), &file);

        assert_eq!(constants.len(), 2);
        assert_eq!(constants[0].module_path, vec!["outer"]);
        assert_eq!(constants[1].module_path, vec!["outer", "inner"]);
    }

    #[test]
    fn test_visibility() {
        let code = r#"
            const PRIVATE: &str = "a";
            pub const PUBLIC: &str = "b";
            pub(crate) const PUB_CRATE: &str = "c";
        "#;

        let file: syn::File = syn::parse_str(code).unwrap();
        let constants = ConstantFinder::find(PathBuf::from("test.rs"), &file);

        assert_eq!(constants.len(), 3);
        assert_eq!(constants[0].visibility, Visibility::Private);
        assert_eq!(constants[1].visibility, Visibility::Public);
        assert_eq!(constants[2].visibility, Visibility::PubCrate);
    }
}