Skip to main content

ic_host_tools/candid/extraction/
mod.rs

1//! Admitted extractor execution with source identity and bounded output evidence.
2//! Callers own grammar validation, publication and exclusion of concurrent writers.
3
4use super::text::{NormalizationError, normalize};
5use ic_host_artifacts::artifact::{ArtifactError, ArtifactIdentity};
6use ic_host_fs::read::hash_file;
7use ic_host_process::tool::{
8    AdmittedTool, CompletedExecution, ExecutionContext, OutputLimits, ToolError,
9};
10use std::{fmt, path::Path};
11
12#[cfg(test)]
13mod tests;
14
15/// A Candid extraction failed, retaining process bytes where invocation occurred.
16#[derive(Debug)]
17pub enum ExtractionError {
18    /// The source path was relative; no tool was invoked.
19    SourcePath,
20    /// Source inspection failed before invocation.
21    Input(ArtifactError),
22    /// Tool verification or execution failed; execution failures retain evidence.
23    Tool(ToolError),
24    /// Source could not be re-inspected after successful execution.
25    SourceInspection {
26        /// Inspection failure.
27        source: ArtifactError,
28        /// Bounded successful process output.
29        evidence: Box<CompletedExecution>,
30    },
31    /// Source bytes changed between preflight and post-extraction observation.
32    SourceChanged {
33        /// Identity observed before invocation.
34        before: ArtifactIdentity,
35        /// Identity observed after invocation.
36        after: ArtifactIdentity,
37        /// Bounded process output that must not be published as valid extraction.
38        evidence: Box<CompletedExecution>,
39    },
40    /// Successful output could not be normalized under the selected bound.
41    Normalize {
42        /// Normalization failure.
43        source: NormalizationError,
44        /// Exact bounded process output, including invalid UTF-8 when present.
45        evidence: Box<CompletedExecution>,
46    },
47}
48
49impl fmt::Display for ExtractionError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            Self::SourcePath => f.write_str("Candid source path must be absolute"),
53            Self::Input(source) => write!(f, "Candid source inspection failed: {source}"),
54            Self::Tool(source) => source.fmt(f),
55            Self::SourceInspection { .. } => f.write_str("Candid source re-inspection failed"),
56            Self::SourceChanged { .. } => f.write_str("Candid source changed during extraction"),
57            Self::Normalize { source, .. } => source.fmt(f),
58        }
59    }
60}
61impl std::error::Error for ExtractionError {
62    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
63        match self {
64            Self::Input(source) | Self::SourceInspection { source, .. } => Some(source),
65            Self::Tool(source) => Some(source),
66            Self::Normalize { source, .. } => Some(source),
67            _ => None,
68        }
69    }
70}
71
72/// Normalized text, source/tool identities, and exact bounded process evidence.
73pub struct ExtractedCandid {
74    /// Normalized UTF-8 declaration; grammar acceptance remains caller-owned.
75    pub text: String,
76    /// Matching source identity observed before and after invocation.
77    pub source_identity: ArtifactIdentity,
78    /// Tool bytes admitted by the consumer.
79    pub tool_identity: ArtifactIdentity,
80    /// Original stdout/stderr and successful direct-child exit status.
81    pub evidence: CompletedExecution,
82}
83
84impl fmt::Debug for ExtractedCandid {
85    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
86        f.debug_struct("ExtractedCandid")
87            .field("text_bytes", &self.text.len())
88            .field("source_identity", &self.source_identity)
89            .field("tool_identity", &self.tool_identity)
90            .field("evidence", &self.evidence)
91            .finish()
92    }
93}
94
95/// Invoke the admitted extractor once with the absolute source path as one arg.
96///
97/// Input regular-file hashing is bounded by `source_bytes`. Normalized text is
98/// bounded by `output.stdout.retained_bytes()`, independently of capture storage. The
99/// source is inspected again on successful completion; observed changes reject
100/// publication. Callers must exclude concurrent writers to source/tool paths:
101/// before/after digests cannot prove that transient changes did not occur.
102///
103/// # Errors
104/// Returns typed path/read, tool-execution, source-change or normalization
105/// failures. After invocation, process failure/validation evidence is retained.
106pub fn extract(
107    tool: &AdmittedTool,
108    source: &Path,
109    context: &ExecutionContext<'_>,
110    source_bytes: u64,
111    output: OutputLimits,
112) -> Result<ExtractedCandid, ExtractionError> {
113    if !source.is_absolute() {
114        return Err(ExtractionError::SourcePath);
115    }
116    let before = hash_file(source, source_bytes).map_err(ExtractionError::Input)?;
117    let evidence = tool
118        .run(&[source.as_os_str().to_owned()], context, output)
119        .and_then(CompletedExecution::require_success)
120        .and_then(CompletedExecution::require_complete)
121        .map_err(ExtractionError::Tool)?;
122    let after = match hash_file(source, source_bytes) {
123        Ok(identity) => identity,
124        Err(source) => {
125            return Err(ExtractionError::SourceInspection {
126                source,
127                evidence: Box::new(evidence),
128            });
129        }
130    };
131    if before != after {
132        return Err(ExtractionError::SourceChanged {
133            before,
134            after,
135            evidence: Box::new(evidence),
136        });
137    }
138    let text = match normalize(&evidence.stdout, output.stdout.retained_bytes()) {
139        Ok(text) => text,
140        Err(source) => {
141            return Err(ExtractionError::Normalize {
142                source,
143                evidence: Box::new(evidence),
144            });
145        }
146    };
147    Ok(ExtractedCandid {
148        text,
149        source_identity: before,
150        tool_identity: tool.identity(),
151        evidence,
152    })
153}