rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
#[cfg(test)]
use googletest::prelude::*;
use ra_ap_syntax::{
    AstNode,
    ast::{self, HasAttrs, HasName},
};

use crate::{AstCtx, Example, Violation};

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "Debug on struct with password field",
        code: "#[derive(Debug)]\nstruct Creds { password: String }",
        pass: false,
    },
    Example {
        label: "Debug on struct without sensitive fields",
        code: "#[derive(Debug)]\nstruct User { name: String }",
        pass: true,
    },
    Example {
        label: "no Debug with password field",
        code: "struct Creds { password: String }",
        pass: true,
    },
    Example {
        label: "Debug on struct with token field",
        code: "#[derive(Debug)]\nstruct Auth { token: String }",
        pass: false,
    },
    Example {
        label: "Debug on struct with api_key field",
        code: "#[derive(Debug)]\nstruct Config { api_key: String }",
        pass: false,
    },
    Example {
        label: "Debug on struct with bearer field",
        code: "#[derive(Debug)]\nstruct Auth { bearer: String }",
        pass: false,
    },
    Example {
        label: "marker at a field-name boundary",
        code: "#[derive(Debug)]\nstruct Auth { oauth_bearer: String }",
        pass: false,
    },
    Example {
        label: "marker text inside an unrelated word",
        code: "#[derive(Debug)]\nstruct Parser { tokenizer: String }",
        pass: true,
    },
    Example {
        label: "sensitive struct in test module",
        code: "#[cfg(test)]\nmod tests {\n    #[derive(Debug)]\n    struct Creds { password: String }\n}",
        pass: true,
    },
];

crate::ast_rule!(
    sensitive_debug,
    "Flag `#[derive(Debug)]` on structs with sensitive fields like `password`.",
    "Deriving Debug on types with passwords or tokens risks leaking secrets in logs and error messages.",
    High,
    params {
        markers: [String] = [
            "api_key",
            "authorization",
            "bearer",
            "credential",
            "credentials",
            "password",
            "passwd",
            "private_key",
            "secret",
            "signing_key",
            "token",
        ],
        allowed_fields: [String] = [],
    },
);

fn check_sensitive_debug(ctx: &AstCtx<'_>) -> Vec<Violation> {
    let markers = sensitive_markers(ctx);
    let allowed = allowed_fields(ctx);

    ctx.nodes::<ast::Struct>()
        .filter(|item| !ctx.is_in_test(item) && has_debug_derive(item))
        .filter_map(|item| {
            let sensitive = find_sensitive_fields(&item, &markers, &allowed);
            let name = item.name()?;

            (!sensitive.is_empty()).then(|| {
                ctx.violation(
                    &name,
                    format!(
                        "#[derive(Debug)] on struct with sensitive field(s): {} — implement Debug manually to redact",
                        sensitive.join(", ")
                    ),
                )
            })
        })
        .collect()
}

fn allowed_fields(ctx: &AstCtx<'_>) -> Vec<String> {
    ctx.file
        .config
        .get_str_array("rust_sensitive_debug", &PARAMS[1])
}

fn sensitive_markers(ctx: &AstCtx<'_>) -> Vec<String> {
    ctx.file
        .config
        .get_str_array("rust_sensitive_debug", &PARAMS[0])
}

fn is_sensitive_field(name: &str, markers: &[String]) -> bool {
    markers.iter().any(|marker| {
        name == marker
            || name
                .strip_prefix(marker)
                .is_some_and(|suffix| suffix.starts_with('_'))
            || name
                .strip_suffix(marker)
                .is_some_and(|prefix| prefix.ends_with('_'))
    })
}

fn has_debug_derive(item: &ast::Struct) -> bool {
    item.attrs().any(|attr| {
        attr.as_simple_call().is_some_and(|(name, tokens)| {
            name == "derive"
                && tokens
                    .syntax()
                    .text()
                    .to_string()
                    .strip_prefix('(')
                    .and_then(|text| text.strip_suffix(')'))
                    .is_some_and(|entries| entries.split(',').any(|entry| entry.trim() == "Debug"))
        })
    })
}

pub(crate) fn has_sensitive_fields(ctx: &AstCtx<'_>, item: &ast::Struct) -> bool {
    !find_sensitive_fields(item, &sensitive_markers(ctx), &allowed_fields(ctx)).is_empty()
}

fn find_sensitive_fields(
    item: &ast::Struct,
    markers: &[String],
    allowed: &[String],
) -> Vec<String> {
    let Some(ast::FieldList::RecordFieldList(fields)) = item.field_list() else {
        return Vec::new();
    };

    let names = fields
        .fields()
        .filter_map(|field| field.name())
        .map(|name| name.text().to_string());

    names
        .filter(|name| !allowed.contains(name) && is_sensitive_field(name, markers))
        .collect()
}

crate::rulewright_ast_test!(check_sensitive_debug, {
    crate::example_tests!(EXAMPLES, check_sensitive_debug);

    #[gtest]
    fn markers_are_configurable() -> Result<()> {
        let violations = crate::test_support::check_source_ast_params(
            "#[derive(Debug)]\nstruct Session { session_cookie: String }",
            "rust_sensitive_debug",
            &[("markers", &["session_cookie"])],
            check_sensitive_debug,
        );

        verify_eq!(violations.len(), 1)
    }

    #[gtest]
    fn exact_domain_fields_can_be_allowed_without_disabling_the_marker() -> Result<()> {
        let source =
            "#[derive(Debug)]\nstruct Topology { topology_token: String, access_token: String }";
        let violations = crate::test_support::check_source_ast_params(
            source,
            "rust_sensitive_debug",
            &[("allowed_fields", &["topology_token"])],
            check_sensitive_debug,
        );

        verify_eq!(violations.len(), 1)?;
        verify_that!(
            violations[0].message.as_str(),
            contains_substring("access_token")
        )?;
        verify_that!(
            violations[0].message.as_str(),
            not(contains_substring("topology_token"))
        )
    }
});