rite_runtime/
output_config.rs1use chrono::Utc;
4use rite_model::{PathSafetyError, safe_join};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone)]
21pub struct OutputConfig {
22 base_dir: PathBuf,
24}
25
26impl OutputConfig {
27 #[must_use]
29 pub fn new(base_dir: PathBuf) -> Self {
30 Self { base_dir }
31 }
32
33 #[must_use]
42 pub fn for_ceremony(parent: Option<PathBuf>, ceremony_name: &str) -> Self {
43 let parent = parent.unwrap_or_else(|| PathBuf::from("."));
44 let timestamp = Utc::now().format("%Y%m%dT%H%M%S");
45 let slug = slugify(ceremony_name);
46 Self {
47 base_dir: parent.join(format!("{slug}-{timestamp}")),
48 }
49 }
50
51 pub fn base_dir(&self) -> &std::path::Path {
53 &self.base_dir
54 }
55
56 pub fn artifacts_dir(&self) -> PathBuf {
58 self.base_dir.join("artifacts")
59 }
60
61 pub fn transcript_path(&self) -> PathBuf {
65 self.base_dir.join("transcript.jsonl")
66 }
67
68 pub fn evidence_dir(&self) -> PathBuf {
70 self.base_dir.join("evidence")
71 }
72
73 pub fn artifact_path(&self, id: &str, extension: &str) -> Result<PathBuf, PathSafetyError> {
84 safe_join(&self.artifacts_dir(), &format!("{id}.{extension}"))
85 }
86}
87
88fn slugify(name: &str) -> String {
90 name.to_lowercase()
91 .chars()
92 .map(|c| if c.is_alphanumeric() { c } else { '-' })
93 .collect::<String>()
94 .split('-')
95 .filter(|s| !s.is_empty())
96 .collect::<Vec<_>>()
97 .join("-")
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn output_config_paths() {
106 let config = OutputConfig::new(PathBuf::from("/tmp/ceremony-20251229T143022"));
107
108 assert_eq!(
109 config.base_dir(),
110 std::path::Path::new("/tmp/ceremony-20251229T143022")
111 );
112 assert_eq!(
113 config.artifacts_dir(),
114 PathBuf::from("/tmp/ceremony-20251229T143022/artifacts")
115 );
116 assert_eq!(
117 config.transcript_path(),
118 PathBuf::from("/tmp/ceremony-20251229T143022/transcript.jsonl")
119 );
120 assert_eq!(
121 config.artifact_path("wrapped_key", "bin").unwrap(),
122 PathBuf::from("/tmp/ceremony-20251229T143022/artifacts/wrapped_key.bin")
123 );
124 }
125
126 #[test]
127 fn artifact_path_rejects_traversal() {
128 let config = OutputConfig::new(PathBuf::from("/tmp/ceremony"));
129 assert!(config.artifact_path("../../etc/passwd", "txt").is_err());
130 assert!(config.artifact_path("sub/dir", "txt").is_err());
131 assert!(config.artifact_path("/abs", "txt").is_err());
132 }
133
134 #[test]
135 fn test_slugify() {
136 assert_eq!(slugify("Simple Name"), "simple-name");
137 assert_eq!(slugify("HSM Bootstrap"), "hsm-bootstrap");
138 assert_eq!(slugify("Root CA Signing"), "root-ca-signing");
139 assert_eq!(slugify("Test_Ceremony-2024"), "test-ceremony-2024");
140 assert_eq!(slugify(" Leading/Trailing "), "leading-trailing");
141 }
142}