Skip to main content

cuttlefish_host/
pipeline.rs

1//! Checking that a pipeline's blocks fit together, before any of them run.
2//!
3//! A pipeline feeds each block's output into the next one's input. Those seams
4//! are where composition actually goes wrong: a block producing a summary string
5//! handed to one expecting a list of chunks does not fail at the seam — it fails
6//! somewhere inside the second block, as a confusing error about a field that is
7//! missing, or worse, as a plausible answer computed from nothing.
8//!
9//! So the seams are checked first, using signatures the blocks declare
10//! themselves (see [`crate::runner::read_signature`]). A mismatch names both
11//! blocks and both types and stops the job before it starts.
12//!
13//! # What this deliberately is not
14//!
15//! Not type *inference*. Every block states its own signature; nothing is
16//! derived. Inference earns its keep in a language with expressions, where types
17//! flow through terms nobody wants to annotate. A pipeline is a list of already
18//! typed things, so there is nothing to infer and machinery to infer it would be
19//! cost without benefit.
20//!
21//! Not a DAG, yet. A pipeline is linear. Branching and joining are real needs,
22//! but a linear chain covers the pipelines that exist today, and the type
23//! discipline established here is what a DAG would extend rather than replace.
24
25use crate::catalog::{Catalog, ResolutionContext, Resolved};
26use cuttlefish_abi::{Signature, Ty};
27use std::path::{Path, PathBuf};
28use wasmtime::Engine;
29
30/// One stage of a checked pipeline.
31pub struct Stage {
32    /// Display name — a file stem for a path-resolved stage, or the bare
33    /// catalog name (no `@version`) for a cataloged one. Used only for
34    /// output and the bundle manifest, never round-tripped into a lookup.
35    pub name: String,
36    /// Block or bundle.
37    pub kind: crate::catalog::ArtifactKind,
38    /// The exact `name@version` this stage resolved to, if it came from the
39    /// catalog. `None` for a direct path.
40    pub resolved: Option<String>,
41    /// The compiled module (block) or `.cfbundle` (bundle) bytes.
42    pub module_bytes: Vec<u8>,
43    /// What it declared.
44    pub signature: Signature,
45    /// The script's own source text, for a `Script`-kind stage — `None` for
46    /// `Block`/`Bundle`. Threaded straight from `ResolvedInput::script`.
47    pub script: Option<String>,
48}
49
50/// One pipeline entry's bytes, already resolved and loaded — from disk or
51/// from the catalog's blob store. `check()`'s input; see resolve_and_load
52/// (added in a later change) for how one of these gets built.
53pub struct ResolvedInput {
54    /// Display name, same convention as [`Stage::name`].
55    pub name: String,
56    /// Block or bundle, already determined (either sniffed from a direct
57    /// path's magic bytes, or read from the catalog entry's own `kind`).
58    pub kind: crate::catalog::ArtifactKind,
59    /// The exact `name@version`, if this came from the catalog.
60    pub resolved: Option<String>,
61    /// The raw bytes.
62    pub bytes: Vec<u8>,
63    /// The script's own source text, for a `Script`-kind entry — `None` for
64    /// `Block`/`Bundle`. `bytes` for a `Script` entry is the *interpreter's*
65    /// bytes (see `resolve_and_load`), not the script; this field is where
66    /// the actual script text lives through the rest of the pipeline.
67    pub script: Option<String>,
68}
69
70impl std::fmt::Debug for ResolvedInput {
71    /// Hand-written so a `Debug`-formatted `ResolvedInput` (e.g. from a
72    /// panic or failed assertion) never dumps a multi-megabyte wasm module
73    /// as a wall of numbers — `bytes` is reported by length only.
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("ResolvedInput")
76            .field("name", &self.name)
77            .field("kind", &self.kind)
78            .field("resolved", &self.resolved)
79            .field("bytes", &format!("<{} bytes>", self.bytes.len()))
80            .field("script", &self.script)
81            .finish()
82    }
83}
84
85/// A pipeline whose seams have been checked.
86///
87/// Only constructible through [`check`], so holding one is evidence the check
88/// ran — a type that cannot be built in an unchecked state is worth more than a
89/// convention that it should not be.
90pub struct Checked {
91    stages: Vec<Stage>,
92}
93
94impl Checked {
95    /// The stages, in execution order.
96    pub fn stages(&self) -> &[Stage] {
97        &self.stages
98    }
99
100    /// What the pipeline as a whole accepts — its first block's input.
101    pub fn input(&self) -> &Ty {
102        &self.stages[0].signature.input
103    }
104
105    /// What it produces — its last block's output.
106    pub fn output(&self) -> &Ty {
107        &self.stages[self.stages.len() - 1].signature.output
108    }
109}
110
111/// Why a pipeline was rejected.
112#[derive(Debug, thiserror::Error)]
113pub enum PipelineError {
114    /// A block could not be read from disk.
115    #[error("reading block {path}: {source}")]
116    Unreadable {
117        /// The block that could not be read.
118        path: PathBuf,
119        /// The underlying I/O failure.
120        source: std::io::Error,
121    },
122    /// A stage could not be inspected: a directory instead of an artifact,
123    /// unrecognized magic bytes, a wasm module that failed to load, or a
124    /// bundle whose cached signature string doesn't parse.
125    #[error("inspecting {name}: {message}")]
126    Uninspectable {
127        /// The stage's display name.
128        name: String,
129        /// What went wrong.
130        message: String,
131    },
132    /// Resolving a pipeline entry through the catalog failed.
133    #[error(transparent)]
134    Resolution(#[from] crate::catalog::CatalogError),
135    /// Two adjacent blocks do not fit.
136    #[error(
137        "block {consumer} expects {expected}, but {producer} before it produces {produced}.\n\
138         Adjust one of the two signatures, or insert a block that converts between them."
139    )]
140    SeamMismatch {
141        /// The block producing the value.
142        producer: String,
143        /// What it produces.
144        produced: String,
145        /// The block receiving it.
146        consumer: String,
147        /// What it needs.
148        expected: String,
149    },
150    /// The pipeline had no blocks.
151    #[error("a pipeline needs at least one block")]
152    Empty,
153}
154
155/// Check that a resolved pipeline's seams fit.
156///
157/// Fails on the *first* mismatch rather than collecting all of them. A later
158/// seam's types depend on an earlier one being what it claimed, so reporting
159/// downstream mismatches after an upstream failure would mostly report
160/// consequences of the first error rather than independent problems.
161///
162/// Takes already-resolved, already-loaded stages — see resolve_and_load
163/// (added in a later change) for turning a spec's pipeline entries into
164/// these. `check` itself does no disk or catalog I/O; a `Direct` vs.
165/// `Cataloged` entry looks identical to it once loaded.
166pub fn check(engine: &Engine, inputs: &[ResolvedInput]) -> Result<Checked, PipelineError> {
167    if inputs.is_empty() {
168        return Err(PipelineError::Empty);
169    }
170
171    let mut stages: Vec<Stage> = Vec::with_capacity(inputs.len());
172    for input in inputs {
173        let signature = read_stage_signature(engine, input)?;
174
175        if let Some(previous) = stages.last() {
176            if !previous.signature.output.assignable_to(&signature.input) {
177                return Err(PipelineError::SeamMismatch {
178                    producer: previous.name.clone(),
179                    produced: previous.signature.output.to_string(),
180                    consumer: input.name.clone(),
181                    expected: signature.input.to_string(),
182                });
183            }
184        }
185
186        stages.push(Stage {
187            name: input.name.clone(),
188            kind: input.kind,
189            resolved: input.resolved.clone(),
190            module_bytes: input.bytes.clone(),
191            signature,
192            script: input.script.clone(),
193        });
194    }
195
196    Ok(Checked { stages })
197}
198
199/// Read one resolved input's declared [`Signature`], regardless of whether
200/// it's a block or a bundle. Shared by [`check`] (linear) and the graph
201/// checker in `crate::dag` — both need exactly this per-node lookup, just
202/// composed differently around it.
203pub fn read_stage_signature(
204    engine: &Engine,
205    input: &ResolvedInput,
206) -> Result<Signature, PipelineError> {
207    match input.kind {
208        crate::catalog::ArtifactKind::Block => crate::runner::read_signature(engine, &input.bytes)
209            .map_err(|e| PipelineError::Uninspectable {
210                name: input.name.clone(),
211                message: format!("{e:#}"),
212            }),
213        crate::catalog::ArtifactKind::Bundle => {
214            let compact = crate::catalog::read_bundle_signature(&input.bytes, &input.name)
215                .map_err(|e| PipelineError::Uninspectable {
216                    name: input.name.clone(),
217                    // `read_bundle_signature`'s own error already embeds
218                    // the name (it's `{path}: {reason}` with `path` set
219                    // to our `name`); re-stringifying the whole error
220                    // here would print the name twice. Pull out just the
221                    // reason so `Uninspectable`'s own `{name}: {message}`
222                    // formatting is the only place the name appears.
223                    message: match e {
224                        crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
225                            reason
226                        }
227                        other => other.to_string(),
228                    },
229                })?;
230            compact
231                .parse::<Signature>()
232                .map_err(|e| PipelineError::Uninspectable {
233                    name: input.name.clone(),
234                    message: format!("cached signature `{compact}` does not parse: {e}"),
235                })
236        }
237        crate::catalog::ArtifactKind::Script => {
238            let script = input
239                .script
240                .as_deref()
241                .ok_or_else(|| PipelineError::Uninspectable {
242                    name: input.name.clone(),
243                    message: "a Script-kind ResolvedInput with no script text — this is an \
244                          internal bug in resolve_and_load, not a user-facing error"
245                        .to_string(),
246                })?;
247            let compact = crate::catalog::read_script_signature(script.as_bytes(), &input.name)
248                .map_err(|e| PipelineError::Uninspectable {
249                    name: input.name.clone(),
250                    message: match e {
251                        crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
252                            reason
253                        }
254                        other => other.to_string(),
255                    },
256                })?;
257            compact
258                .parse::<Signature>()
259                .map_err(|e| PipelineError::Uninspectable {
260                    name: input.name.clone(),
261                    message: format!("cached signature `{compact}` does not parse: {e}"),
262                })
263        }
264    }
265}
266
267/// Turn one pipeline-entry string from a spec into a loaded, kind-tagged
268/// [`ResolvedInput`], ready for [`check`]. Shared by `cuttlefishd`'s run
269/// path and `cuttlefish build` — the only two callers, and both need the
270/// same resolve-then-load behavior.
271///
272/// An entry that resolves to a real path once joined against `spec_dir` is
273/// treated as that path — matching the join every other spec-relative
274/// reference (`capabilities`) already gets, so a relative block path means
275/// the same thing regardless of the process's working directory. This
276/// mirrors [`Catalog::resolve`]'s own Direct-vs-Cataloged decision (`s.ends_with(".wasm")
277/// || Path::new(s).exists()`), just relative to `spec_dir` instead of the
278/// process's CWD, rather than guessing from the raw string's shape (`entry`
279/// containing `/`) before `resolve` ever gets a look — a catalog name is
280/// free to contain `/` (a namespaced convention like `"team/cat-a@1"`), and
281/// joining on shape alone would corrupt such a name into a bogus path before
282/// the catalog's own index lookup ever sees it.
283///
284/// A `.wasm`/`.cfbundle` suffix is still always treated as a path reference
285/// even when the joined candidate doesn't exist on disk, so a genuinely
286/// missing compiled artifact still names the expected file in its error
287/// instead of silently falling back to a catalog lookup on a string that
288/// happens to contain that suffix. Anything else that doesn't resolve to a
289/// real file is passed to [`Catalog::resolve`] unmodified — joining it would
290/// turn `name@version` into `<spec_dir>/name@version`, which matches neither
291/// an index key nor a real file.
292pub fn resolve_and_load(
293    catalog: &Catalog,
294    spec_dir: &Path,
295    entry: &str,
296    context: ResolutionContext,
297) -> Result<ResolvedInput, PipelineError> {
298    let candidate = spec_dir.join(entry);
299    let use_joined = candidate.exists() || entry.ends_with(".wasm") || entry.ends_with(".cfbundle");
300    let joined;
301    let s: &str = if use_joined {
302        joined = candidate.to_string_lossy().into_owned();
303        &joined
304    } else {
305        entry
306    };
307
308    match catalog.resolve(s, context)? {
309        Resolved::Direct(path) => {
310            if path.is_dir() {
311                return Err(PipelineError::Uninspectable {
312                    name: path.display().to_string(),
313                    message: "this is a directory. A pipeline names compiled \
314                              `.wasm`/`.cfbundle` artifacts, not block source \
315                              directories — build the block first and point at \
316                              the compiled artifact."
317                        .into(),
318                });
319            }
320            let bytes = std::fs::read(&path).map_err(|source| PipelineError::Unreadable {
321                path: path.clone(),
322                source,
323            })?;
324            let kind = crate::catalog::sniff_artifact_kind(&bytes).ok_or_else(|| {
325                PipelineError::Uninspectable {
326                    name: path.display().to_string(),
327                    message: "not a recognized artifact (neither wasm nor .cfbundle magic \
328                              bytes)"
329                        .into(),
330                }
331            })?;
332            Ok(ResolvedInput {
333                name: name_of(&path),
334                kind,
335                resolved: None,
336                bytes,
337                script: None,
338            })
339        }
340        Resolved::Cataloged {
341            name_version,
342            entry,
343        } => {
344            let name = name_version
345                .split_once('@')
346                .map(|(n, _)| n)
347                .unwrap_or(&name_version)
348                .to_string();
349
350            if entry.kind == crate::catalog::ArtifactKind::Script {
351                let script_bytes = catalog.read_blob(&entry)?;
352                let script =
353                    String::from_utf8(script_bytes).map_err(|e| PipelineError::Uninspectable {
354                        name: name.clone(),
355                        message: format!("cataloged script is not valid UTF-8: {e}"),
356                    })?;
357                return Ok(ResolvedInput {
358                    name,
359                    kind: entry.kind,
360                    resolved: Some(name_version),
361                    bytes: crate::embedded_rhai_interpreter_bytes().to_vec(),
362                    script: Some(script),
363                });
364            }
365
366            let bytes = catalog.read_blob(&entry)?;
367            Ok(ResolvedInput {
368                name,
369                kind: entry.kind,
370                resolved: Some(name_version),
371                bytes,
372                script: None,
373            })
374        }
375    }
376}
377
378/// A short name for error messages and manifest output — the file stem, or
379/// the whole path when there isn't one (`Path::file_stem(".wasm")` returns
380/// `Some(".wasm")` under the leading-dot rule, so this never panics or
381/// empties out on a no-real-basename path).
382fn name_of(path: &Path) -> String {
383    path.file_stem()
384        .map(|s| s.to_string_lossy().into_owned())
385        .unwrap_or_else(|| path.display().to_string())
386}