Skip to main content

rite_runtime/
executor.rs

1//! Execution-level error / outcome types and shared helpers used by
2//! [`crate::runner::Executor`] when walking a resolved ceremony.
3
4use crate::actions::ArtifactValue;
5use crate::step_info::StepInfo;
6use rite_model::{
7    ActionType, ArtifactId, Material, MaterialKind, MaterialSource, Sha256Digest, Step, StepId,
8};
9use rite_sdk::BackendError;
10use std::fs;
11use std::io;
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use thiserror::Error;
15use zeroize::Zeroizing;
16
17use crate::output_config::OutputConfig;
18
19/// Errors during ceremony execution.
20#[derive(Debug, Error)]
21pub enum ExecutionError {
22    /// Required parameters are missing values.
23    #[error("Validation failed: {0}")]
24    ValidationFailed(String),
25
26    /// The operator chose to stop the ceremony. Not an error condition: the
27    /// active step (if any) is already recorded in the transcript stream, so
28    /// the terminal message stays ceremony-level rather than naming a step.
29    #[error("Ceremony aborted by the operator")]
30    Aborted,
31
32    /// An I/O error occurred.
33    #[error("IO error: {0}")]
34    Io(#[from] io::Error),
35
36    /// A step handler returned a failure.
37    #[error("Step '{step}' failed: {reason}")]
38    StepFailed {
39        /// Step that failed.
40        step: StepId,
41        /// Failure description.
42        reason: String,
43    },
44
45    /// The action type is not registered.
46    #[error("Unknown action: '{0}'")]
47    UnknownAction(ActionType),
48
49    /// Invalid or missing parameters for a handler.
50    #[error("Invalid params: {0}")]
51    InvalidParams(String),
52
53    /// The host operating system failed to provide entropy for the seed.
54    #[error("Failed to gather machine entropy: {0}")]
55    EntropyError(String),
56
57    /// A material could not be loaded.
58    #[error("Failed to load material '{name}': {reason}")]
59    MaterialLoadFailed {
60        /// Material name.
61        name: String,
62        /// Failure description.
63        reason: String,
64    },
65
66    /// An output artifact could not be written.
67    #[error("Failed to write output '{name}': {reason}")]
68    OutputWriteFailed {
69        /// Output name.
70        name: String,
71        /// Failure description.
72        reason: String,
73    },
74
75    /// Transcript writing failed.
76    #[error("Transcript error: {0}")]
77    TranscriptError(String),
78
79    /// A backend returned an error.
80    #[error("Backend error: {0}")]
81    BackendError(#[from] BackendError),
82}
83
84/// Build a `StepInfo` from a resolved `Step`.
85pub(crate) fn step_info_from(step: &Step) -> StepInfo {
86    StepInfo::new(
87        step.id.clone(),
88        step.role.clone(),
89        step.backend.clone(),
90        step.creates.clone(),
91        step.reads_resolved.clone(),
92    )
93}
94
95/// Load a single material into an `ArtifactValue`.
96pub(crate) fn load_material_artifact(
97    name: &str,
98    material: &Material,
99) -> Result<ArtifactValue, ExecutionError> {
100    match &material.kind {
101        MaterialKind::Physical { identifier, .. } => {
102            let text = identifier
103                .as_deref()
104                .or(material.title.as_deref())
105                .unwrap_or(name)
106                .to_string();
107            Ok(ArtifactValue::Text(text))
108        }
109        MaterialKind::Digital { source } => {
110            let source = source
111                .as_ref()
112                .ok_or_else(|| ExecutionError::MaterialLoadFailed {
113                    name: name.to_string(),
114                    reason: "no source provided for digital material".to_string(),
115                })?;
116            match source {
117                MaterialSource::File { file } => {
118                    let bytes = fs::read(file).map_err(|e| ExecutionError::MaterialLoadFailed {
119                        name: name.to_string(),
120                        reason: e.to_string(),
121                    })?;
122                    Ok(ArtifactValue::Bytes(bytes))
123                }
124                MaterialSource::Identifier { identifier } => {
125                    // Digital material with an inline identifier: treat as text content.
126                    Ok(ArtifactValue::Text(identifier.clone()))
127                }
128            }
129        }
130    }
131}
132
133/// Write `bytes` to a freshly created file at `path`, failing if anything already exists there.
134///
135/// Uses `create_new` so the write cannot follow a pre-existing symlink planted at the
136/// destination (which could otherwise redirect artifact bytes outside the run directory) and
137/// cannot clobber an existing file. On Unix the file is created with `0o600` so artifact
138/// material is not world-readable.
139///
140/// # Errors
141///
142/// Returns the I/O error if the file exists or cannot be written and synced.
143pub fn write_new_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
144    let mut options = fs::OpenOptions::new();
145    options.write(true).create_new(true);
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::OpenOptionsExt;
149        options.mode(0o600);
150    }
151    let mut file = options.open(path)?;
152    file.write_all(bytes)?;
153    file.sync_all()
154}
155
156/// Serialize an artifact, write it to disk, and return `(path, sha256-hex, size-bytes, mime-type)`.
157pub(crate) fn write_artifact_to_disk(
158    artifact_id: &ArtifactId,
159    artifact_value: &ArtifactValue,
160    output_config: &OutputConfig,
161) -> Result<(PathBuf, Sha256Digest, u64, Option<String>), ExecutionError> {
162    let serialized =
163        artifact_value
164            .serialize(None)
165            .map_err(|e| ExecutionError::OutputWriteFailed {
166                name: artifact_id.as_str().to_string(),
167                reason: e,
168            })?;
169    // The serialized copy is the one buffer this path owns. Moved into a
170    // wiping wrapper rather than dropped as is, so writing opened content
171    // leaves no copy of it behind in memory once the file exists.
172    let bytes = Zeroizing::new(serialized.bytes);
173
174    let path = output_config
175        .artifact_path(artifact_id.as_str(), serialized.extension)
176        .map_err(|e| ExecutionError::OutputWriteFailed {
177            name: artifact_id.as_str().to_string(),
178            reason: e.to_string(),
179        })?;
180
181    write_new_file(&path, &bytes).map_err(|e| ExecutionError::OutputWriteFailed {
182        name: artifact_id.as_str().to_string(),
183        reason: e.to_string(),
184    })?;
185
186    // Hashed from the buffer just written and synced, not read back from the
187    // file: a read-back would be one more unwiped copy of opened content.
188    let hash = Sha256Digest::of(&bytes);
189
190    let size = fs::metadata(&path)
191        .map_err(|e| ExecutionError::OutputWriteFailed {
192            name: artifact_id.as_str().to_string(),
193            reason: format!("metadata read failed: {e}"),
194        })?
195        .len();
196
197    Ok((path, hash, size, serialized.mime_type))
198}