Skip to main content

freenet_stdlib/
delegate_manifest.rs

1//! The delegate manifest: what a delegate asks the node for, declared inside
2//! its own WASM.
3//!
4//! A delegate that wants more than the request/response model it has always
5//! had (today: lifecycle events; later: wake-ups, notifications) says so in a
6//! manifest. The `#[delegate(manifest(...))]` attribute writes it into a WASM
7//! custom section named [`MANIFEST_SECTION_NAME`]. The node reads that section
8//! when the delegate is registered, without running any delegate code.
9//!
10//! # Why a manifest at all
11//!
12//! Two jobs, both of which need the node to know what a delegate wants
13//! *before* it runs:
14//!
15//! 1. **Backward compatibility of new inbound messages.** An
16//!    [`InboundDelegateMsg`](crate::prelude::InboundDelegateMsg) variant added
17//!    in a later stdlib is a hard decode error in a delegate built against an
18//!    earlier one (see `WIRE-FORMAT.md`). So the node sends a new kind of
19//!    inbound message — [`LifecycleEvent`] today — **only** to a delegate
20//!    whose manifest lists it. A delegate without a manifest never receives
21//!    one, and behaves exactly as it did before manifests existed.
22//! 2. **Consent.** Some capabilities are gated by the node on the user's
23//!    permission ("run in the background"). The node reads the capabilities
24//!    from the manifest and asks the user once, at registration time, for the
25//!    ones not yet granted.
26//!
27//! # Why it cannot be swapped
28//!
29//! The section is part of the WASM module, and a delegate's key is derived
30//! from the hash of that module. Changing the manifest changes the delegate
31//! key, exactly as changing any line of code does.
32//!
33//! # Encoding: JSON, deliberately
34//!
35//! The payload is UTF-8 JSON, not bincode. The manifest is the one piece of
36//! delegate metadata the node must read from delegates built against *any*
37//! stdlib, including ones newer than the node. bincode is positional and
38//! cannot skip what it does not know (`WIRE-FORMAT.md`), so a newer stdlib
39//! adding a field or a capability would make older nodes reject the whole
40//! manifest. JSON names its fields, so an older reader ignores fields it does
41//! not know, and unknown capability or lifecycle names decode as
42//! [`Capability::Unknown`] / [`LifecycleKind::Unknown`] and are ignored rather
43//! than rejecting the manifest. (`#[serde(other)]` is safe here because JSON
44//! is self-describing; `WIRE-FORMAT.md`'s warning against it is about
45//! bincode.)
46//!
47//! # Emitting a manifest adds a section, and so changes the delegate key
48//!
49//! Only delegates that write `manifest(...)` get the section. Upgrading stdlib
50//! does not add one to delegates that do not ask for it.
51
52use serde::{Deserialize, Serialize};
53
54/// Name of the WASM custom section holding the manifest.
55pub const MANIFEST_SECTION_NAME: &str = "freenet-manifest";
56
57/// The manifest format version this stdlib writes.
58///
59/// Informational only. Readers accept any version `>= 1` and never gate on it,
60/// because a reader that refused newer versions would drop every capability it
61/// does understand the moment one it does not is added. That works only under
62/// two rules, which are permanent:
63///
64/// - the meaning of an existing field or name never changes; a changed meaning
65///   gets a new field or a new name;
66/// - `lifecycle` and `capabilities` entries are what a reader looks up by
67///   name. A later format that needs parameters for a capability adds a new
68///   top-level field for them. (A reader still tolerates a non-string entry:
69///   it decodes as `Unknown`, see [`DelegateManifest::from_bytes`].)
70pub const MANIFEST_VERSION: u16 = 1;
71
72/// Largest manifest payload a reader accepts, in bytes. A manifest is a
73/// handful of short names; anything bigger is not a manifest.
74pub const MAX_MANIFEST_BYTES: usize = 4096;
75
76/// What a delegate declares it wants from the node.
77///
78/// `#[non_exhaustive]` so fields can be added without a source break; build one
79/// with [`DelegateManifest::new`].
80#[non_exhaustive]
81#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
82pub struct DelegateManifest {
83    /// Format version; see [`MANIFEST_VERSION`].
84    pub manifest_version: u16,
85    /// Lifecycle events the delegate wants delivered. The node never sends a
86    /// [`LifecycleEvent`] of a kind that is not listed here.
87    #[serde(default, deserialize_with = "lenient_list")]
88    pub lifecycle: Vec<LifecycleKind>,
89    /// Node-enforced capabilities the delegate asks the user for.
90    #[serde(default, deserialize_with = "lenient_list")]
91    pub capabilities: Vec<Capability>,
92}
93
94/// A kind of [`LifecycleEvent`] a delegate can ask to receive.
95///
96/// Adding a kind later means adding a variant here **and** a matching
97/// [`LifecycleEvent`] variant. A delegate built before the addition cannot
98/// list the new kind, so it never receives the new event — which is what makes
99/// appending a `LifecycleEvent` variant safe for already-deployed delegates.
100#[non_exhaustive]
101#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
102#[serde(rename_all = "snake_case")]
103pub enum LifecycleKind {
104    /// [`LifecycleEvent::Installed`].
105    Installed,
106    /// [`LifecycleEvent::NodeStarted`].
107    NodeStarted,
108    /// A name this stdlib does not know, written by a newer one. Readers
109    /// ignore it. The macro never writes it; re-serializing a manifest read
110    /// from a newer stdlib does (as `"unknown"`).
111    #[serde(other)]
112    Unknown,
113}
114
115/// A node-enforced capability, granted by the user once per app.
116///
117/// A node that implements capabilities refuses one until the user has granted
118/// it, and remembers the answer per app, so the user is asked once. How a node
119/// identifies an app is the node's business, not part of this format.
120#[non_exhaustive]
121#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
122#[serde(rename_all = "snake_case")]
123pub enum Capability {
124    /// Run without an open app: receive lifecycle events. Required by any
125    /// manifest that lists a lifecycle kind (the macro enforces it).
126    Background,
127    /// A name this stdlib does not know, written by a newer one. Readers
128    /// ignore it. The macro never writes it; re-serializing a manifest read
129    /// from a newer stdlib does (as `"unknown"`).
130    #[serde(other)]
131    Unknown,
132}
133
134/// A lifecycle event, delivered as
135/// [`InboundDelegateMsg::Lifecycle`](crate::prelude::InboundDelegateMsg::Lifecycle).
136///
137/// Only sent to a delegate whose manifest lists the matching
138/// [`LifecycleKind`]. A node that implements delivery also requires the user's
139/// [`Capability::Background`] grant for the delegate's app. The run gets the
140/// delegate's registered parameters and no origin.
141///
142/// # Wire format
143///
144/// bincode, nested inside `InboundDelegateMsg`. Variants are appended, never
145/// inserted or reordered (pinned by `lifecycle_event_tags_are_pinned`). A
146/// variant's fields are frozen once released: `WIRE-FORMAT.md` rule 1 forbids
147/// appending a field to a struct already on the wire, so new information
148/// arrives as a new variant.
149#[non_exhaustive]
150#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
151pub enum LifecycleEvent {
152    /// This delegate was installed on this node: its first registration here,
153    /// or the first time its app was granted [`Capability::Background`] after
154    /// that. Delivered at most once per delegate key per node.
155    ///
156    /// A delegate typically uses it to subscribe to the contracts it watches
157    /// and to do any one-time setup.
158    Installed,
159    /// The node started. Delivered once per node start, after the node has
160    /// finished whatever restore work it does at start-up.
161    ///
162    /// Contract notifications that arrived while the node was down were not
163    /// delivered and are never replayed, so a delegate should re-read any
164    /// contract it depends on rather than assume it saw every change.
165    NodeStarted {
166        /// When the node was last known to be running (milliseconds since the
167        /// Unix epoch), if it knows. Everything between this and now was
168        /// missed. `None` means the node does not know, not that nothing was
169        /// missed.
170        down_since_ms: Option<u64>,
171    },
172}
173
174impl LifecycleEvent {
175    /// The manifest kind that must be listed for this event to be delivered.
176    pub fn kind(&self) -> LifecycleKind {
177        match self {
178            LifecycleEvent::Installed => LifecycleKind::Installed,
179            LifecycleEvent::NodeStarted { .. } => LifecycleKind::NodeStarted,
180        }
181    }
182}
183
184/// Why a manifest could not be read.
185#[derive(Debug, thiserror::Error, PartialEq, Eq)]
186#[non_exhaustive]
187pub enum ManifestError {
188    #[error("not a WASM module (bad magic or version)")]
189    NotWasm,
190    #[error("truncated or malformed WASM section structure")]
191    Malformed,
192    #[error("more than one `{MANIFEST_SECTION_NAME}` custom section")]
193    Duplicate,
194    #[error("manifest is {0} bytes, over the {MAX_MANIFEST_BYTES}-byte limit")]
195    TooLarge(usize),
196    #[error("manifest is not valid JSON for this schema: {0}")]
197    Decode(String),
198    #[error("manifest_version 0 is not a valid version")]
199    BadVersion,
200}
201
202impl DelegateManifest {
203    /// A manifest at the current [`MANIFEST_VERSION`].
204    pub fn new(lifecycle: Vec<LifecycleKind>, capabilities: Vec<Capability>) -> Self {
205        Self {
206            manifest_version: MANIFEST_VERSION,
207            lifecycle,
208            capabilities,
209        }
210    }
211
212    /// Whether the manifest asks for lifecycle events of this kind.
213    pub fn wants_lifecycle(&self, kind: LifecycleKind) -> bool {
214        kind != LifecycleKind::Unknown && self.lifecycle.contains(&kind)
215    }
216
217    /// Whether the manifest asks for this capability.
218    pub fn wants_capability(&self, cap: Capability) -> bool {
219        cap != Capability::Unknown && self.capabilities.contains(&cap)
220    }
221
222    /// Known capabilities this manifest asks for, deduplicated, in declaration
223    /// order. Unknown names are dropped.
224    pub fn known_capabilities(&self) -> Vec<Capability> {
225        let mut out = Vec::new();
226        for c in &self.capabilities {
227            if *c != Capability::Unknown && !out.contains(c) {
228                out.push(*c);
229            }
230        }
231        out
232    }
233
234    /// Serialize to the section payload.
235    pub fn to_bytes(&self) -> Vec<u8> {
236        serde_json::to_vec(self).expect("a manifest always serializes")
237    }
238
239    /// Parse a section payload.
240    ///
241    /// Tolerant of manifests written by newer stdlibs: unknown fields are
242    /// ignored, a list field that is not an array (`null`, or any other shape)
243    /// reads as empty, and a list entry that is not a
244    /// name this reader knows (an unknown name, or a non-string value) reads as
245    /// `Unknown` rather than failing the manifest, so the known entries next
246    /// to it still count.
247    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ManifestError> {
248        if bytes.len() > MAX_MANIFEST_BYTES {
249            return Err(ManifestError::TooLarge(bytes.len()));
250        }
251        let m: DelegateManifest =
252            serde_json::from_slice(bytes).map_err(|e| ManifestError::Decode(e.to_string()))?;
253        if m.manifest_version == 0 {
254            return Err(ManifestError::BadVersion);
255        }
256        Ok(m)
257    }
258
259    /// Read the manifest from a raw WASM module (no version prefix).
260    ///
261    /// `Ok(None)` means the module has no manifest section: a delegate that
262    /// asked for nothing, which is every delegate built before manifests
263    /// existed. Only the section headers are walked; nothing is executed or
264    /// validated beyond what locating the section needs.
265    pub fn from_wasm(module: &[u8]) -> Result<Option<Self>, ManifestError> {
266        let mut found: Option<&[u8]> = None;
267        for section in custom_sections(module)? {
268            let (name, payload) = section?;
269            if name == MANIFEST_SECTION_NAME.as_bytes() {
270                if found.is_some() {
271                    return Err(ManifestError::Duplicate);
272                }
273                found = Some(payload);
274            }
275        }
276        found.map(Self::from_bytes).transpose()
277    }
278}
279
280/// Decode a list whose entries are enum names, mapping any entry this reader
281/// cannot decode to the enum's `#[serde(other)]` variant.
282fn lenient_list<'de, D, T>(d: D) -> Result<Vec<T>, D::Error>
283where
284    D: serde::Deserializer<'de>,
285    T: serde::de::DeserializeOwned + Unknownable,
286{
287    // Anything but an array (`null`, or a shape a later format might use)
288    // reads as an empty list: asking for nothing is the safe direction.
289    let serde_json::Value::Array(raw) = serde_json::Value::deserialize(d)? else {
290        return Ok(Vec::new());
291    };
292    Ok(raw
293        .into_iter()
294        .map(|v| serde_json::from_value(v).unwrap_or_else(|_| T::unknown()))
295        .collect())
296}
297
298trait Unknownable {
299    fn unknown() -> Self;
300}
301impl Unknownable for LifecycleKind {
302    fn unknown() -> Self {
303        LifecycleKind::Unknown
304    }
305}
306impl Unknownable for Capability {
307    fn unknown() -> Self {
308        Capability::Unknown
309    }
310}
311
312/// Used by `#[delegate(manifest(...))]` to check, at compile time, that the
313/// section name and version it writes are the ones this stdlib reads. Not
314/// part of the public API.
315#[doc(hidden)]
316pub const fn __manifest_macro_agrees(section: &str, version: u16) -> bool {
317    let (a, b) = (section.as_bytes(), MANIFEST_SECTION_NAME.as_bytes());
318    if a.len() != b.len() || version != MANIFEST_VERSION {
319        return false;
320    }
321    let mut i = 0;
322    while i < a.len() {
323        if a[i] != b[i] {
324            return false;
325        }
326        i += 1;
327    }
328    true
329}
330
331/// A custom section's `(name, payload)`.
332type CustomSection<'a> = (&'a [u8], &'a [u8]);
333
334/// Iterate over a WASM module's custom sections.
335fn custom_sections(
336    module: &[u8],
337) -> Result<impl Iterator<Item = Result<CustomSection<'_>, ManifestError>>, ManifestError> {
338    const HEADER: [u8; 8] = [0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
339    if module.len() < HEADER.len() || module[..HEADER.len()] != HEADER {
340        return Err(ManifestError::NotWasm);
341    }
342    let mut pos = HEADER.len();
343    let mut failed = false;
344    Ok(std::iter::from_fn(move || loop {
345        if failed || pos >= module.len() {
346            return None;
347        }
348        let parsed = (|| {
349            let id = module[pos];
350            let mut p = pos + 1;
351            let size = read_leb_u32(module, &mut p)? as usize;
352            let end = p.checked_add(size).ok_or(ManifestError::Malformed)?;
353            if end > module.len() {
354                return Err(ManifestError::Malformed);
355            }
356            let custom = if id == 0 {
357                let name_len = read_leb_u32(module, &mut p)? as usize;
358                let name_end = p.checked_add(name_len).ok_or(ManifestError::Malformed)?;
359                if name_end > end {
360                    return Err(ManifestError::Malformed);
361                }
362                Some((&module[p..name_end], &module[name_end..end]))
363            } else {
364                None
365            };
366            Ok((end, custom))
367        })();
368        match parsed {
369            Ok((end, custom)) => {
370                pos = end;
371                if let Some(c) = custom {
372                    return Some(Ok(c));
373                }
374            }
375            Err(e) => {
376                failed = true;
377                return Some(Err(e));
378            }
379        }
380    }))
381}
382
383fn read_leb_u32(buf: &[u8], pos: &mut usize) -> Result<u32, ManifestError> {
384    let mut result: u32 = 0;
385    for i in 0..5 {
386        let byte = *buf.get(*pos).ok_or(ManifestError::Malformed)?;
387        *pos += 1;
388        if i == 4 && byte & 0xf0 != 0 {
389            return Err(ManifestError::Malformed);
390        }
391        result |= u32::from(byte & 0x7f) << (7 * i);
392        if byte & 0x80 == 0 {
393            return Ok(result);
394        }
395    }
396    Err(ManifestError::Malformed)
397}
398
399#[cfg(test)]
400mod tests {
401    use super::*;
402
403    fn leb(mut v: u32) -> Vec<u8> {
404        let mut out = Vec::new();
405        loop {
406            let mut b = (v & 0x7f) as u8;
407            v >>= 7;
408            if v != 0 {
409                b |= 0x80;
410            }
411            out.push(b);
412            if v == 0 {
413                return out;
414            }
415        }
416    }
417
418    fn custom_section(name: &str, payload: &[u8]) -> Vec<u8> {
419        let mut body = leb(name.len() as u32);
420        body.extend_from_slice(name.as_bytes());
421        body.extend_from_slice(payload);
422        let mut out = vec![0u8];
423        out.extend(leb(body.len() as u32));
424        out.extend(body);
425        out
426    }
427
428    fn module(sections: &[Vec<u8>]) -> Vec<u8> {
429        let mut m = vec![0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00];
430        // A non-custom section first (type section, empty vec), so the walker
431        // has to step over a section it does not care about.
432        m.extend([0x01, 0x01, 0x00]);
433        for s in sections {
434            m.extend_from_slice(s);
435        }
436        m
437    }
438
439    fn sample() -> DelegateManifest {
440        DelegateManifest::new(
441            vec![LifecycleKind::Installed, LifecycleKind::NodeStarted],
442            vec![Capability::Background],
443        )
444    }
445
446    #[test]
447    fn round_trips_through_a_wasm_custom_section() {
448        let m = module(&[
449            custom_section("name", b"whatever"),
450            custom_section(MANIFEST_SECTION_NAME, &sample().to_bytes()),
451        ]);
452        assert_eq!(DelegateManifest::from_wasm(&m).unwrap(), Some(sample()));
453    }
454
455    #[test]
456    fn a_module_without_the_section_has_no_manifest() {
457        let m = module(&[custom_section("producers", b"rustc")]);
458        assert_eq!(DelegateManifest::from_wasm(&m).unwrap(), None);
459    }
460
461    /// The exact JSON the macro writes. If this changes, every delegate that
462    /// declares a manifest re-keys on its next build, and older nodes must
463    /// still read it.
464    #[test]
465    fn json_shape_is_pinned() {
466        assert_eq!(
467            String::from_utf8(sample().to_bytes()).unwrap(),
468            r#"{"manifest_version":1,"lifecycle":["installed","node_started"],"capabilities":["background"]}"#
469        );
470    }
471
472    /// A manifest from a newer stdlib — an unknown field, an unknown
473    /// capability, an unknown lifecycle kind — must still be read, keeping
474    /// what this reader knows. Otherwise one new capability name would strip
475    /// every older node of the kinds it does understand.
476    #[test]
477    fn a_newer_manifest_is_read_keeping_known_entries() {
478        let json = br#"{"manifest_version":3,"lifecycle":["installed","woke_up"],
479            "capabilities":["background","teleport"],"brand_new_field":{"x":1}}"#;
480        let m = DelegateManifest::from_bytes(json).unwrap();
481        assert!(m.wants_lifecycle(LifecycleKind::Installed));
482        assert!(!m.wants_lifecycle(LifecycleKind::NodeStarted));
483        assert!(!m.wants_lifecycle(LifecycleKind::Unknown));
484        assert!(!m.wants_capability(Capability::Unknown));
485        assert_eq!(m.known_capabilities(), vec![Capability::Background]);
486    }
487
488    #[test]
489    fn missing_lists_default_to_empty() {
490        let m = DelegateManifest::from_bytes(br#"{"manifest_version":1}"#).unwrap();
491        assert!(m.lifecycle.is_empty() && m.capabilities.is_empty());
492        let m = DelegateManifest::from_bytes(
493            br#"{"manifest_version":1,"lifecycle":null,"capabilities":null}"#,
494        )
495        .unwrap();
496        assert!(m.lifecycle.is_empty() && m.capabilities.is_empty());
497        let m = DelegateManifest::from_bytes(
498            br#"{"manifest_version":1,"lifecycle":"installed","capabilities":{"background":{}}}"#,
499        )
500        .unwrap();
501        assert!(m.lifecycle.is_empty() && m.capabilities.is_empty());
502    }
503
504    /// A later format might write an entry that is not a bare name. That entry
505    /// is unknown to this reader; the known ones next to it must still count.
506    #[test]
507    fn a_non_string_entry_reads_as_unknown_not_as_an_error() {
508        let json = br#"{"manifest_version":2,
509            "lifecycle":[{"woke_up":{"every_s":60}},"node_started",7],
510            "capabilities":[{"notify":{"max_per_hour":4}},"background",null]}"#;
511        let m = DelegateManifest::from_bytes(json).unwrap();
512        assert_eq!(
513            m.lifecycle,
514            vec![
515                LifecycleKind::Unknown,
516                LifecycleKind::NodeStarted,
517                LifecycleKind::Unknown
518            ]
519        );
520        assert_eq!(m.known_capabilities(), vec![Capability::Background]);
521    }
522
523    #[test]
524    fn macro_agreement_check() {
525        assert!(__manifest_macro_agrees(
526            MANIFEST_SECTION_NAME,
527            MANIFEST_VERSION
528        ));
529        assert!(!__manifest_macro_agrees(
530            "freenet-manifesT",
531            MANIFEST_VERSION
532        ));
533        assert!(!__manifest_macro_agrees(
534            "freenet-manifest2",
535            MANIFEST_VERSION
536        ));
537        assert!(!__manifest_macro_agrees(
538            MANIFEST_SECTION_NAME,
539            MANIFEST_VERSION + 1
540        ));
541    }
542
543    #[test]
544    fn rejects_version_zero_oversize_and_garbage() {
545        assert_eq!(
546            DelegateManifest::from_bytes(br#"{"manifest_version":0}"#),
547            Err(ManifestError::BadVersion)
548        );
549        let big = vec![b' '; MAX_MANIFEST_BYTES + 1];
550        assert_eq!(
551            DelegateManifest::from_bytes(&big),
552            Err(ManifestError::TooLarge(MAX_MANIFEST_BYTES + 1))
553        );
554        assert!(matches!(
555            DelegateManifest::from_bytes(b"not json"),
556            Err(ManifestError::Decode(_))
557        ));
558    }
559
560    /// Two sections would be ambiguous. It also catches two crates in one
561    /// build each emitting a manifest: the linker would normally concatenate
562    /// same-named sections into one, which fails to decode instead, but a
563    /// post-link tool could leave them separate.
564    #[test]
565    fn rejects_a_duplicate_section() {
566        let payload = sample().to_bytes();
567        let m = module(&[
568            custom_section(MANIFEST_SECTION_NAME, &payload),
569            custom_section(MANIFEST_SECTION_NAME, &payload),
570        ]);
571        assert_eq!(
572            DelegateManifest::from_wasm(&m),
573            Err(ManifestError::Duplicate)
574        );
575    }
576
577    #[test]
578    fn concatenated_manifests_fail_to_decode() {
579        let mut payload = sample().to_bytes();
580        payload.extend(sample().to_bytes());
581        let m = module(&[custom_section(MANIFEST_SECTION_NAME, &payload)]);
582        assert!(matches!(
583            DelegateManifest::from_wasm(&m),
584            Err(ManifestError::Decode(_))
585        ));
586    }
587
588    #[test]
589    fn rejects_non_wasm_and_truncated_modules() {
590        assert_eq!(
591            DelegateManifest::from_wasm(b"\0asm"),
592            Err(ManifestError::NotWasm)
593        );
594        assert_eq!(
595            DelegateManifest::from_wasm(b"hello world, not wasm"),
596            Err(ManifestError::NotWasm)
597        );
598        let mut m = module(&[custom_section(MANIFEST_SECTION_NAME, &sample().to_bytes())]);
599        m.truncate(m.len() - 3);
600        assert_eq!(
601            DelegateManifest::from_wasm(&m),
602            Err(ManifestError::Malformed)
603        );
604        // A section whose declared size runs past the end.
605        let mut m = module(&[]);
606        m.extend([0x00, 0xff, 0xff, 0x03]);
607        assert_eq!(
608            DelegateManifest::from_wasm(&m),
609            Err(ManifestError::Malformed)
610        );
611        // An over-long LEB128 (more than 5 bytes).
612        let mut m = module(&[]);
613        m.extend([0x00, 0x80, 0x80, 0x80, 0x80, 0x80, 0x00]);
614        assert_eq!(
615            DelegateManifest::from_wasm(&m),
616            Err(ManifestError::Malformed)
617        );
618    }
619
620    /// Wire pin for the nested enum. Appending a variant is fine; reordering
621    /// or inserting one silently reinterprets deployed delegates' input.
622    #[test]
623    fn lifecycle_event_tags_are_pinned() {
624        fn tag(e: &LifecycleEvent) -> u32 {
625            match e {
626                LifecycleEvent::Installed => 0,
627                LifecycleEvent::NodeStarted { .. } => 1,
628            }
629        }
630        let all = [
631            LifecycleEvent::Installed,
632            LifecycleEvent::NodeStarted {
633                down_since_ms: Some(0x0102_0304_0506_0708),
634            },
635        ];
636        for e in &all {
637            let enc = bincode::serialize(e).unwrap();
638            assert_eq!(u32::from_le_bytes(enc[..4].try_into().unwrap()), tag(e));
639            assert_eq!(&bincode::deserialize::<LifecycleEvent>(&enc).unwrap(), e);
640        }
641        // Full byte layout of NodeStarted: tag 1, Option tag 1, u64 LE.
642        assert_eq!(
643            bincode::serialize(&all[1]).unwrap(),
644            vec![1, 0, 0, 0, 1, 8, 7, 6, 5, 4, 3, 2, 1]
645        );
646        // `kind()` agrees with the manifest kinds.
647        assert_eq!(all[0].kind(), LifecycleKind::Installed);
648        assert_eq!(all[1].kind(), LifecycleKind::NodeStarted);
649    }
650}