Skip to main content

chainlink/
identity.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4
5/// Machine-local agent identity. Lives at `.chainlink/agent.json`.
6/// This file is gitignored — each machine has its own.
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct AgentConfig {
9    pub agent_id: String,
10    pub machine_id: String,
11    #[serde(default)]
12    pub description: Option<String>,
13}
14
15impl AgentConfig {
16    /// Load from the .chainlink directory. Returns None if agent.json doesn't exist.
17    pub fn load(chainlink_dir: &Path) -> Result<Option<Self>> {
18        let path = chainlink_dir.join("agent.json");
19        if !path.exists() {
20            return Ok(None);
21        }
22        let content = std::fs::read_to_string(&path)
23            .with_context(|| format!("Failed to read {}", path.display()))?;
24        let config: AgentConfig = serde_json::from_str(&content)
25            .with_context(|| format!("Failed to parse {}", path.display()))?;
26        config.validate()?;
27        Ok(Some(config))
28    }
29
30    /// Create and write a new agent config.
31    pub fn init(chainlink_dir: &Path, agent_id: &str, description: Option<&str>) -> Result<Self> {
32        let machine_id = detect_hostname();
33        let config = AgentConfig {
34            agent_id: agent_id.to_string(),
35            machine_id,
36            description: description.map(|s| s.to_string()),
37        };
38        config.validate()?;
39        let path = chainlink_dir.join("agent.json");
40        let json = serde_json::to_string_pretty(&config)?;
41        std::fs::write(&path, json)
42            .with_context(|| format!("Failed to write {}", path.display()))?;
43        Ok(config)
44    }
45
46    fn validate(&self) -> Result<()> {
47        anyhow::ensure!(!self.agent_id.is_empty(), "agent_id cannot be empty");
48        anyhow::ensure!(
49            self.agent_id.len() >= 3,
50            "agent_id must be at least 3 characters"
51        );
52        anyhow::ensure!(
53            self.agent_id
54                .chars()
55                .all(|c| c.is_alphanumeric() || c == '-' || c == '_'),
56            "agent_id must be alphanumeric with hyphens/underscores only"
57        );
58        anyhow::ensure!(
59            self.agent_id.len() <= 64,
60            "agent_id must be <= 64 characters"
61        );
62        anyhow::ensure!(
63            !is_windows_reserved_name(&self.agent_id),
64            "agent_id '{}' is a Windows reserved filename",
65            self.agent_id
66        );
67        Ok(())
68    }
69}
70
71/// Detect the hostname of the current machine.
72fn detect_hostname() -> String {
73    if let Ok(name) = std::env::var("COMPUTERNAME") {
74        return name;
75    }
76    if let Ok(name) = std::env::var("HOSTNAME") {
77        return name;
78    }
79    if let Ok(output) = std::process::Command::new("hostname").output() {
80        if output.status.success() {
81            let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
82            if !name.is_empty() {
83                return name;
84            }
85        }
86    }
87    "unknown".to_string()
88}
89
90/// Check if a name is a Windows reserved filename (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
91fn is_windows_reserved_name(name: &str) -> bool {
92    let upper = name.to_uppercase();
93    matches!(
94        upper.as_str(),
95        "CON"
96            | "PRN"
97            | "AUX"
98            | "NUL"
99            | "COM1"
100            | "COM2"
101            | "COM3"
102            | "COM4"
103            | "COM5"
104            | "COM6"
105            | "COM7"
106            | "COM8"
107            | "COM9"
108            | "LPT1"
109            | "LPT2"
110            | "LPT3"
111            | "LPT4"
112            | "LPT5"
113            | "LPT6"
114            | "LPT7"
115            | "LPT8"
116            | "LPT9"
117    )
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123    use tempfile::tempdir;
124
125    fn test_config(agent_id: &str) -> AgentConfig {
126        AgentConfig {
127            agent_id: agent_id.to_string(),
128            machine_id: "test".to_string(),
129            description: None,
130        }
131    }
132
133    #[test]
134    fn test_load_missing_file() {
135        let dir = tempdir().unwrap();
136        let result = AgentConfig::load(dir.path()).unwrap();
137        assert!(result.is_none());
138    }
139
140    #[test]
141    fn test_init_and_load_roundtrip() {
142        let dir = tempdir().unwrap();
143        let config = AgentConfig::init(dir.path(), "worker-1", Some("Test agent")).unwrap();
144        assert_eq!(config.agent_id, "worker-1");
145        assert_eq!(config.description, Some("Test agent".to_string()));
146        assert!(!config.machine_id.is_empty());
147
148        let loaded = AgentConfig::load(dir.path()).unwrap().unwrap();
149        assert_eq!(loaded.agent_id, config.agent_id);
150        assert_eq!(loaded.machine_id, config.machine_id);
151        assert_eq!(loaded.description, config.description);
152    }
153
154    #[test]
155    fn test_validate_empty_id() {
156        assert!(test_config("").validate().is_err());
157    }
158
159    #[test]
160    fn test_validate_invalid_chars() {
161        assert!(test_config("worker 1").validate().is_err());
162        assert!(test_config("worker@1").validate().is_err());
163    }
164
165    #[test]
166    fn test_validate_too_short() {
167        assert!(test_config("ab").validate().is_err());
168        assert!(test_config("abc").validate().is_ok());
169    }
170
171    #[test]
172    fn test_validate_valid_ids() {
173        for id in &["worker-1", "agent_2", "MyAgent", "abc", "test-agent-42"] {
174            assert!(test_config(id).validate().is_ok(), "Failed for id: {}", id);
175        }
176    }
177
178    #[test]
179    fn test_validate_rejects_windows_reserved() {
180        for id in &["CON", "con", "PRN", "AUX", "NUL", "COM1", "LPT1"] {
181            assert!(test_config(id).validate().is_err(), "Should reject: {}", id);
182        }
183    }
184
185    #[test]
186    fn test_json_roundtrip() {
187        let config = AgentConfig {
188            description: Some("Test agent".to_string()),
189            machine_id: "my-host".to_string(),
190            ..test_config("worker-1")
191        };
192        let json = serde_json::to_string(&config).unwrap();
193        let parsed: AgentConfig = serde_json::from_str(&json).unwrap();
194        assert_eq!(config, parsed);
195    }
196
197    #[test]
198    fn test_json_missing_description_defaults_none() {
199        let json = r#"{"agent_id": "worker-1", "machine_id": "host"}"#;
200        let config: AgentConfig = serde_json::from_str(json).unwrap();
201        assert!(config.description.is_none());
202    }
203
204    #[test]
205    fn test_detect_hostname_returns_something() {
206        let hostname = detect_hostname();
207        assert!(!hostname.is_empty());
208    }
209}