arcature-cli 2026.1.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Defense-in-depth lint: detect secret-bearing field names in a browser
//! contract artifact.
//!
//! Field-name matching is **not** the security boundary (the explicit `impl
//! ClientData` opt-in plus the `render_page` type bound is). This is a
//! backstop diagnostic against accidentally exposing a secret-bearing field
//! through a `ClientData` type whose author named a field carelessly.

use std::collections::BTreeMap;

use super::artifact::{Prop, Type};

/// Field names that are lint hits when they appear in a browser-exposed
/// contract. Kept here so `arc check` and `arc exposure` share one list.
pub(crate) const DANGEROUS_FIELDS: &[&str] = &[
    "password",
    "password_hash",
    "secret",
    "token",
    "access_token",
    "refresh_token",
    "api_key",
    "private_key",
    "session_id",
    "cookie",
    "authorization",
    "credential",
];

/// A single dangerous-field lint finding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DangerousField {
    pub(crate) page: String,
    pub(crate) field: String,
}

/// Scan an artifact for dangerous field names. Returns findings in stable
/// (page, field) order.
pub(crate) fn dangerous_fields(
    pages: &BTreeMap<String, super::artifact::Page>,
) -> Vec<DangerousField> {
    let mut hits = Vec::new();
    for (page, schema) in pages {
        scan(page, "", &schema.props.fields, &mut hits);
    }
    hits
}

fn scan(page: &str, prefix: &str, fields: &BTreeMap<String, Prop>, hits: &mut Vec<DangerousField>) {
    for (name, prop) in fields {
        let path = if prefix.is_empty() {
            name.clone()
        } else {
            format!("{prefix}.{name}")
        };
        if DANGEROUS_FIELDS.iter().any(|danger| name == danger) {
            hits.push(DangerousField {
                page: page.to_owned(),
                field: path.clone(),
            });
        }
        if let Type::Object { fields } = &prop.ty {
            scan(page, &path, fields, hits);
        }
        if let Type::Array { item } = &prop.ty
            && let Type::Object { fields } = item.as_ref()
        {
            scan(page, &path, fields, hits);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::contracts::artifact::{Page, Props};

    fn prop(ty: Type) -> Prop {
        Prop { required: true, ty }
    }

    #[test]
    fn flags_password_field() {
        let mut fields = BTreeMap::new();
        fields.insert("password".to_owned(), prop(Type::String));
        let mut pages = BTreeMap::new();
        pages.insert(
            "Dashboard".to_owned(),
            Page {
                props: Props { fields },
            },
        );
        let hits = dangerous_fields(&pages);
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].field, "password");
    }

    #[test]
    fn flags_nested_secret_in_array() {
        let inner = BTreeMap::from([("token".to_owned(), prop(Type::String))]);
        let mut fields = BTreeMap::new();
        fields.insert(
            "users".to_owned(),
            prop(Type::Array {
                item: Box::new(Type::Object { fields: inner }),
            }),
        );
        let mut pages = BTreeMap::new();
        pages.insert(
            "Dashboard".to_owned(),
            Page {
                props: Props { fields },
            },
        );
        let hits = dangerous_fields(&pages);
        assert_eq!(hits.len(), 1);
        assert_eq!(hits[0].field, "users.token");
    }

    #[test]
    fn passes_clean_fields() {
        let mut fields = BTreeMap::new();
        fields.insert("name".to_owned(), prop(Type::String));
        let mut pages = BTreeMap::new();
        pages.insert(
            "Dashboard".to_owned(),
            Page {
                props: Props { fields },
            },
        );
        assert!(dangerous_fields(&pages).is_empty());
    }
}