openzeppelin_relayer/config/config_file/signer/
local.rs

1//! Local signer configuration for the OpenZeppelin Relayer.
2//!
3//! This module provides functionality for managing and validating local signer configurations
4//! that use filesystem-based keystores. It handles loading keystores from disk with passphrase
5//! protection, supporting both plain text and environment variable-based passphrases.
6//!
7//! # Features
8//!
9//! * Validation of signer file paths and passphrases
10//! * Support for environment variable-based passphrase retrieval
11use serde::{Deserialize, Serialize};
12
13use crate::{config::ConfigFileError, models::PlainOrEnvValue};
14
15use super::SignerConfigValidate;
16use std::path::Path;
17
18#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
19#[serde(deny_unknown_fields)]
20pub struct LocalSignerFileConfig {
21    pub path: String,
22    pub passphrase: PlainOrEnvValue,
23}
24
25impl LocalSignerFileConfig {
26    fn validate_path(&self) -> Result<(), ConfigFileError> {
27        if self.path.is_empty() {
28            return Err(ConfigFileError::InvalidIdLength(
29                "Signer path cannot be empty".into(),
30            ));
31        }
32
33        let path = Path::new(&self.path);
34        if !path.exists() {
35            return Err(ConfigFileError::FileNotFound(format!(
36                "Signer file not found at path: {}",
37                path.display()
38            )));
39        }
40
41        if !path.is_file() {
42            return Err(ConfigFileError::InvalidFormat(format!(
43                "Path exists but is not a file: {}",
44                path.display()
45            )));
46        }
47
48        Ok(())
49    }
50
51    fn validate_passphrase(&self) -> Result<(), ConfigFileError> {
52        match &self.passphrase {
53            PlainOrEnvValue::Env { value } => {
54                if value.is_empty() {
55                    return Err(ConfigFileError::MissingField(
56                        "Passphrase environment variable name cannot be empty".into(),
57                    ));
58                }
59                if std::env::var(value).is_err() {
60                    return Err(ConfigFileError::MissingEnvVar(format!(
61                        "Environment variable {} not found",
62                        value
63                    )));
64                }
65            }
66            PlainOrEnvValue::Plain { value } => {
67                if value.is_empty() {
68                    return Err(ConfigFileError::InvalidFormat(
69                        "Passphrase value cannot be empty".into(),
70                    ));
71                }
72            }
73        }
74
75        Ok(())
76    }
77}
78
79impl SignerConfigValidate for LocalSignerFileConfig {
80    fn validate(&self) -> Result<(), ConfigFileError> {
81        self.validate_path()?;
82        self.validate_passphrase()?;
83
84        Ok(())
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::models::SecretString;
92    use std::env;
93    use std::fs::File;
94    use std::io::Write;
95    use tempfile::tempdir;
96
97    #[test]
98    fn test_valid_local_signer_config() {
99        let temp_dir = tempdir().unwrap();
100        let file_path = temp_dir.path().join("test-keystore.json");
101        let mut file = File::create(&file_path).unwrap();
102        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
103
104        let config = LocalSignerFileConfig {
105            path: file_path.to_str().unwrap().to_string(),
106            passphrase: PlainOrEnvValue::Plain {
107                value: SecretString::new("password123"),
108            },
109        };
110
111        assert!(config.validate().is_ok());
112    }
113
114    #[test]
115    fn test_empty_path() {
116        let config = LocalSignerFileConfig {
117            path: "".to_string(),
118            passphrase: PlainOrEnvValue::Plain {
119                value: SecretString::new("password123"),
120            },
121        };
122
123        let result = config.validate();
124        assert!(result.is_err());
125        assert!(matches!(result, Err(ConfigFileError::InvalidIdLength(_))));
126    }
127
128    #[test]
129    fn test_nonexistent_path() {
130        let config = LocalSignerFileConfig {
131            path: "/tmp/definitely-doesnt-exist-12345.json".to_string(),
132            passphrase: PlainOrEnvValue::Plain {
133                value: SecretString::new("password123"),
134            },
135        };
136
137        let result = config.validate();
138        assert!(result.is_err());
139        assert!(matches!(result, Err(ConfigFileError::FileNotFound(_))));
140    }
141
142    #[test]
143    fn test_path_is_directory() {
144        let temp_dir = tempdir().unwrap();
145
146        let config = LocalSignerFileConfig {
147            path: temp_dir.path().to_str().unwrap().to_string(),
148            passphrase: PlainOrEnvValue::Plain {
149                value: SecretString::new("password123"),
150            },
151        };
152
153        let result = config.validate();
154        assert!(result.is_err());
155        assert!(matches!(result, Err(ConfigFileError::InvalidFormat(_))));
156    }
157
158    #[test]
159    fn test_empty_plain_passphrase() {
160        let temp_dir = tempdir().unwrap();
161        let file_path = temp_dir.path().join("test-keystore.json");
162        let mut file = File::create(&file_path).unwrap();
163        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
164
165        let config = LocalSignerFileConfig {
166            path: file_path.to_str().unwrap().to_string(),
167            passphrase: PlainOrEnvValue::Plain {
168                value: SecretString::new(""),
169            },
170        };
171
172        let result = config.validate();
173        assert!(result.is_err());
174        assert!(matches!(result, Err(ConfigFileError::InvalidFormat(_))));
175    }
176
177    #[test]
178    fn test_empty_env_name() {
179        let temp_dir = tempdir().unwrap();
180        let file_path = temp_dir.path().join("test-keystore.json");
181        let mut file = File::create(&file_path).unwrap();
182        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
183
184        let config = LocalSignerFileConfig {
185            path: file_path.to_str().unwrap().to_string(),
186            passphrase: PlainOrEnvValue::Env {
187                value: "".to_string(),
188            },
189        };
190
191        let result = config.validate();
192        assert!(result.is_err());
193        assert!(matches!(result, Err(ConfigFileError::MissingField(_))));
194    }
195
196    #[test]
197    fn test_missing_env_var() {
198        let temp_dir = tempdir().unwrap();
199        let file_path = temp_dir.path().join("test-keystore.json");
200        let mut file = File::create(&file_path).unwrap();
201        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
202
203        // Make sure this environment variable doesn't exist
204        env::remove_var("TEST_SIGNER_PASSPHRASE_THAT_DOESNT_EXIST");
205
206        let config = LocalSignerFileConfig {
207            path: file_path.to_str().unwrap().to_string(),
208            passphrase: PlainOrEnvValue::Env {
209                value: "TEST_SIGNER_PASSPHRASE_THAT_DOESNT_EXIST".to_string(),
210            },
211        };
212
213        let result = config.validate();
214        assert!(result.is_err());
215        assert!(matches!(result, Err(ConfigFileError::MissingEnvVar(_))));
216    }
217
218    #[test]
219    fn test_valid_env_var_passphrase() {
220        let temp_dir = tempdir().unwrap();
221        let file_path = temp_dir.path().join("test-keystore.json");
222        let mut file = File::create(&file_path).unwrap();
223        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
224
225        env::set_var("TEST_SIGNER_PASSPHRASE", "super-secret-passphrase");
226
227        let config = LocalSignerFileConfig {
228            path: file_path.to_str().unwrap().to_string(),
229            passphrase: PlainOrEnvValue::Env {
230                value: "TEST_SIGNER_PASSPHRASE".to_string(),
231            },
232        };
233
234        assert!(config.validate().is_ok());
235
236        env::remove_var("TEST_SIGNER_PASSPHRASE");
237    }
238
239    #[test]
240    fn test_serialize_deserialize() {
241        let config = LocalSignerFileConfig {
242            path: "/path/to/keystore.json".to_string(),
243            passphrase: PlainOrEnvValue::Plain {
244                value: SecretString::new("password123"),
245            },
246        };
247
248        let serialized = serde_json::to_string(&config).unwrap();
249        let deserialized: LocalSignerFileConfig = serde_json::from_str(&serialized).unwrap();
250
251        assert_eq!(config.path, deserialized.path);
252        assert_ne!(config.passphrase, deserialized.passphrase);
253    }
254
255    #[test]
256    fn test_deserialize_from_json() {
257        let json = r#"{
258            "path": "/path/to/keystore.json",
259            "passphrase": {
260                "type": "plain",
261                "value": "password123"
262            }
263        }"#;
264
265        let config: LocalSignerFileConfig = serde_json::from_str(json).unwrap();
266        assert_eq!(config.path, "/path/to/keystore.json");
267
268        if let PlainOrEnvValue::Plain { value } = &config.passphrase {
269            assert_eq!(value.to_str().as_str(), "password123");
270        } else {
271            panic!("Expected Plain passphrase variant");
272        }
273    }
274
275    #[test]
276    fn test_deserialize_env_passphrase() {
277        let json = r#"{
278            "path": "/path/to/keystore.json",
279            "passphrase": {
280                "type": "env",
281                "value": "KEYSTORE_PASSPHRASE"
282            }
283        }"#;
284
285        let config: LocalSignerFileConfig = serde_json::from_str(json).unwrap();
286        assert_eq!(config.path, "/path/to/keystore.json");
287
288        if let PlainOrEnvValue::Env { value } = &config.passphrase {
289            assert_eq!(value, "KEYSTORE_PASSPHRASE");
290        } else {
291            panic!("Expected Env passphrase variant");
292        }
293    }
294
295    #[test]
296    fn test_reject_unknown_fields() {
297        let json = r#"{
298            "path": "/path/to/keystore.json",
299            "passphrase": {
300                "type": "plain",
301                "value": "password123"
302            },
303            "unexpected_field": "should cause error"
304        }"#;
305
306        let result: Result<LocalSignerFileConfig, _> = serde_json::from_str(json);
307        assert!(result.is_err());
308    }
309
310    #[test]
311    fn test_validate_path_and_passphrase_methods() {
312        let temp_dir = tempdir().unwrap();
313        let file_path = temp_dir.path().join("test-keystore.json");
314        let mut file = File::create(&file_path).unwrap();
315        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
316
317        let config1 = LocalSignerFileConfig {
318            path: file_path.to_str().unwrap().to_string(),
319            passphrase: PlainOrEnvValue::Plain {
320                value: SecretString::new("password123"),
321            },
322        };
323        assert!(config1.validate_path().is_ok());
324
325        assert!(config1.validate_passphrase().is_ok());
326
327        let config2 = LocalSignerFileConfig {
328            path: "/nonexistent/path.json".to_string(),
329            passphrase: PlainOrEnvValue::Plain {
330                value: SecretString::new("password123"),
331            },
332        };
333        assert!(config2.validate_path().is_err());
334
335        let config3 = LocalSignerFileConfig {
336            path: file_path.to_str().unwrap().to_string(),
337            passphrase: PlainOrEnvValue::Plain {
338                value: SecretString::new(""),
339            },
340        };
341        assert!(config3.validate_passphrase().is_err());
342    }
343
344    #[test]
345    fn test_env_var_passphrase_with_special_chars() {
346        let temp_dir = tempdir().unwrap();
347        let file_path = temp_dir.path().join("test-keystore.json");
348        let mut file = File::create(&file_path).unwrap();
349        writeln!(file, "{{\"mock\": \"keystore\"}}").unwrap();
350
351        // Create a temporary .env file
352        let env_path = temp_dir.path().join(".env");
353        let mut env_file = File::create(&env_path).unwrap();
354        writeln!(
355            env_file,
356            "TEST_SIGNER_PASSPHRASE_SPECIAL=super#secret#passphrase"
357        )
358        .unwrap();
359
360        // Load the .env file
361        dotenvy::from_path(&env_path).unwrap();
362
363        let config = LocalSignerFileConfig {
364            path: file_path.to_str().unwrap().to_string(),
365            passphrase: PlainOrEnvValue::Env {
366                value: "TEST_SIGNER_PASSPHRASE_SPECIAL".to_string(),
367            },
368        };
369
370        assert!(config.validate().is_ok());
371        // Validate that the value from config matches the environment variable
372        if let PlainOrEnvValue::Env { value } = &config.passphrase {
373            assert_eq!(
374                std::env::var(value).unwrap(),
375                "super#secret#passphrase",
376                "Environment variable value should match the expected value"
377            );
378        } else {
379            panic!("Expected Env passphrase variant");
380        }
381    }
382}