1use std::path::{Path, PathBuf};
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use serde::{Deserialize, Serialize};
35use sha2::{Digest, Sha256};
36
37use crate::fs::Fs;
38use crate::paths::Pather;
39use crate::{DodotError, Result};
40
41pub const SCHEMA_VERSION: u32 = 1;
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46pub struct Baseline {
47 pub version: u32,
49 #[serde(default)]
59 pub source_path: PathBuf,
60 pub rendered_hash: String,
62 pub rendered_content: String,
66 pub source_hash: String,
71 #[serde(default)]
77 pub context_hash: String,
78 #[serde(default)]
86 pub tracked_render: String,
87 pub timestamp: u64,
91}
92
93impl Baseline {
94 pub fn build(
103 source_path: &Path,
104 rendered_content: &[u8],
105 source_bytes: &[u8],
106 tracked_render: Option<&str>,
107 context_hash: Option<&[u8; 32]>,
108 ) -> Self {
109 Self {
110 version: SCHEMA_VERSION,
111 source_path: source_path.to_path_buf(),
112 rendered_hash: hex_sha256(rendered_content),
113 rendered_content: String::from_utf8_lossy(rendered_content).into_owned(),
114 source_hash: hex_sha256(source_bytes),
115 context_hash: context_hash.map(hex_encode_32).unwrap_or_default(),
116 tracked_render: tracked_render.unwrap_or("").to_string(),
117 timestamp: now_secs_unix(),
118 }
119 }
120
121 pub fn write(
125 &self,
126 fs: &dyn Fs,
127 paths: &dyn Pather,
128 pack: &str,
129 handler: &str,
130 filename: &str,
131 ) -> Result<PathBuf> {
132 let path = paths.preprocessor_baseline_path(pack, handler, filename);
133 if let Some(parent) = path.parent() {
134 fs.mkdir_all(parent)?;
135 }
136 let body = serde_json::to_string_pretty(self).map_err(|e| {
137 DodotError::Other(format!(
138 "failed to serialise baseline for {pack}/{handler}/{filename}: {e}"
139 ))
140 })?;
141 fs.write_file(&path, body.as_bytes())?;
142 Ok(path)
143 }
144
145 pub fn load(
151 fs: &dyn Fs,
152 paths: &dyn Pather,
153 pack: &str,
154 handler: &str,
155 filename: &str,
156 ) -> Result<Option<Self>> {
157 let path = paths.preprocessor_baseline_path(pack, handler, filename);
158 if !fs.exists(&path) {
159 return Ok(None);
160 }
161 let raw = fs.read_to_string(&path)?;
162 let baseline: Self = serde_json::from_str(&raw).map_err(|e| {
163 DodotError::Other(format!(
164 "failed to parse baseline at {}: {e}\n \
165 Try `dodot up --force` to re-baseline.",
166 path.display()
167 ))
168 })?;
169 if baseline.version != SCHEMA_VERSION {
170 return Err(DodotError::Other(format!(
171 "baseline at {} has unsupported schema version {} (expected {}). \
172 Clear the file and run `dodot up` to rebuild.",
173 path.display(),
174 baseline.version,
175 SCHEMA_VERSION
176 )));
177 }
178 Ok(Some(baseline))
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
194pub struct SecretsSidecar {
195 pub version: u32,
198 pub secret_line_ranges: Vec<crate::preprocessing::SecretLineRange>,
203}
204
205pub const SECRETS_SIDECAR_VERSION: u32 = 1;
207
208impl SecretsSidecar {
209 pub fn new(ranges: Vec<crate::preprocessing::SecretLineRange>) -> Self {
211 Self {
212 version: SECRETS_SIDECAR_VERSION,
213 secret_line_ranges: ranges,
214 }
215 }
216
217 pub fn write(
231 &self,
232 fs: &dyn Fs,
233 paths: &dyn Pather,
234 pack: &str,
235 handler: &str,
236 filename: &str,
237 ) -> Result<Option<PathBuf>> {
238 let path = paths.preprocessor_secrets_sidecar_path(pack, handler, filename);
239 if self.secret_line_ranges.is_empty() {
240 if fs.exists(&path) {
243 fs.remove_file(&path)?;
244 }
245 return Ok(None);
246 }
247 if let Some(parent) = path.parent() {
248 fs.mkdir_all(parent)?;
249 }
250 let body = serde_json::to_string_pretty(self).map_err(|e| {
251 DodotError::Other(format!(
252 "failed to serialise secrets sidecar for {pack}/{handler}/{filename}: {e}"
253 ))
254 })?;
255 fs.write_file(&path, body.as_bytes())?;
256 Ok(Some(path))
257 }
258
259 pub fn load(
264 fs: &dyn Fs,
265 paths: &dyn Pather,
266 pack: &str,
267 handler: &str,
268 filename: &str,
269 ) -> Result<Option<Self>> {
270 let path = paths.preprocessor_secrets_sidecar_path(pack, handler, filename);
271 if !fs.exists(&path) {
272 return Ok(None);
273 }
274 let raw = fs.read_to_string(&path)?;
275 let sidecar: Self = serde_json::from_str(&raw).map_err(|e| {
276 DodotError::Other(format!(
277 "failed to parse secrets sidecar at {}: {e}\n \
278 Run `dodot up --force` to re-render and rewrite the sidecar.",
279 path.display()
280 ))
281 })?;
282 if sidecar.version != SECRETS_SIDECAR_VERSION {
283 return Err(DodotError::Other(format!(
284 "secrets sidecar at {} has unsupported schema version {} (expected {}). \
285 Clear the file and run `dodot up --force` to rebuild.",
286 path.display(),
287 sidecar.version,
288 SECRETS_SIDECAR_VERSION
289 )));
290 }
291 Ok(Some(sidecar))
292 }
293}
294
295pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
301 let mut hasher = Sha256::new();
302 hasher.update(bytes);
303 hex_encode_32(&hasher.finalize().into())
304}
305
306fn hex_encode_32(bytes: &[u8; 32]) -> String {
307 let mut out = String::with_capacity(64);
308 for b in bytes {
309 out.push(hex_nibble(b >> 4));
310 out.push(hex_nibble(b & 0x0f));
311 }
312 out
313}
314
315fn hex_nibble(n: u8) -> char {
316 match n {
317 0..=9 => (b'0' + n) as char,
318 10..=15 => (b'a' + n - 10) as char,
319 _ => unreachable!(),
320 }
321}
322
323fn now_secs_unix() -> u64 {
324 SystemTime::now()
325 .duration_since(UNIX_EPOCH)
326 .map(|d| d.as_secs())
327 .unwrap_or(0)
328}
329
330pub fn cache_filename_for(virtual_relative: &Path) -> String {
343 virtual_relative
344 .file_name()
345 .map(|n| n.to_string_lossy().into_owned())
346 .unwrap_or_else(|| virtual_relative.to_string_lossy().into_owned())
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use crate::testing::TempEnvironment;
353
354 #[test]
355 fn build_then_write_then_load_round_trips() {
356 let env = TempEnvironment::builder().build();
357 let baseline = Baseline::build(
358 Path::new("/tmp/config.toml.tmpl"),
359 b"name = Alice\n",
360 b"name = {{ name }}\n",
361 Some("name = \u{1e}Alice\u{1f}\n"),
362 Some(&[0x42; 32]),
363 );
364 let path = baseline
365 .write(
366 env.fs.as_ref(),
367 env.paths.as_ref(),
368 "app",
369 "preprocessed",
370 "config.toml",
371 )
372 .unwrap();
373 assert!(env.fs.exists(&path));
374
375 let loaded = Baseline::load(
376 env.fs.as_ref(),
377 env.paths.as_ref(),
378 "app",
379 "preprocessed",
380 "config.toml",
381 )
382 .unwrap()
383 .expect("baseline must exist after write");
384 assert_eq!(loaded, baseline);
385 }
386
387 #[test]
388 fn load_returns_none_for_missing_file() {
389 let env = TempEnvironment::builder().build();
390 let result = Baseline::load(
391 env.fs.as_ref(),
392 env.paths.as_ref(),
393 "app",
394 "preprocessed",
395 "nope.toml",
396 )
397 .unwrap();
398 assert!(result.is_none());
399 }
400
401 #[test]
402 fn load_rejects_unsupported_schema_version() {
403 let env = TempEnvironment::builder().build();
404 let path = env
405 .paths
406 .preprocessor_baseline_path("app", "preprocessed", "config.toml");
407 env.fs.mkdir_all(path.parent().unwrap()).unwrap();
408 env.fs
409 .write_file(
410 &path,
411 br#"{"version": 999, "rendered_hash": "x", "rendered_content": "x", "source_hash": "x", "timestamp": 0}"#,
412 )
413 .unwrap();
414
415 let err = Baseline::load(
416 env.fs.as_ref(),
417 env.paths.as_ref(),
418 "app",
419 "preprocessed",
420 "config.toml",
421 )
422 .unwrap_err();
423 assert!(
424 format!("{err}").contains("unsupported schema version"),
425 "got: {err}"
426 );
427 }
428
429 #[test]
430 fn load_rejects_corrupted_json() {
431 let env = TempEnvironment::builder().build();
432 let path = env
433 .paths
434 .preprocessor_baseline_path("app", "preprocessed", "config.toml");
435 env.fs.mkdir_all(path.parent().unwrap()).unwrap();
436 env.fs.write_file(&path, b"{not json").unwrap();
437
438 let err = Baseline::load(
439 env.fs.as_ref(),
440 env.paths.as_ref(),
441 "app",
442 "preprocessed",
443 "config.toml",
444 )
445 .unwrap_err();
446 let msg = format!("{err}");
447 assert!(msg.contains("failed to parse"), "got: {msg}");
448 assert!(
451 msg.contains("--force"),
452 "expected recovery hint, got: {msg}"
453 );
454 }
455
456 #[test]
457 fn build_records_hashes_and_optional_fields() {
458 let p = Path::new("/dummy/source");
460 let b = Baseline::build(p, b"hello", b"hello", None, None);
461 assert_eq!(b.version, SCHEMA_VERSION);
462 assert_eq!(b.source_path, p);
463 assert_eq!(b.rendered_hash.len(), 64); assert_eq!(b.source_hash, b.rendered_hash); assert!(b.context_hash.is_empty());
466 assert!(b.tracked_render.is_empty());
467
468 let b2 = Baseline::build(p, b"x", b"y", Some("tracked"), Some(&[0xff; 32]));
470 assert_eq!(b2.context_hash.len(), 64);
471 assert!(b2.context_hash.chars().all(|c| c == 'f'));
472 assert_eq!(b2.tracked_render, "tracked");
473 }
474
475 #[test]
476 fn rendered_content_preserves_lossy_utf8() {
477 let b = Baseline::build(
481 Path::new("/dummy"),
482 &[0x66, 0x6f, 0xff, 0x6f],
483 b"src",
484 None,
485 None,
486 );
487 assert_eq!(b.rendered_content, "fo\u{fffd}o");
489 }
490
491 #[test]
492 fn write_creates_nested_directories() {
493 let env = TempEnvironment::builder().build();
496 let baseline = Baseline::build(Path::new("/dummy"), b"x", b"y", None, None);
497 let path = baseline
498 .write(
499 env.fs.as_ref(),
500 env.paths.as_ref(),
501 "deep",
502 "preprocessed",
503 "x",
504 )
505 .unwrap();
506 assert!(env.fs.exists(&path));
507 assert!(env.fs.is_dir(path.parent().unwrap()));
508 }
509
510 #[test]
511 fn write_overwrites_existing_baseline() {
512 let env = TempEnvironment::builder().build();
514 let first = Baseline::build(Path::new("/dummy"), b"first", b"src", None, None);
515 first
516 .write(
517 env.fs.as_ref(),
518 env.paths.as_ref(),
519 "app",
520 "preprocessed",
521 "f",
522 )
523 .unwrap();
524 let second = Baseline::build(Path::new("/dummy"), b"second", b"src", None, None);
525 second
526 .write(
527 env.fs.as_ref(),
528 env.paths.as_ref(),
529 "app",
530 "preprocessed",
531 "f",
532 )
533 .unwrap();
534
535 let loaded = Baseline::load(
536 env.fs.as_ref(),
537 env.paths.as_ref(),
538 "app",
539 "preprocessed",
540 "f",
541 )
542 .unwrap()
543 .unwrap();
544 assert_eq!(loaded.rendered_content, "second");
545 }
546
547 #[test]
548 fn cache_filename_for_drops_parent_directories() {
549 assert_eq!(cache_filename_for(Path::new("config.toml")), "config.toml");
550 assert_eq!(
551 cache_filename_for(Path::new("subdir/config.toml")),
552 "config.toml"
553 );
554 assert_eq!(cache_filename_for(Path::new("a/b/c/leaf.txt")), "leaf.txt");
555 }
556
557 #[test]
558 fn hex_encoding_is_lowercase_and_padded() {
559 assert_eq!(hex_encode_32(&[0; 32]).len(), 64);
560 assert!(hex_encode_32(&[0; 32]).chars().all(|c| c == '0'));
561 assert_eq!(hex_encode_32(&[0xab; 32]).len(), 64);
562 assert!(hex_encode_32(&[0xab; 32])
564 .chars()
565 .all(|c| c == 'a' || c == 'b'));
566 }
567
568 fn range(start: usize, reference: &str) -> crate::preprocessing::SecretLineRange {
571 crate::preprocessing::SecretLineRange {
572 start,
573 end: start + 1,
574 reference: reference.into(),
575 }
576 }
577
578 #[test]
579 fn sidecar_round_trips_through_write_and_load() {
580 let env = TempEnvironment::builder().build();
581 let sidecar = SecretsSidecar::new(vec![
582 range(2, "op://Vault/db/password"),
583 range(5, "pass:api/token"),
584 ]);
585
586 let written = sidecar
587 .write(
588 env.fs.as_ref(),
589 env.paths.as_ref(),
590 "app",
591 "preprocessed",
592 "config.toml",
593 )
594 .unwrap()
595 .expect("non-empty sidecar should write");
596 assert!(env.fs.exists(&written));
597
598 let loaded = SecretsSidecar::load(
599 env.fs.as_ref(),
600 env.paths.as_ref(),
601 "app",
602 "preprocessed",
603 "config.toml",
604 )
605 .unwrap()
606 .expect("written sidecar should load");
607
608 assert_eq!(loaded, sidecar);
609 assert_eq!(loaded.version, SECRETS_SIDECAR_VERSION);
610 assert_eq!(loaded.secret_line_ranges.len(), 2);
611 assert_eq!(
612 loaded.secret_line_ranges[0].reference,
613 "op://Vault/db/password"
614 );
615 }
616
617 #[test]
618 fn sidecar_load_returns_none_when_absent() {
619 let env = TempEnvironment::builder().build();
620 let loaded = SecretsSidecar::load(
621 env.fs.as_ref(),
622 env.paths.as_ref(),
623 "app",
624 "preprocessed",
625 "config.toml",
626 )
627 .unwrap();
628 assert!(
629 loaded.is_none(),
630 "absent sidecar = None (no secrets to mask)"
631 );
632 }
633
634 #[test]
635 fn sidecar_write_with_empty_ranges_does_not_create_file() {
636 let env = TempEnvironment::builder().build();
641 let sidecar = SecretsSidecar::new(Vec::new());
642 let written = sidecar
643 .write(
644 env.fs.as_ref(),
645 env.paths.as_ref(),
646 "app",
647 "preprocessed",
648 "c.toml",
649 )
650 .unwrap();
651 assert!(written.is_none(), "empty sidecar should not write");
652 let path = env
653 .paths
654 .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
655 assert!(!env.fs.exists(&path));
656 }
657
658 #[test]
659 fn sidecar_write_with_empty_ranges_removes_stale_file() {
660 let env = TempEnvironment::builder().build();
665 let original = SecretsSidecar::new(vec![range(1, "pass:k")]);
666 original
667 .write(
668 env.fs.as_ref(),
669 env.paths.as_ref(),
670 "app",
671 "preprocessed",
672 "c.toml",
673 )
674 .unwrap()
675 .expect("first write");
676
677 let path = env
678 .paths
679 .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
680 assert!(env.fs.exists(&path));
681
682 let empty = SecretsSidecar::new(Vec::new());
683 empty
684 .write(
685 env.fs.as_ref(),
686 env.paths.as_ref(),
687 "app",
688 "preprocessed",
689 "c.toml",
690 )
691 .unwrap();
692 assert!(
693 !env.fs.exists(&path),
694 "stale sidecar must be removed when the new render has no secrets"
695 );
696 }
697
698 #[test]
699 fn sidecar_load_rejects_unsupported_version_with_actionable_message() {
700 let env = TempEnvironment::builder().build();
701 let path = env
702 .paths
703 .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
704 env.fs.mkdir_all(path.parent().unwrap()).unwrap();
705 env.fs
706 .write_file(&path, br#"{"version":99,"secret_line_ranges":[]}"#)
707 .unwrap();
708
709 let err = SecretsSidecar::load(
710 env.fs.as_ref(),
711 env.paths.as_ref(),
712 "app",
713 "preprocessed",
714 "c.toml",
715 )
716 .unwrap_err()
717 .to_string();
718 assert!(err.contains("unsupported schema version 99"));
719 assert!(err.contains("dodot up --force"));
720 }
721
722 #[test]
723 fn sidecar_load_rejects_corrupt_json_with_actionable_message() {
724 let env = TempEnvironment::builder().build();
725 let path = env
726 .paths
727 .preprocessor_secrets_sidecar_path("app", "preprocessed", "c.toml");
728 env.fs.mkdir_all(path.parent().unwrap()).unwrap();
729 env.fs.write_file(&path, b"{not valid json").unwrap();
730
731 let err = SecretsSidecar::load(
732 env.fs.as_ref(),
733 env.paths.as_ref(),
734 "app",
735 "preprocessed",
736 "c.toml",
737 )
738 .unwrap_err()
739 .to_string();
740 assert!(err.contains("failed to parse"));
741 assert!(err.contains("dodot up --force"));
742 }
743}