cuttlefish-host 0.8.0

Wasmtime host that drives cuttlefish proc-blocks and enforces capabilities
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Checking that a pipeline's blocks fit together, before any of them run.
//!
//! A pipeline feeds each block's output into the next one's input. Those seams
//! are where composition actually goes wrong: a block producing a summary string
//! handed to one expecting a list of chunks does not fail at the seam — it fails
//! somewhere inside the second block, as a confusing error about a field that is
//! missing, or worse, as a plausible answer computed from nothing.
//!
//! So the seams are checked first, using signatures the blocks declare
//! themselves (see [`crate::runner::read_signature`]). A mismatch names both
//! blocks and both types and stops the job before it starts.
//!
//! # What this deliberately is not
//!
//! Not type *inference*. Every block states its own signature; nothing is
//! derived. Inference earns its keep in a language with expressions, where types
//! flow through terms nobody wants to annotate. A pipeline is a list of already
//! typed things, so there is nothing to infer and machinery to infer it would be
//! cost without benefit.
//!
//! Not a DAG, yet. A pipeline is linear. Branching and joining are real needs,
//! but a linear chain covers the pipelines that exist today, and the type
//! discipline established here is what a DAG would extend rather than replace.

use crate::catalog::{Catalog, ResolutionContext, Resolved};
use cuttlefish_abi::{Signature, Ty};
use std::path::{Path, PathBuf};
use wasmtime::Engine;

/// One stage of a checked pipeline.
pub struct Stage {
    /// Display name — a file stem for a path-resolved stage, or the bare
    /// catalog name (no `@version`) for a cataloged one. Used only for
    /// output and the bundle manifest, never round-tripped into a lookup.
    pub name: String,
    /// Block or bundle.
    pub kind: crate::catalog::ArtifactKind,
    /// The exact `name@version` this stage resolved to, if it came from the
    /// catalog. `None` for a direct path.
    pub resolved: Option<String>,
    /// The compiled module (block) or `.cfbundle` (bundle) bytes.
    pub module_bytes: Vec<u8>,
    /// What it declared.
    pub signature: Signature,
    /// The script's own source text, for a `Script`-kind stage — `None` for
    /// `Block`/`Bundle`. Threaded straight from `ResolvedInput::script`.
    pub script: Option<String>,
}

/// One pipeline entry's bytes, already resolved and loaded — from disk or
/// from the catalog's blob store. `check()`'s input; see resolve_and_load
/// (added in a later change) for how one of these gets built.
pub struct ResolvedInput {
    /// Display name, same convention as [`Stage::name`].
    pub name: String,
    /// Block or bundle, already determined (either sniffed from a direct
    /// path's magic bytes, or read from the catalog entry's own `kind`).
    pub kind: crate::catalog::ArtifactKind,
    /// The exact `name@version`, if this came from the catalog.
    pub resolved: Option<String>,
    /// The raw bytes.
    pub bytes: Vec<u8>,
    /// The script's own source text, for a `Script`-kind entry — `None` for
    /// `Block`/`Bundle`. `bytes` for a `Script` entry is the *interpreter's*
    /// bytes (see `resolve_and_load`), not the script; this field is where
    /// the actual script text lives through the rest of the pipeline.
    pub script: Option<String>,
}

impl std::fmt::Debug for ResolvedInput {
    /// Hand-written so a `Debug`-formatted `ResolvedInput` (e.g. from a
    /// panic or failed assertion) never dumps a multi-megabyte wasm module
    /// as a wall of numbers — `bytes` is reported by length only.
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ResolvedInput")
            .field("name", &self.name)
            .field("kind", &self.kind)
            .field("resolved", &self.resolved)
            .field("bytes", &format!("<{} bytes>", self.bytes.len()))
            .field("script", &self.script)
            .finish()
    }
}

/// A pipeline whose seams have been checked.
///
/// Only constructible through [`check`], so holding one is evidence the check
/// ran — a type that cannot be built in an unchecked state is worth more than a
/// convention that it should not be.
pub struct Checked {
    stages: Vec<Stage>,
}

impl Checked {
    /// The stages, in execution order.
    pub fn stages(&self) -> &[Stage] {
        &self.stages
    }

    /// What the pipeline as a whole accepts — its first block's input.
    pub fn input(&self) -> &Ty {
        &self.stages[0].signature.input
    }

    /// What it produces — its last block's output.
    pub fn output(&self) -> &Ty {
        &self.stages[self.stages.len() - 1].signature.output
    }
}

/// Why a pipeline was rejected.
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
    /// A block could not be read from disk.
    #[error("reading block {path}: {source}")]
    Unreadable {
        /// The block that could not be read.
        path: PathBuf,
        /// The underlying I/O failure.
        source: std::io::Error,
    },
    /// A stage could not be inspected: a directory instead of an artifact,
    /// unrecognized magic bytes, a wasm module that failed to load, or a
    /// bundle whose cached signature string doesn't parse.
    #[error("inspecting {name}: {message}")]
    Uninspectable {
        /// The stage's display name.
        name: String,
        /// What went wrong.
        message: String,
    },
    /// Resolving a pipeline entry through the catalog failed.
    #[error(transparent)]
    Resolution(#[from] crate::catalog::CatalogError),
    /// Two adjacent blocks do not fit.
    #[error(
        "block {consumer} expects {expected}, but {producer} before it produces {produced}.\n\
         Adjust one of the two signatures, or insert a block that converts between them."
    )]
    SeamMismatch {
        /// The block producing the value.
        producer: String,
        /// What it produces.
        produced: String,
        /// The block receiving it.
        consumer: String,
        /// What it needs.
        expected: String,
    },
    /// The pipeline had no blocks.
    #[error("a pipeline needs at least one block")]
    Empty,
}

/// Check that a resolved pipeline's seams fit.
///
/// Fails on the *first* mismatch rather than collecting all of them. A later
/// seam's types depend on an earlier one being what it claimed, so reporting
/// downstream mismatches after an upstream failure would mostly report
/// consequences of the first error rather than independent problems.
///
/// Takes already-resolved, already-loaded stages — see resolve_and_load
/// (added in a later change) for turning a spec's pipeline entries into
/// these. `check` itself does no disk or catalog I/O; a `Direct` vs.
/// `Cataloged` entry looks identical to it once loaded.
pub fn check(engine: &Engine, inputs: &[ResolvedInput]) -> Result<Checked, PipelineError> {
    if inputs.is_empty() {
        return Err(PipelineError::Empty);
    }

    let mut stages: Vec<Stage> = Vec::with_capacity(inputs.len());
    for input in inputs {
        let signature = read_stage_signature(engine, input)?;

        if let Some(previous) = stages.last() {
            if !previous.signature.output.assignable_to(&signature.input) {
                return Err(PipelineError::SeamMismatch {
                    producer: previous.name.clone(),
                    produced: previous.signature.output.to_string(),
                    consumer: input.name.clone(),
                    expected: signature.input.to_string(),
                });
            }
        }

        stages.push(Stage {
            name: input.name.clone(),
            kind: input.kind,
            resolved: input.resolved.clone(),
            module_bytes: input.bytes.clone(),
            signature,
            script: input.script.clone(),
        });
    }

    Ok(Checked { stages })
}

/// Read one resolved input's declared [`Signature`], regardless of whether
/// it's a block or a bundle. Shared by [`check`] (linear) and the graph
/// checker in `crate::dag` — both need exactly this per-node lookup, just
/// composed differently around it.
pub fn read_stage_signature(
    engine: &Engine,
    input: &ResolvedInput,
) -> Result<Signature, PipelineError> {
    match input.kind {
        crate::catalog::ArtifactKind::Block => crate::runner::read_signature(engine, &input.bytes)
            .map_err(|e| PipelineError::Uninspectable {
                name: input.name.clone(),
                message: format!("{e:#}"),
            }),
        crate::catalog::ArtifactKind::Bundle => {
            let compact = crate::catalog::read_bundle_signature(&input.bytes, &input.name)
                .map_err(|e| PipelineError::Uninspectable {
                    name: input.name.clone(),
                    // `read_bundle_signature`'s own error already embeds
                    // the name (it's `{path}: {reason}` with `path` set
                    // to our `name`); re-stringifying the whole error
                    // here would print the name twice. Pull out just the
                    // reason so `Uninspectable`'s own `{name}: {message}`
                    // formatting is the only place the name appears.
                    message: match e {
                        crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
                            reason
                        }
                        other => other.to_string(),
                    },
                })?;
            compact
                .parse::<Signature>()
                .map_err(|e| PipelineError::Uninspectable {
                    name: input.name.clone(),
                    message: format!("cached signature `{compact}` does not parse: {e}"),
                })
        }
        crate::catalog::ArtifactKind::Script => {
            let script = input
                .script
                .as_deref()
                .ok_or_else(|| PipelineError::Uninspectable {
                    name: input.name.clone(),
                    message: "a Script-kind ResolvedInput with no script text — this is an \
                          internal bug in resolve_and_load, not a user-facing error"
                        .to_string(),
                })?;
            let compact = crate::catalog::read_script_signature(script.as_bytes(), &input.name)
                .map_err(|e| PipelineError::Uninspectable {
                    name: input.name.clone(),
                    message: match e {
                        crate::catalog::CatalogError::UninspectableArtifact { reason, .. } => {
                            reason
                        }
                        other => other.to_string(),
                    },
                })?;
            compact
                .parse::<Signature>()
                .map_err(|e| PipelineError::Uninspectable {
                    name: input.name.clone(),
                    message: format!("cached signature `{compact}` does not parse: {e}"),
                })
        }
    }
}

/// Turn one pipeline-entry string from a spec into a loaded, kind-tagged
/// [`ResolvedInput`], ready for [`check`]. Shared by `cuttlefishd`'s run
/// path and `cuttlefish build` — the only two callers, and both need the
/// same resolve-then-load behavior.
///
/// An entry that resolves to a real path once joined against `spec_dir` is
/// treated as that path — matching the join every other spec-relative
/// reference (`capabilities`) already gets, so a relative block path means
/// the same thing regardless of the process's working directory. This
/// mirrors [`Catalog::resolve`]'s own Direct-vs-Cataloged decision (`s.ends_with(".wasm")
/// || Path::new(s).exists()`), just relative to `spec_dir` instead of the
/// process's CWD, rather than guessing from the raw string's shape (`entry`
/// containing `/`) before `resolve` ever gets a look — a catalog name is
/// free to contain `/` (a namespaced convention like `"team/cat-a@1"`), and
/// joining on shape alone would corrupt such a name into a bogus path before
/// the catalog's own index lookup ever sees it.
///
/// A `.wasm`/`.cfbundle` suffix is still always treated as a path reference
/// even when the joined candidate doesn't exist on disk, so a genuinely
/// missing compiled artifact still names the expected file in its error
/// instead of silently falling back to a catalog lookup on a string that
/// happens to contain that suffix. Anything else that doesn't resolve to a
/// real file is passed to [`Catalog::resolve`] unmodified — joining it would
/// turn `name@version` into `<spec_dir>/name@version`, which matches neither
/// an index key nor a real file.
pub fn resolve_and_load(
    catalog: &Catalog,
    spec_dir: &Path,
    entry: &str,
    context: ResolutionContext,
) -> Result<ResolvedInput, PipelineError> {
    let candidate = spec_dir.join(entry);
    let use_joined = candidate.exists() || entry.ends_with(".wasm") || entry.ends_with(".cfbundle");
    let joined;
    let s: &str = if use_joined {
        joined = candidate.to_string_lossy().into_owned();
        &joined
    } else {
        entry
    };

    match catalog.resolve(s, context)? {
        Resolved::Direct(path) => {
            if path.is_dir() {
                return Err(PipelineError::Uninspectable {
                    name: path.display().to_string(),
                    message: "this is a directory. A pipeline names compiled \
                              `.wasm`/`.cfbundle` artifacts, not block source \
                              directories — build the block first and point at \
                              the compiled artifact."
                        .into(),
                });
            }
            let bytes = std::fs::read(&path).map_err(|source| PipelineError::Unreadable {
                path: path.clone(),
                source,
            })?;
            // A `.rhai` file has no magic bytes, so it is recognized by
            // extension — the same rule `catalog add` itself uses.
            //
            // Referencing a script by path is what makes an edit-run-edit
            // loop possible at all. A catalogued `name@version` is immutable
            // all the way down (`catalog rm` drops the index entry but keeps
            // the blob), which is right for anything a shipped spec depends
            // on and unusable while the script is still being written: every
            // edit would need a new version. Requiring the catalog here sent
            // real users off to invent content-hash versions and generated
            // specs to route around it.
            if path.extension().is_some_and(|e| e == "rhai") {
                let script =
                    String::from_utf8(bytes).map_err(|e| PipelineError::Uninspectable {
                        name: path.display().to_string(),
                        message: format!("script is not valid UTF-8: {e}"),
                    })?;
                return Ok(ResolvedInput {
                    name: name_of(&path),
                    kind: crate::catalog::ArtifactKind::Script,
                    // No `name@version`, because there deliberately isn't
                    // one: this is a file on disk, and that is the point.
                    resolved: None,
                    bytes: crate::embedded_rhai_interpreter_bytes().to_vec(),
                    script: Some(script),
                });
            }
            let kind = crate::catalog::sniff_artifact_kind(&bytes).ok_or_else(|| {
                let message = if path.extension().is_some_and(|e| e == "rhai") {
                    "not a recognized artifact: a .rhai script must be `cuttlefish catalog \
                     add`ed before use — a pipeline can't reference one directly by path"
                        .to_string()
                } else {
                    "not a recognized artifact (neither wasm nor .cfbundle magic bytes)".into()
                };
                PipelineError::Uninspectable {
                    name: path.display().to_string(),
                    message,
                }
            })?;
            Ok(ResolvedInput {
                name: name_of(&path),
                kind,
                resolved: None,
                bytes,
                script: None,
            })
        }
        Resolved::Cataloged {
            name_version,
            entry,
        } => {
            let name = name_version
                .split_once('@')
                .map(|(n, _)| n)
                .unwrap_or(&name_version)
                .to_string();

            if entry.kind == crate::catalog::ArtifactKind::Script {
                let script_bytes = catalog.read_blob(&entry)?;
                let script =
                    String::from_utf8(script_bytes).map_err(|e| PipelineError::Uninspectable {
                        name: name.clone(),
                        message: format!("cataloged script is not valid UTF-8: {e}"),
                    })?;
                return Ok(ResolvedInput {
                    name,
                    kind: entry.kind,
                    resolved: Some(name_version),
                    bytes: crate::embedded_rhai_interpreter_bytes().to_vec(),
                    script: Some(script),
                });
            }

            let bytes = catalog.read_blob(&entry)?;
            Ok(ResolvedInput {
                name,
                kind: entry.kind,
                resolved: Some(name_version),
                bytes,
                script: None,
            })
        }
    }
}

/// A short name for error messages and manifest output — the file stem, or
/// the whole path when there isn't one (`Path::file_stem(".wasm")` returns
/// `Some(".wasm")` under the leading-dot rule, so this never panics or
/// empties out on a no-real-basename path).
fn name_of(path: &Path) -> String {
    path.file_stem()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string())
}