Skip to main content

tuff_core/
check.rs

1use std::path::Path;
2
3use serde::Serialize;
4
5use crate::error::Result;
6use crate::lockfile;
7use crate::manifest::CapabilityType;
8
9#[derive(Debug, Serialize)]
10pub struct CheckResult {
11    pub id: String,
12    #[serde(rename = "type")]
13    pub capability_type: CapabilityType,
14    pub target: String,
15    pub status: String,
16    #[serde(skip_serializing_if = "Vec::is_empty")]
17    pub files: Vec<String>,
18}
19
20/// A policy rule recorded as not enforced for an agent (RFC-107 D6).
21#[derive(Debug, Serialize)]
22pub struct PolicyGap {
23    pub id: String,
24    pub target: String,
25    /// One-based position of the rule in the policy.
26    pub rule: usize,
27    pub description: String,
28    pub reason: String,
29}
30
31#[derive(Debug, Serialize)]
32pub struct CheckOutcome {
33    pub valid: bool,
34    pub results: Vec<CheckResult>,
35    /// Recorded policy rules an agent does not enforce. They do not affect
36    /// `valid`; `tuff check --strict` fails on them.
37    #[serde(skip_serializing_if = "Vec::is_empty")]
38    pub gaps: Vec<PolicyGap>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum CheckScope {
43    ProjectAndGlobal,
44    Global,
45    /// The project's lockfile only, as a console report describes it.
46    Project,
47}
48
49pub fn run_checks(repo_root: &Path, scope: CheckScope) -> Result<CheckOutcome> {
50    let mut results = Vec::new();
51    let mut gaps = Vec::new();
52
53    if scope == CheckScope::ProjectAndGlobal
54        && let Some(lf) = lockfile::read_optional_lockfile(&lockfile::project_lockfile(repo_root))?
55    {
56        check_lockfile(repo_root, &lf, &mut results, &mut gaps);
57    }
58
59    if scope == CheckScope::Project
60        && let Some(lf) = lockfile::read_optional_lockfile(&lockfile::project_lockfile(repo_root))?
61    {
62        check_lockfile(repo_root, &lf, &mut results, &mut gaps);
63    }
64
65    if scope != CheckScope::Project
66        && let Some(home) = home_dir()
67    {
68        let lock_path = crate::paths::global_lockfile(&home);
69        if let Some(lf) = lockfile::read_optional_lockfile(&lock_path)? {
70            check_lockfile(&home, &lf, &mut results, &mut gaps);
71        }
72    }
73
74    let valid = results.iter().all(|r| r.status == "ok");
75    Ok(CheckOutcome {
76        valid,
77        results,
78        gaps,
79    })
80}
81
82fn check_lockfile(
83    scope_root: &Path,
84    lf: &lockfile::Lockfile,
85    results: &mut Vec<CheckResult>,
86    gaps: &mut Vec<PolicyGap>,
87) {
88    for (id, entry) in lf.capabilities.iter() {
89        for (target_id, target_entry) in entry.targets.iter() {
90            for unenforced in &target_entry.unenforced_rules {
91                gaps.push(PolicyGap {
92                    id: id.clone(),
93                    target: target_id.clone(),
94                    rule: unenforced.rule,
95                    description: unenforced.description.clone(),
96                    reason: unenforced.reason.clone(),
97                });
98            }
99
100            let mut failing_files = Vec::new();
101
102            if target_entry.installed_path.is_empty() {
103                failing_files.push(id.clone());
104            } else {
105                let path = scope_root.join(&target_entry.installed_path);
106                match crate::cache::hash_tree(&path) {
107                    Ok(hash) if hash == target_entry.sha256 => {}
108                    Ok(_) | Err(_) => failing_files.push(target_entry.installed_path.clone()),
109                }
110            }
111
112            for hook in &target_entry.managed_hooks {
113                if lockfile::managed_hook_status(scope_root, hook) != "clean" {
114                    failing_files.push(format!("{}#{}", hook.settings_path, hook.event));
115                }
116            }
117
118            for permission in &target_entry.managed_permissions {
119                if crate::policy::managed_permission_status(scope_root, permission) != "clean" {
120                    let location = crate::policy::permission_location(permission);
121                    if !failing_files.contains(&location) {
122                        failing_files.push(location);
123                    }
124                }
125            }
126
127            if let Some(managed_entry) = &target_entry.managed_mcp_entry
128                && lockfile::managed_mcp_entry_status(scope_root, id, managed_entry) != "clean"
129            {
130                failing_files.push(format!("{}#{}", managed_entry.config_path, id));
131            }
132
133            let status = if failing_files.is_empty() {
134                "ok"
135            } else {
136                "modified"
137            };
138
139            results.push(CheckResult {
140                id: id.clone(),
141                capability_type: entry.capability_type,
142                target: target_id.clone(),
143                status: status.to_string(),
144                files: failing_files,
145            });
146        }
147    }
148}
149
150fn home_dir() -> Option<std::path::PathBuf> {
151    std::env::var("HOME").ok().map(std::path::PathBuf::from)
152}