1use crate::defaults::{default_version, default_honeytoken_count, default_artifact_permissions, default_event_buffer_size, default_log_format, default_rotate_size, default_max_log_files, default_log_level};
11use crate::errors::{self, UnixPermissionError, PathValidationError, CollectionValidationError, RangeValidationError};
12use crate::tags::RootTag;
13use crate::timing::{enforce_operation_min_timing, TimingOperation};
14use crate::validation::ValidationMode;
15use crate::CONFIG_VERSION;
16use palisade_errors::Result;
17use serde::{Deserialize, Serialize};
18use std::path::{Path, PathBuf};
19use std::time::Instant;
20use zeroize::{Zeroize, ZeroizeOnDrop};
21
22#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
24pub struct Config {
25 #[serde(default = "default_version")]
27 pub version: u32,
28
29 pub agent: AgentConfig,
31
32 pub deception: DeceptionConfig,
34
35 pub telemetry: TelemetryConfig,
37
38 pub logging: LoggingConfig,
40}
41
42#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
44pub struct AgentConfig {
45 #[serde(skip, default)]
47 pub instance_id: ProtectedString,
48
49 #[serde(skip, default)]
51 pub work_dir: ProtectedPath,
52
53 #[serde(default)]
55 pub environment: Option<String>,
56
57 #[serde(default)]
59 pub hostname: Option<String>,
60
61 #[serde(rename = "instance_id")]
63 instance_id_raw: String,
64 #[serde(rename = "work_dir")]
65 work_dir_raw: String,
66}
67
68#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
70pub struct DeceptionConfig {
71 #[serde(default)]
73 #[zeroize(skip)]
74 pub decoy_paths: Box<[PathBuf]>,
75
76 #[serde(deserialize_with = "deserialize_boxed_strings")]
78 pub credential_types: Box<[String]>,
79
80 #[serde(default = "default_honeytoken_count")]
82 pub honeytoken_count: usize,
83
84 pub root_tag: RootTag,
86
87 #[serde(default = "default_artifact_permissions")]
89 pub artifact_permissions: u32,
90}
91
92fn deserialize_boxed_strings<'de, D>(deserializer: D) -> std::result::Result<Box<[String]>, D::Error>
94where
95 D: serde::Deserializer<'de>,
96{
97 let vec = Vec::<String>::deserialize(deserializer)?;
98 Ok(vec.into_boxed_slice())
99}
100
101#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
103pub struct TelemetryConfig {
104 #[zeroize(skip)]
106 pub watch_paths: Box<[PathBuf]>,
107
108 #[serde(default = "default_event_buffer_size")]
110 pub event_buffer_size: usize,
111
112 #[serde(default)]
114 pub enable_syscall_monitor: bool,
115}
116
117#[derive(Debug, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
119pub struct LoggingConfig {
120 #[zeroize(skip)]
122 pub log_path: PathBuf,
123
124 #[serde(default = "default_log_format")]
126 pub format: LogFormat,
127
128 #[serde(default = "default_rotate_size")]
130 pub rotate_size_bytes: u64,
131
132 #[serde(default = "default_max_log_files")]
134 pub max_log_files: usize,
135
136 #[serde(default = "default_log_level")]
138 pub level: LogLevel,
139}
140
141#[derive(Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
143#[serde(rename_all = "lowercase")]
144pub enum LogFormat {
145 Json,
147 Text,
149}
150
151#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
153#[serde(rename_all = "UPPERCASE")]
154pub enum LogLevel {
155 Debug,
157 Info,
159 Warn,
161 Error,
163}
164
165#[derive(Zeroize, ZeroizeOnDrop, Default)]
167pub struct ProtectedString {
168 #[zeroize(skip)]
169 inner: String,
170}
171
172impl ProtectedString {
173 #[inline]
175 #[must_use]
176 pub fn new(s: String) -> Self {
177 Self { inner: s }
178 }
179
180 #[inline]
182 #[must_use]
183 pub fn as_str(&self) -> &str {
184 &self.inner
185 }
186
187 #[inline]
189 #[must_use]
190 pub fn into_inner(mut self) -> String {
191 std::mem::take(&mut self.inner)
192 }
193}
194
195impl std::fmt::Debug for ProtectedString {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 write!(f, "ProtectedString([REDACTED])")
198 }
199}
200
201#[derive(Zeroize, ZeroizeOnDrop, Default)]
203pub struct ProtectedPath {
204 #[zeroize(skip)]
205 inner: PathBuf,
206}
207
208impl ProtectedPath {
209 #[inline]
211 #[must_use]
212 pub fn new(path: PathBuf) -> Self {
213 Self { inner: path }
214 }
215
216 #[inline]
218 #[must_use]
219 pub fn as_path(&self) -> &Path {
220 &self.inner
221 }
222
223 #[inline]
225 #[must_use]
226 pub fn into_inner(mut self) -> PathBuf {
227 std::mem::replace(&mut self.inner, PathBuf::new())
228 }
229}
230
231impl std::fmt::Debug for ProtectedPath {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 write!(f, "ProtectedPath([REDACTED])")
234 }
235}
236
237impl Config {
238 pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
244 Self::from_file_with_mode(path, ValidationMode::Standard).await
245 }
246
247 pub async fn from_file_with_mode<P: AsRef<Path>>(path: P, mode: ValidationMode) -> Result<Self> {
253 let started = Instant::now();
254 let path = path.as_ref();
255 let result = async {
256 Self::validate_file_permissions(path)?;
258
259 let contents = tokio::fs::read_to_string(path)
260 .await
261 .map_err(|e| errors::io_read_error("load_config", path, e))?;
262
263 let mut config: Config = toml::from_str(&contents).map_err(|e| {
264 let location = e
265 .span().map_or_else(|| "unknown location".to_string(), |s| format!("line {}", contents[..s.start].matches('\n').count() + 1));
266
267 errors::parse_error(
268 "parse_config_toml",
269 format!("Invalid TOML syntax at {location}: {e}"),
270 )
271 })?;
272
273 config.agent.instance_id =
275 ProtectedString::new(std::mem::take(&mut config.agent.instance_id_raw));
276 config.agent.work_dir =
277 ProtectedPath::new(PathBuf::from(std::mem::take(&mut config.agent.work_dir_raw)));
278
279 if config.version != CONFIG_VERSION {
281 let message = if config.version > CONFIG_VERSION {
282 "Configuration version too new - upgrade agent"
283 } else {
284 "Configuration version outdated - update config"
285 };
286
287 return Err(errors::version_error(
288 "validate_config_version",
289 config.version,
290 CONFIG_VERSION,
291 message,
292 ));
293 }
294
295 config.validate_with_mode(mode)?;
296
297 Ok(config)
298 }
299 .await;
300 enforce_operation_min_timing(started, TimingOperation::ConfigLoad);
301 result
302 }
303
304 fn validate_with_mode(&self, mode: ValidationMode) -> Result<()> {
306 let started = Instant::now();
307 let result = (|| {
308 self.validate_agent()?;
309 self.validate_deception(mode)?;
310 self.validate_telemetry(mode)?;
311 self.validate_logging(mode)?;
312 Ok(())
313 })();
314 enforce_operation_min_timing(
315 started,
316 match mode {
317 ValidationMode::Standard => TimingOperation::ConfigValidateStandard,
318 ValidationMode::Strict => TimingOperation::ConfigValidateStrict,
319 },
320 );
321 result
322 }
323
324 pub fn validate(&self) -> Result<()> {
326 self.validate_with_mode(ValidationMode::Standard)
327 }
328
329 #[cfg(unix)]
330 fn validate_file_permissions(path: &Path) -> Result<()> {
331 use std::os::unix::fs::PermissionsExt;
332
333 let metadata = std::fs::metadata(path)
334 .map_err(|e| errors::io_metadata_error("validate_config_file", path, e))?;
335
336 let mode = metadata.permissions().mode();
337
338 if (mode & 0o077) != 0 {
340 return Err(UnixPermissionError::insecure_permissions(mode, "0o600"));
341 }
342
343 Ok(())
344 }
345
346 #[cfg(not(unix))]
347 fn validate_file_permissions(_path: &Path) -> Result<()> {
348 Ok(())
350 }
351
352 fn validate_agent(&self) -> Result<()> {
353 if self.agent.instance_id.as_str().is_empty() {
354 return Err(errors::missing_required(
355 "validate_agent",
356 "agent.instance_id",
357 "no_telemetry_correlation",
358 ));
359 }
360
361 if !self.agent.work_dir.as_path().is_absolute() {
362 return Err(PathValidationError::not_absolute(
363 "agent.work_dir",
364 "validate_agent",
365 ));
366 }
367
368 Ok(())
369 }
370
371 fn validate_deception(&self, mode: ValidationMode) -> Result<()> {
372 if self.deception.decoy_paths.is_empty() {
373 return Err(CollectionValidationError::empty(
374 "deception.decoy_paths",
375 "no_deception",
376 "validate_deception",
377 ));
378 }
379
380 for (idx, path) in self.deception.decoy_paths.iter().enumerate() {
381 if !path.is_absolute() {
382 return Err(PathValidationError::not_absolute(
383 "deception.decoy_paths",
384 "validate_deception",
385 ));
386 }
387
388 if mode == ValidationMode::Strict
389 && let Some(parent) = path.parent()
390 && !parent.exists() {
391 return Err(PathValidationError::parent_missing(
392 "deception.decoy_paths",
393 Some(idx),
394 "validate_deception",
395 ));
396 }
397 }
398
399 if self.deception.credential_types.is_empty() {
400 return Err(CollectionValidationError::empty(
401 "deception.credential_types",
402 "no_credential_types",
403 "validate_deception",
404 ));
405 }
406
407 if self.deception.honeytoken_count == 0 || self.deception.honeytoken_count > 100 {
408 return Err(RangeValidationError::out_of_range(
409 "deception.honeytoken_count",
410 self.deception.honeytoken_count,
411 1,
412 100,
413 "validate_deception",
414 ));
415 }
416
417 if self.deception.artifact_permissions > 0o777 {
418 return Err(RangeValidationError::above_maximum(
419 "deception.artifact_permissions",
420 format!("{:o}", self.deception.artifact_permissions),
421 "0o777".to_string(),
422 "validate_deception",
423 ));
424 }
425
426 Ok(())
427 }
428
429 fn validate_telemetry(&self, mode: ValidationMode) -> Result<()> {
430 if self.telemetry.watch_paths.is_empty() {
431 return Err(CollectionValidationError::empty(
432 "telemetry.watch_paths",
433 "no_monitoring",
434 "validate_telemetry",
435 ));
436 }
437
438 for (idx, path) in self.telemetry.watch_paths.iter().enumerate() {
439 if !path.is_absolute() {
440 return Err(PathValidationError::not_absolute(
441 "telemetry.watch_paths",
442 "validate_telemetry",
443 ));
444 }
445
446 if mode == ValidationMode::Strict && !path.exists() {
447 return Err(PathValidationError::not_found(
448 "telemetry.watch_paths",
449 Some(idx),
450 "validate_telemetry",
451 ));
452 }
453 }
454
455 if self.telemetry.event_buffer_size < 100 {
456 return Err(RangeValidationError::below_minimum(
457 "telemetry.event_buffer_size",
458 self.telemetry.event_buffer_size,
459 100,
460 "validate_telemetry",
461 ));
462 }
463
464 Ok(())
465 }
466
467 fn validate_logging(&self, mode: ValidationMode) -> Result<()> {
468 if !self.logging.log_path.is_absolute() {
469 return Err(PathValidationError::not_absolute(
470 "logging.log_path",
471 "validate_logging",
472 ));
473 }
474
475 if mode == ValidationMode::Strict
476 && let Some(parent) = self.logging.log_path.parent() {
477 if !parent.exists() {
478 return Err(PathValidationError::parent_missing(
479 "logging.log_path",
480 None,
481 "validate_logging",
482 ));
483 }
484
485 let test_file = parent.join(".palisade-write-test");
486 std::fs::write(&test_file, b"test")
487 .map_err(|e| errors::io_write_error("test_log_directory_write", &test_file, e))?;
488 let _ = std::fs::remove_file(&test_file);
489 }
490
491 if self.logging.rotate_size_bytes < 1024 * 1024 {
492 return Err(RangeValidationError::below_minimum(
493 "logging.rotate_size_bytes",
494 self.logging.rotate_size_bytes,
495 1024 * 1024,
496 "validate_logging",
497 ));
498 }
499
500 if self.logging.max_log_files == 0 {
501 return Err(errors::invalid_value(
502 "validate_logging",
503 "logging.max_log_files",
504 "logging.max_log_files cannot be zero",
505 ));
506 }
507
508 Ok(())
509 }
510
511 #[must_use]
513 pub fn hostname(&self) -> std::borrow::Cow<'_, str> {
514 let started = Instant::now();
515 let hostname = if let Some(h) = &self.agent.hostname { std::borrow::Cow::Borrowed(h.as_str()) } else {
516 let system_hostname = hostname::get()
518 .ok()
519 .and_then(|h| h.into_string().ok())
520 .unwrap_or_else(|| "unknown-host".to_string());
521 std::borrow::Cow::Owned(system_hostname)
522 };
523 enforce_operation_min_timing(started, TimingOperation::ConfigHostname);
524 hostname
525 }
526}
527
528impl Default for Config {
529 fn default() -> Self {
530 let default_instance_id = hostname::get()
531 .ok()
532 .and_then(|h| h.into_string().ok())
533 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
534
535 Self {
536 version: CONFIG_VERSION,
537 agent: AgentConfig {
538 instance_id: ProtectedString::new(default_instance_id.clone()),
539 work_dir: ProtectedPath::new(PathBuf::from("/var/lib/palisade-agent")),
540 environment: None,
541 hostname: None,
542 instance_id_raw: default_instance_id,
543 work_dir_raw: "/var/lib/palisade-agent".to_string(),
544 },
545 deception: DeceptionConfig {
546 decoy_paths: vec![
547 PathBuf::from("/tmp/.credentials"),
548 PathBuf::from("/opt/.backup"),
549 ]
550 .into_boxed_slice(),
551 credential_types: vec!["aws".to_string(), "ssh".to_string()].into_boxed_slice(),
552 honeytoken_count: 5,
553 root_tag: RootTag::generate().expect("Failed to generate root tag - system entropy failure"),
554 artifact_permissions: 0o600,
555 },
556 telemetry: TelemetryConfig {
557 watch_paths: vec![PathBuf::from("/tmp")].into_boxed_slice(),
558 event_buffer_size: 10_000,
559 enable_syscall_monitor: false,
560 },
561 logging: LoggingConfig {
562 log_path: PathBuf::from("/var/log/palisade-agent.log"),
563 format: LogFormat::Json,
564 rotate_size_bytes: 100 * 1024 * 1024,
565 max_log_files: 10,
566 level: LogLevel::Info,
567 },
568 }
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn default_config_validates() {
578 let config = Config::default();
579 assert!(config.validate().is_ok());
580 }
581
582 #[test]
583 fn hostname_fallback() {
584 let config = Config::default();
585 let hostname = config.hostname();
586 assert!(!hostname.is_empty());
587 }
588
589 #[test]
590 fn protected_string_redacts_in_debug() {
591 let protected = ProtectedString::new("secret123".to_string());
592 let debug = format!("{:?}", protected);
593 assert!(!debug.contains("secret123"));
594 assert!(debug.contains("REDACTED"));
595 }
596
597 #[test]
598 fn protected_path_redacts_in_debug() {
599 let protected = ProtectedPath::new(PathBuf::from("/etc/shadow"));
600 let debug = format!("{:?}", protected);
601 assert!(!debug.contains("shadow"));
602 assert!(debug.contains("REDACTED"));
603 }
604
605 #[test]
606 fn validation_catches_empty_instance_id() {
607 let mut config = Config::default();
608 config.agent.instance_id = ProtectedString::new(String::new());
609
610 let result = config.validate();
611 assert!(result.is_err());
612
613 if let Err(err) = result {
614 assert!(err.to_string().contains("Configuration"));
615 }
616 }
617
618 #[test]
619 fn validation_catches_relative_work_dir() {
620 let mut config = Config::default();
621 config.agent.work_dir = ProtectedPath::new(PathBuf::from("relative/path"));
622
623 let result = config.validate();
624 assert!(result.is_err());
625 }
626
627 #[test]
628 fn validation_catches_empty_decoy_paths() {
629 let mut config = Config::default();
630 config.deception.decoy_paths = Box::new([]);
631 assert!(config.validate().is_err());
632 }
633
634 #[test]
635 fn validation_catches_invalid_honeytoken_count() {
636 let mut config = Config::default();
637 config.deception.honeytoken_count = 0;
638 assert!(config.validate().is_err());
639
640 let mut config = Config::default();
641 config.deception.honeytoken_count = 101;
642 assert!(config.validate().is_err());
643 }
644}