Skip to main content

ic_host_tools/candid/
mod.rs

1//! Bounded Candid extractor invocation and Canic-compatible text normalization.
2//!
3//! Tool admission is owned by `tool`. Consumers own Candid grammar/service
4//! validation, expected methods, metadata policy and artifact publication.
5//! The library does not publish sidecars or clean up source files. The selected
6//! extractor must be consumer-governed and read-only; observed tool-side source
7//! changes are rejected, but the library does not sandbox or undo those effects.
8
9use crate::{
10    artifact::{ArtifactError, ArtifactIdentity, hash_file},
11    tool::{AdmittedTool, ExecutionContext, ExecutionEvidence, OutputLimits, ToolError},
12};
13use std::{fmt, path::Path};
14
15#[cfg(test)]
16mod tests;
17
18/// UTF-8 or resource failure while normalizing captured extractor output.
19#[derive(Debug)]
20pub enum NormalizationError {
21    /// Input bytes exceed the caller's bound before decoding.
22    InputLimit {
23        /// Observed byte count.
24        actual: usize,
25        /// Maximum permitted bytes.
26        limit: usize,
27    },
28    /// Captured output is not valid UTF-8; no lossy decoding is performed.
29    Utf8(std::str::Utf8Error),
30    /// Normalized text, including added line endings, would exceed the bound.
31    OutputLimit {
32        /// Maximum permitted normalized bytes.
33        limit: usize,
34    },
35    /// Storage for normalized text could not be allocated.
36    Allocation(std::collections::TryReserveError),
37}
38
39impl fmt::Display for NormalizationError {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        match self {
42            Self::InputLimit { actual, limit } => {
43                write!(f, "Candid output has {actual} bytes, exceeding {limit}")
44            }
45            Self::Utf8(_) => f.write_str("Candid extractor output is not UTF-8"),
46            Self::OutputLimit { limit } => write!(f, "normalized Candid exceeds {limit} bytes"),
47            Self::Allocation(_) => f.write_str("Candid normalization allocation failed"),
48        }
49    }
50}
51impl std::error::Error for NormalizationError {
52    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
53        match self {
54            Self::Utf8(source) => Some(source),
55            Self::Allocation(source) => Some(source),
56            _ => None,
57        }
58    }
59}
60
61/// Normalize UTF-8 extractor bytes with an explicit input/output byte bound.
62///
63/// Matches Canic's `str::lines` / `trim_end` contract: remove each line's
64/// trailing Unicode whitespace, emit LF endings, and preserve blank lines.
65/// Empty input stays empty; a nonempty unterminated line gains one LF.
66/// This does not parse Candid or select an expected service contract.
67///
68/// # Errors
69/// Returns input overflow, invalid UTF-8, normalized-output overflow or
70/// allocation failure. No partial normalized text is returned.
71pub fn normalize(bytes: &[u8], max_bytes: usize) -> Result<String, NormalizationError> {
72    if bytes.len() > max_bytes {
73        return Err(NormalizationError::InputLimit {
74            actual: bytes.len(),
75            limit: max_bytes,
76        });
77    }
78    let text = std::str::from_utf8(bytes).map_err(NormalizationError::Utf8)?;
79    let mut normalized = String::new();
80    for line in text.lines() {
81        let line = line.trim_end();
82        let length = normalized
83            .len()
84            .checked_add(line.len())
85            .and_then(|length| length.checked_add(1));
86        if length.is_none_or(|length| length > max_bytes) {
87            return Err(NormalizationError::OutputLimit { limit: max_bytes });
88        }
89        normalized
90            .try_reserve_exact(line.len() + 1)
91            .map_err(NormalizationError::Allocation)?;
92        normalized.push_str(line);
93        normalized.push('\n');
94    }
95    Ok(normalized)
96}
97
98/// A Candid extraction failed, retaining process bytes where invocation occurred.
99#[derive(Debug)]
100pub enum ExtractionError {
101    /// The source path was relative; no tool was invoked.
102    SourcePath,
103    /// Source inspection failed before invocation.
104    Input(ArtifactError),
105    /// Tool verification or execution failed; execution failures retain evidence.
106    Tool(ToolError),
107    /// Source could not be re-inspected after successful execution.
108    SourceInspection {
109        /// Inspection failure.
110        source: ArtifactError,
111        /// Bounded successful process output.
112        evidence: Box<ExecutionEvidence>,
113    },
114    /// Source bytes changed between preflight and post-extraction observation.
115    SourceChanged {
116        /// Identity observed before invocation.
117        before: ArtifactIdentity,
118        /// Identity observed after invocation.
119        after: ArtifactIdentity,
120        /// Bounded process output that must not be published as valid extraction.
121        evidence: Box<ExecutionEvidence>,
122    },
123    /// Successful output could not be normalized under the selected bound.
124    Normalize {
125        /// Normalization failure.
126        source: NormalizationError,
127        /// Exact bounded process output, including invalid UTF-8 when present.
128        evidence: Box<ExecutionEvidence>,
129    },
130}
131
132impl fmt::Display for ExtractionError {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::SourcePath => f.write_str("Candid source path must be absolute"),
136            Self::Input(source) => write!(f, "Candid source inspection failed: {source}"),
137            Self::Tool(source) => source.fmt(f),
138            Self::SourceInspection { .. } => f.write_str("Candid source re-inspection failed"),
139            Self::SourceChanged { .. } => f.write_str("Candid source changed during extraction"),
140            Self::Normalize { source, .. } => source.fmt(f),
141        }
142    }
143}
144impl std::error::Error for ExtractionError {
145    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
146        match self {
147            Self::Input(source) | Self::SourceInspection { source, .. } => Some(source),
148            Self::Tool(source) => Some(source),
149            Self::Normalize { source, .. } => Some(source),
150            _ => None,
151        }
152    }
153}
154
155/// Normalized text, source/tool identities, and exact bounded process evidence.
156pub struct ExtractedCandid {
157    /// Normalized UTF-8 declaration; grammar acceptance remains caller-owned.
158    pub text: String,
159    /// Matching source identity observed before and after invocation.
160    pub source_identity: ArtifactIdentity,
161    /// Tool bytes admitted by the consumer.
162    pub tool_identity: ArtifactIdentity,
163    /// Original stdout/stderr and successful direct-child exit status.
164    pub evidence: ExecutionEvidence,
165}
166
167impl fmt::Debug for ExtractedCandid {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.debug_struct("ExtractedCandid")
170            .field("text_bytes", &self.text.len())
171            .field("source_identity", &self.source_identity)
172            .field("tool_identity", &self.tool_identity)
173            .field("evidence", &self.evidence)
174            .finish()
175    }
176}
177
178/// Invoke the admitted extractor once with the absolute source path as one arg.
179///
180/// Input regular-file hashing is bounded by `source_bytes`. Normalized text is
181/// bounded by `output.stdout_bytes`, independently of capture storage. The
182/// source is inspected again on successful completion; observed changes reject
183/// publication. Callers must exclude concurrent writers to source/tool paths:
184/// before/after digests cannot prove that transient changes did not occur.
185///
186/// # Errors
187/// Returns typed path/read, tool-execution, source-change or normalization
188/// failures. After invocation, process failure/validation evidence is retained.
189pub fn extract(
190    tool: &AdmittedTool,
191    source: &Path,
192    context: &ExecutionContext<'_>,
193    source_bytes: u64,
194    output: OutputLimits,
195) -> Result<ExtractedCandid, ExtractionError> {
196    if !source.is_absolute() {
197        return Err(ExtractionError::SourcePath);
198    }
199    let before = hash_file(source, source_bytes).map_err(ExtractionError::Input)?;
200    let evidence = tool
201        .run(&[source.as_os_str().to_owned()], context, output)
202        .map_err(ExtractionError::Tool)?;
203    let after = match hash_file(source, source_bytes) {
204        Ok(identity) => identity,
205        Err(source) => {
206            return Err(ExtractionError::SourceInspection {
207                source,
208                evidence: Box::new(evidence),
209            });
210        }
211    };
212    if before != after {
213        return Err(ExtractionError::SourceChanged {
214            before,
215            after,
216            evidence: Box::new(evidence),
217        });
218    }
219    let text = match normalize(&evidence.stdout, output.stdout_bytes) {
220        Ok(text) => text,
221        Err(source) => {
222            return Err(ExtractionError::Normalize {
223                source,
224                evidence: Box::new(evidence),
225            });
226        }
227    };
228    Ok(ExtractedCandid {
229        text,
230        source_identity: before,
231        tool_identity: tool.identity(),
232        evidence,
233    })
234}