hugr_core/
envelope.rs

1//! Envelope format for HUGR packages.
2//!
3//! The format is designed to be extensible and backwards-compatible. It
4//! consists of a header declaring the format used to encode the HUGR, followed
5//! by the encoded HUGR itself.
6//!
7//! Use [`read_envelope`] and [`write_envelope`] for reading and writing
8//! envelopes from/to readers and writers, or call [Package::load] and
9//! [Package::store] directly.
10//!
11//! ## Payload formats
12//!
13//! The envelope may encode the HUGR in different formats, listed in
14//! [`EnvelopeFormat`]. The payload may also be compressed with zstd.
15//!
16//! Some formats can be represented as ASCII, as indicated by the
17//! [`EnvelopeFormat::ascii_printable`] method. When this is the case, the
18//! whole envelope can be stored in a string.
19//!
20//! ## Envelope header
21//!
22//! The binary header format is 10 bytes, with the following fields:
23//!
24//! | Field  | Size (bytes) | Description |
25//! |--------|--------------|-------------|
26//! | Magic  | 8            | [MAGIC_NUMBERS] constant identifying the envelope format. |
27//! | Format | 1            | [EnvelopeFormat] describing the payload format. |
28//! | Flags  | 1            | Additional configuration flags. |
29//!
30//! Flags:
31//!
32//! - Bit 0: Whether the payload is compressed with zstd.
33//! - Bits 1-5: Reserved for future use.
34//! - Bit 7,6: Constant "01" to make some headers ascii-printable.
35//!
36
37#![allow(deprecated)]
38// TODO: Due to a bug in `derive_more`
39// (https://github.com/JelteF/derive_more/issues/419) we need to deactivate
40// deprecation warnings here. We can reactivate them once the bug is fixed by
41// https://github.com/JelteF/derive_more/pull/454.
42
43mod header;
44
45pub use header::{EnvelopeConfig, EnvelopeFormat, ZstdConfig, MAGIC_NUMBERS};
46
47use crate::{
48    extension::ExtensionRegistry,
49    package::{Package, PackageEncodingError, PackageError},
50};
51use header::EnvelopeHeader;
52use std::io::BufRead;
53use std::io::Write;
54
55#[allow(unused_imports)]
56use itertools::Itertools as _;
57
58#[cfg(feature = "model_unstable")]
59use crate::import::ImportError;
60
61/// Read a HUGR envelope from a reader.
62///
63/// Returns the deserialized package and the configuration used to encode it.
64///
65/// Parameters:
66/// - `reader`: The reader to read the envelope from.
67/// - `registry`: An extension registry with additional extensions to use when
68///   decoding the HUGR, if they are not already included in the package.
69pub fn read_envelope(
70    mut reader: impl BufRead,
71    registry: &ExtensionRegistry,
72) -> Result<(EnvelopeConfig, Package), EnvelopeError> {
73    let header = EnvelopeHeader::read(&mut reader)?;
74
75    let package = match header.zstd {
76        #[cfg(feature = "zstd")]
77        true => read_impl(
78            std::io::BufReader::new(zstd::Decoder::new(reader)?),
79            header,
80            registry,
81        ),
82        #[cfg(not(feature = "zstd"))]
83        true => Err(EnvelopeError::ZstdUnsupported),
84        false => read_impl(reader, header, registry),
85    }?;
86    Ok((header.config(), package))
87}
88
89/// Write a HUGR package into an envelope, using the specified configuration.
90///
91/// It is recommended to use a buffered writer for better performance.
92/// See [`std::io::BufWriter`] for more information.
93pub fn write_envelope(
94    mut writer: impl Write,
95    package: &Package,
96    config: EnvelopeConfig,
97) -> Result<(), EnvelopeError> {
98    let header = config.make_header();
99    header.write(&mut writer)?;
100
101    match config.zstd {
102        #[cfg(feature = "zstd")]
103        Some(zstd) => {
104            let writer = zstd::Encoder::new(writer, zstd.level())?.auto_finish();
105            write_impl(writer, package, config)?;
106        }
107        #[cfg(not(feature = "zstd"))]
108        Some(_) => return Err(EnvelopeError::ZstdUnsupported),
109        None => write_impl(writer, package, config)?,
110    }
111
112    Ok(())
113}
114
115/// Error type for envelope operations.
116#[derive(derive_more::Display, derive_more::Error, Debug, derive_more::From)]
117#[non_exhaustive]
118pub enum EnvelopeError {
119    /// Bad magic number.
120    #[display(
121        "Bad magic number. expected 0x{:X} found 0x{:X}",
122        u64::from_be_bytes(*expected),
123        u64::from_be_bytes(*found)
124    )]
125    #[from(ignore)]
126    MagicNumber {
127        /// The expected magic number.
128        ///
129        /// See [`MAGIC_NUMBERS`].
130        expected: [u8; 8],
131        /// The magic number in the envelope.
132        found: [u8; 8],
133    },
134    /// The specified payload format is invalid.
135    #[display("Format descriptor {descriptor} is invalid.")]
136    #[from(ignore)]
137    InvalidFormatDescriptor {
138        /// The unsupported format.
139        descriptor: usize,
140    },
141    /// The specified payload format is not supported.
142    #[display("Payload format {format} is not supported.{}",
143        match feature {
144            Some(f) => format!(" This requires the '{f}' feature for `hugr`."),
145            None => "".to_string()
146        },
147    )]
148    #[from(ignore)]
149    FormatUnsupported {
150        /// The unsupported format.
151        format: EnvelopeFormat,
152        /// Optionally, the feature required to support this format.
153        feature: Option<&'static str>,
154    },
155    /// Not all envelope formats can be represented as ASCII.
156    ///
157    /// This error is used when trying to store the envelope into a string.
158    #[display("Envelope format {format} cannot be represented as ASCII.")]
159    #[from(ignore)]
160    NonASCIIFormat {
161        /// The unsupported format.
162        format: EnvelopeFormat,
163    },
164    /// Envelope encoding required zstd compression, but the feature is not enabled.
165    #[display("Zstd compression is not supported. This requires the 'zstd' feature for `hugr`.")]
166    #[from(ignore)]
167    ZstdUnsupported,
168    /// Tried to encode a package with multiple HUGRs, when only 1 was expected.
169    #[display(
170            "Packages with multiple HUGRs are currently unsupported. Tried to encode {count} HUGRs, when 1 was expected."
171        )]
172    #[from(ignore)]
173    /// Deprecated: Packages with multiple HUGRs is a legacy feature that is no longer supported.
174    #[deprecated(since = "0.15.2", note = "Multiple HUGRs are supported via packages.")]
175    MultipleHugrs {
176        /// The number of HUGRs in the package.
177        count: usize,
178    },
179    /// JSON serialization error.
180    SerdeError {
181        /// The source error.
182        source: serde_json::Error,
183    },
184    /// IO read/write error.
185    IO {
186        /// The source error.
187        source: std::io::Error,
188    },
189    /// Error decoding a package from the payload.
190    Package {
191        /// The source error.
192        source: PackageError,
193    },
194    /// Error writing a json package to the payload.
195    PackageEncoding {
196        /// The source error.
197        source: PackageEncodingError,
198    },
199    /// Error importing a HUGR from a hugr-model payload.
200    #[cfg(feature = "model_unstable")]
201    ModelImport {
202        /// The source error.
203        source: ImportError,
204    },
205    /// Error reading a HUGR model payload.
206    #[cfg(feature = "model_unstable")]
207    ModelRead {
208        /// The source error.
209        source: hugr_model::v0::binary::ReadError,
210    },
211    /// Error writing a HUGR model payload.
212    #[cfg(feature = "model_unstable")]
213    ModelWrite {
214        /// The source error.
215        source: hugr_model::v0::binary::WriteError,
216    },
217}
218
219/// Internal implementation of [`read_envelope`] to call with/without the zstd decompression wrapper.
220fn read_impl(
221    payload: impl BufRead,
222    header: EnvelopeHeader,
223    registry: &ExtensionRegistry,
224) -> Result<Package, EnvelopeError> {
225    match header.format {
226        #[allow(deprecated)]
227        EnvelopeFormat::PackageJson => Ok(Package::from_json_reader(payload, registry)?),
228        #[cfg(feature = "model_unstable")]
229        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
230            decode_model(payload, registry, header.format)
231        }
232        #[cfg(not(feature = "model_unstable"))]
233        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
234            Err(EnvelopeError::FormatUnsupported {
235                format: header.format,
236                feature: Some("model_unstable"),
237            })
238        }
239    }
240}
241
242/// Read a HUGR model payload from a reader.
243///
244/// Parameters:
245/// - `stream`: The reader to read the envelope from.
246/// - `extension_registry`: An extension registry with additional extensions to use when
247///   decoding the HUGR, if they are not already included in the package.
248/// - `format`: The format of the payload.
249#[cfg(feature = "model_unstable")]
250fn decode_model(
251    mut stream: impl BufRead,
252    extension_registry: &ExtensionRegistry,
253    format: EnvelopeFormat,
254) -> Result<Package, EnvelopeError> {
255    use crate::{import::import_package, Extension};
256    use hugr_model::v0::bumpalo::Bump;
257
258    if format.model_version() != Some(0) {
259        return Err(EnvelopeError::FormatUnsupported {
260            format,
261            feature: None,
262        });
263    }
264
265    let bump = Bump::default();
266    let model_package = hugr_model::v0::binary::read_from_reader(&mut stream, &bump)?;
267
268    let mut extension_registry = extension_registry.clone();
269    if format.append_extensions() {
270        let extra_extensions: Vec<Extension> =
271            serde_json::from_reader::<_, Vec<Extension>>(stream)?;
272        for ext in extra_extensions {
273            extension_registry.register_updated(ext);
274        }
275    }
276
277    Ok(import_package(&model_package, &extension_registry)?)
278}
279
280/// Internal implementation of [`write_envelope`] to call with/without the zstd compression wrapper.
281fn write_impl(
282    writer: impl Write,
283    package: &Package,
284    config: EnvelopeConfig,
285) -> Result<(), EnvelopeError> {
286    match config.format {
287        #[allow(deprecated)]
288        EnvelopeFormat::PackageJson => package.to_json_writer(writer)?,
289        #[cfg(feature = "model_unstable")]
290        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
291            encode_model(writer, package, config.format)?
292        }
293        #[cfg(not(feature = "model_unstable"))]
294        EnvelopeFormat::Model | EnvelopeFormat::ModelWithExtensions => {
295            return Err(EnvelopeError::FormatUnsupported {
296                format: config.format,
297                feature: Some("model_unstable"),
298            })
299        }
300    }
301    Ok(())
302}
303
304#[cfg(feature = "model_unstable")]
305fn encode_model(
306    mut writer: impl Write,
307    package: &Package,
308    format: EnvelopeFormat,
309) -> Result<(), EnvelopeError> {
310    use hugr_model::v0::{binary::write_to_writer, bumpalo::Bump};
311
312    use crate::export::export_package;
313
314    if format.model_version() != Some(0) {
315        return Err(EnvelopeError::FormatUnsupported {
316            format,
317            feature: None,
318        });
319    }
320
321    let bump = Bump::default();
322    let model_package = export_package(package, &bump);
323    write_to_writer(&model_package, &mut writer)?;
324
325    if format.append_extensions() {
326        serde_json::to_writer(writer, &package.extensions.iter().collect_vec())?;
327    }
328
329    Ok(())
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use cool_asserts::assert_matches;
336    use rstest::rstest;
337    use std::io::BufReader;
338
339    use crate::builder::test::{multi_module_package, simple_package};
340    use crate::extension::PRELUDE_REGISTRY;
341
342    #[rstest]
343    fn errors() {
344        let package = simple_package();
345        assert_matches!(
346            package.store_str(EnvelopeConfig::binary()),
347            Err(EnvelopeError::NonASCIIFormat { .. })
348        );
349    }
350
351    #[rstest]
352    #[case::empty(Package::default())]
353    #[case::simple(simple_package())]
354    #[case::multi(multi_module_package())]
355    fn text_roundtrip(#[case] package: Package) {
356        let envelope = package.store_str(EnvelopeConfig::text()).unwrap();
357        let new_package = Package::load_str(&envelope, None).unwrap();
358        assert_eq!(package, new_package);
359    }
360
361    #[rstest]
362    #[case::empty(Package::default())]
363    #[case::simple(simple_package())]
364    #[case::multi(multi_module_package())]
365    #[cfg_attr(all(miri, feature = "zstd"), ignore)] // FFI calls (required to compress with zstd) are not supported in miri
366    fn compressed_roundtrip(#[case] package: Package) {
367        let mut buffer = Vec::new();
368        let config = EnvelopeConfig {
369            format: EnvelopeFormat::PackageJson,
370            zstd: Some(ZstdConfig::default()),
371        };
372        let res = package.store(&mut buffer, config);
373
374        match cfg!(feature = "zstd") {
375            true => res.unwrap(),
376            false => {
377                assert_matches!(res, Err(EnvelopeError::ZstdUnsupported));
378                return;
379            }
380        }
381
382        let (decoded_config, new_package) =
383            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
384
385        assert_eq!(config.format, decoded_config.format);
386        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
387        assert_eq!(package, new_package);
388    }
389
390    #[rstest]
391    //#[case::empty(Package::default())] // Not currently supported
392    #[case::simple(simple_package())]
393    //#[case::multi(multi_module_package())] // Not currently supported
394    #[cfg(feature = "model_unstable")]
395    fn module_exts_roundtrip(#[case] package: Package) {
396        let mut buffer = Vec::new();
397        let config = EnvelopeConfig {
398            format: EnvelopeFormat::ModelWithExtensions,
399            zstd: None,
400        };
401        package.store(&mut buffer, config).unwrap();
402        let (decoded_config, new_package) =
403            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
404
405        assert_eq!(config.format, decoded_config.format);
406        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
407        assert_eq!(package, new_package);
408    }
409
410    #[rstest]
411    //#[case::empty(Package::default())] // Not currently supported
412    #[case::simple(simple_package())]
413    //#[case::multi(multi_module_package())] // Not currently supported
414    fn module_roundtrip(#[case] package: Package) {
415        let mut buffer = Vec::new();
416        let config = EnvelopeConfig {
417            format: EnvelopeFormat::Model,
418            zstd: None,
419        };
420        let res = package.store(&mut buffer, config);
421
422        match cfg!(feature = "model_unstable") {
423            true => res.unwrap(),
424            false => {
425                assert_matches!(res, Err(EnvelopeError::FormatUnsupported { .. }));
426                return;
427            }
428        }
429
430        let (decoded_config, new_package) =
431            read_envelope(BufReader::new(buffer.as_slice()), &PRELUDE_REGISTRY).unwrap();
432
433        assert_eq!(config.format, decoded_config.format);
434        assert_eq!(config.zstd.is_some(), decoded_config.zstd.is_some());
435
436        assert_eq!(package, new_package);
437    }
438}