Skip to main content

rite_runtime/
output_config.rs

1//! Configuration for ceremony output directory structure.
2
3use chrono::Utc;
4use rite_model::{PathSafetyError, safe_join};
5use std::path::PathBuf;
6
7/// Configuration for ceremony output directory structure.
8///
9/// All ceremony outputs (artifacts and transcript) are written to a structured
10/// directory layout:
11///
12/// ```text
13/// ceremony-name-20251229T143022/
14/// ├── transcript.jsonl
15/// └── artifacts/
16///     ├── wrapped_key.bin
17///     ├── public_key.pem
18///     └── share_1.txt
19/// ```
20#[derive(Debug, Clone)]
21pub struct OutputConfig {
22    /// Base directory for all outputs (e.g., "./runs/ceremony-20251229T143022/")
23    base_dir: PathBuf,
24}
25
26impl OutputConfig {
27    /// Create a new output configuration with the given base directory.
28    #[must_use]
29    pub fn new(base_dir: PathBuf) -> Self {
30        Self { base_dir }
31    }
32
33    /// Create output configuration for a ceremony run.
34    ///
35    /// Generates a timestamped directory name from the ceremony name:
36    /// `<parent>/<ceremony-slug>-<YYYYMMDDTHHMMSS>/`
37    ///
38    /// # Arguments
39    /// * `parent` - Parent directory (defaults to current directory if None)
40    /// * `ceremony_name` - Human-readable ceremony name to slugify
41    #[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    /// Returns the base output directory.
52    pub fn base_dir(&self) -> &std::path::Path {
53        &self.base_dir
54    }
55
56    /// Returns the path to the artifacts subdirectory.
57    pub fn artifacts_dir(&self) -> PathBuf {
58        self.base_dir.join("artifacts")
59    }
60
61    /// Returns the path to the transcript file.
62    ///
63    /// Uses JSONL format (`transcript.jsonl`) for hash-chained event log.
64    pub fn transcript_path(&self) -> PathBuf {
65        self.base_dir.join("transcript.jsonl")
66    }
67
68    /// Returns the path to the evidence subdirectory.
69    pub fn evidence_dir(&self) -> PathBuf {
70        self.base_dir.join("evidence")
71    }
72
73    /// Returns the path for an artifact with the given ID and extension.
74    ///
75    /// The id is treated as untrusted: it becomes a filename directly inside the
76    /// artifacts directory, so this rejects any id that contains a path
77    /// separator or `..` traversal. The resolver already validates artifact ids
78    /// at load time; this is the defense-in-depth check at the filesystem
79    /// boundary so a future code path cannot reintroduce a traversal.
80    ///
81    /// # Errors
82    /// Returns [`PathSafetyError`] if `id` is not a safe single filename.
83    pub fn artifact_path(&self, id: &str, extension: &str) -> Result<PathBuf, PathSafetyError> {
84        safe_join(&self.artifacts_dir(), &format!("{id}.{extension}"))
85    }
86}
87
88/// Convert a ceremony name to a filesystem-safe slug.
89fn 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}