sentio-core 0.1.5

AST-based security scanner for Solana/Anchor programs
Documentation
use quote::ToTokens;
use serde::Serialize;
use syn::spanned::Spanned;
use syn::{Attribute, Fields};

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AstIndex {
    pub structs: Vec<AstStruct>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AstStruct {
    pub name: String,
    pub attrs: Vec<AstAttr>,
    pub fields: Vec<AstField>,
    pub span: AstSpan,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AstField {
    pub name: Option<String>,
    pub ty: String,
    pub attrs: Vec<AstAttr>,
    pub span: AstSpan,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AstAttr {
    pub path: String,
    pub tokens: Option<String>,
    pub span: AstSpan,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct AstSpan {
    pub start_line: usize,
    pub start_column: usize,
    pub end_line: usize,
    pub end_column: usize,
}

pub fn collect_ast_index(file: &syn::File) -> AstIndex {
    let mut structs = Vec::new();
    collect_from_items(&file.items, &mut structs);
    AstIndex { structs }
}

fn collect_from_items(items: &[syn::Item], structs: &mut Vec<AstStruct>) {
    for item in items {
        match item {
            syn::Item::Struct(item) => structs.push(ast_struct_from_syn(item)),
            syn::Item::Mod(module) => {
                if let Some((_, nested_items)) = &module.content {
                    collect_from_items(nested_items, structs);
                }
            }
            _ => {}
        }
    }
}

pub(crate) fn ast_struct_from_syn(item: &syn::ItemStruct) -> AstStruct {
    let fields = match &item.fields {
        Fields::Named(named) => named.named.iter().map(ast_field_from_syn).collect(),
        Fields::Unnamed(unnamed) => unnamed.unnamed.iter().map(ast_field_from_syn).collect(),
        Fields::Unit => Vec::new(),
    };

    AstStruct {
        name: item.ident.to_string(),
        attrs: ast_attrs_from_syn(&item.attrs),
        fields,
        span: span_of(item.span()),
    }
}

pub(crate) fn ast_field_from_syn(field: &syn::Field) -> AstField {
    AstField {
        name: field.ident.as_ref().map(|ident| ident.to_string()),
        ty: field.ty.to_token_stream().to_string(),
        attrs: ast_attrs_from_syn(&field.attrs),
        span: span_of(field.span()),
    }
}

pub(crate) fn ast_attrs_from_syn(attrs: &[Attribute]) -> Vec<AstAttr> {
    attrs
        .iter()
        .map(|attr| AstAttr {
            path: attr.path().to_token_stream().to_string(),
            tokens: attr_tokens(attr),
            span: span_of(attr.span()),
        })
        .collect()
}

fn attr_tokens(attr: &Attribute) -> Option<String> {
    match &attr.meta {
        syn::Meta::Path(_) => None,
        syn::Meta::List(list) => Some(list.tokens.to_string()),
        syn::Meta::NameValue(name_value) => Some(name_value.value.to_token_stream().to_string()),
    }
}

pub(crate) fn span_of(span: proc_macro2::Span) -> AstSpan {
    let start = span.start();
    let end = span.end();

    AstSpan {
        start_line: start.line,
        start_column: start.column,
        end_line: end.line,
        end_column: end.column,
    }
}

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

    fn parse_file(source: &str) -> syn::File {
        syn::parse_file(source).expect("source should parse")
    }

    #[test]
    fn collects_struct_fields_attrs_and_spans() {
        let file = parse_file(
            r#"
            #[derive(Accounts)]
            pub struct Example<'info> {
                #[account(mut, signer)]
                pub authority: Signer<'info>,
                pub count: u64,
            }
            "#,
        );

        let index = collect_ast_index(&file);
        assert_eq!(index.structs.len(), 1);

        let item = &index.structs[0];
        assert_eq!(item.name, "Example");
        assert_eq!(item.attrs.len(), 1);
        assert_eq!(item.attrs[0].path, "derive");
        assert_eq!(item.fields.len(), 2);
        assert_eq!(item.fields[0].name.as_deref(), Some("authority"));
        assert_eq!(item.fields[0].attrs.len(), 1);
        assert_eq!(item.fields[0].attrs[0].path, "account");
    }
}