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