1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
//! Configuration for ceremony output directory structure.
use chrono::Utc;
use rite_model::{PathSafetyError, safe_join};
use std::path::PathBuf;
/// Configuration for ceremony output directory structure.
///
/// All ceremony outputs (artifacts and transcript) are written to a structured
/// directory layout:
///
/// ```text
/// ceremony-name-20251229T143022/
/// ├── transcript.jsonl
/// └── artifacts/
/// ├── wrapped_key.bin
/// ├── public_key.pem
/// └── share_1.txt
/// ```
#[derive(Debug, Clone)]
pub struct OutputConfig {
/// Base directory for all outputs (e.g., "./runs/ceremony-20251229T143022/")
base_dir: PathBuf,
}
impl OutputConfig {
/// Create a new output configuration with the given base directory.
#[must_use]
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
/// Create output configuration for a ceremony run.
///
/// Generates a timestamped directory name from the ceremony name:
/// `<parent>/<ceremony-slug>-<YYYYMMDDTHHMMSS>/`
///
/// # Arguments
/// * `parent` - Parent directory (defaults to current directory if None)
/// * `ceremony_name` - Human-readable ceremony name to slugify
#[must_use]
pub fn for_ceremony(parent: Option<PathBuf>, ceremony_name: &str) -> Self {
let parent = parent.unwrap_or_else(|| PathBuf::from("."));
let timestamp = Utc::now().format("%Y%m%dT%H%M%S");
let slug = slugify(ceremony_name);
Self {
base_dir: parent.join(format!("{slug}-{timestamp}")),
}
}
/// Returns the base output directory.
pub fn base_dir(&self) -> &std::path::Path {
&self.base_dir
}
/// Returns the path to the artifacts subdirectory.
pub fn artifacts_dir(&self) -> PathBuf {
self.base_dir.join("artifacts")
}
/// Returns the path to the transcript file.
///
/// Uses JSONL format (`transcript.jsonl`) for hash-chained event log.
pub fn transcript_path(&self) -> PathBuf {
self.base_dir.join("transcript.jsonl")
}
/// Returns the path to the evidence subdirectory.
pub fn evidence_dir(&self) -> PathBuf {
self.base_dir.join("evidence")
}
/// Returns the path for an artifact with the given ID and extension.
///
/// The id is treated as untrusted: it becomes a filename directly inside the
/// artifacts directory, so this rejects any id that contains a path
/// separator or `..` traversal. The resolver already validates artifact ids
/// at load time; this is the defense-in-depth check at the filesystem
/// boundary so a future code path cannot reintroduce a traversal.
///
/// # Errors
/// Returns [`PathSafetyError`] if `id` is not a safe single filename.
pub fn artifact_path(&self, id: &str, extension: &str) -> Result<PathBuf, PathSafetyError> {
safe_join(&self.artifacts_dir(), &format!("{id}.{extension}"))
}
}
/// Convert a ceremony name to a filesystem-safe slug.
fn slugify(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn output_config_paths() {
let config = OutputConfig::new(PathBuf::from("/tmp/ceremony-20251229T143022"));
assert_eq!(
config.base_dir(),
std::path::Path::new("/tmp/ceremony-20251229T143022")
);
assert_eq!(
config.artifacts_dir(),
PathBuf::from("/tmp/ceremony-20251229T143022/artifacts")
);
assert_eq!(
config.transcript_path(),
PathBuf::from("/tmp/ceremony-20251229T143022/transcript.jsonl")
);
assert_eq!(
config.artifact_path("wrapped_key", "bin").unwrap(),
PathBuf::from("/tmp/ceremony-20251229T143022/artifacts/wrapped_key.bin")
);
}
#[test]
fn artifact_path_rejects_traversal() {
let config = OutputConfig::new(PathBuf::from("/tmp/ceremony"));
assert!(config.artifact_path("../../etc/passwd", "txt").is_err());
assert!(config.artifact_path("sub/dir", "txt").is_err());
assert!(config.artifact_path("/abs", "txt").is_err());
}
#[test]
fn slugify_lowercases_and_collapses_separators() {
assert_eq!(slugify("Simple Name"), "simple-name");
assert_eq!(slugify("HSM Bootstrap"), "hsm-bootstrap");
assert_eq!(slugify("Root CA Signing"), "root-ca-signing");
assert_eq!(slugify("Test_Ceremony-2024"), "test-ceremony-2024");
assert_eq!(slugify(" Leading/Trailing "), "leading-trailing");
}
}