Skip to main content

planarian_daemon/
hooks.rs

1//! The two Phase A hooks (§9, §10): a signed external desired-state source,
2//! verified and cached with no producer and no network, and a signed health
3//! report emitted locally with the minimal disclosure profile.
4//!
5//! Both are Ed25519 over the exact bytes of a JSON document, with a domain
6//! tag so a signature for one can never pass as the other. Signatures sit
7//! next to the document as `<file>.sig`: 128 lowercase hex digits.
8//!
9//! I/O stays here and in `main`; the core only receives a verified document
10//! (`Core::set_external`) and hands out the state the report is built from.
11
12use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
13use planarian_schema::{GenerationId, Incident, IncidentState, SystemState, Timestamp, UnitDefs, UnitStatus};
14use serde::{Deserialize, Serialize};
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18const DESIRED_TAG: &[u8] = b"planarian desired-state v1\0";
19const HEALTH_TAG: &[u8] = b"planarian health-report v1\0";
20
21pub fn hex(bytes: &[u8]) -> String {
22    bytes.iter().map(|b| format!("{b:02x}")).collect()
23}
24
25pub fn unhex(s: &str) -> Option<Vec<u8>> {
26    let s = s.trim();
27    if !s.len().is_multiple_of(2) {
28        return None;
29    }
30    (0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
31}
32
33fn tagged(tag: &[u8], doc: &[u8]) -> Vec<u8> {
34    [tag, doc].concat()
35}
36
37fn sig_path(p: &Path) -> PathBuf {
38    let mut s = p.as_os_str().to_owned();
39    s.push(".sig");
40    s.into()
41}
42
43// ---------- External desired-state source (§9) ----------
44
45/// `services.planarian.externalSource`: declared in the generation, so
46/// changing it is Security-sensitive (it is under `services.planarian`).
47#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct ExternalSourceConfig {
50    /// The Ed25519 public key, 64 hex digits.
51    pub public_key: String,
52    /// Where a producer (none in Phase A) drops the document; `<path>.sig` beside it.
53    pub path: PathBuf,
54}
55
56/// The signed document. Its units join the generation's manifest; a unit the
57/// manifest declares is the manifest's (the generation wins).
58#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
59pub struct DesiredDoc {
60    /// Strictly increasing per producer: an older document never replaces a newer one.
61    pub seq: u64,
62    pub issued: Timestamp,
63    /// Who produced it, for the provenance in reports (§9, future-work T5).
64    #[serde(default)]
65    pub issuer: String,
66    #[serde(default)]
67    pub units: UnitDefs,
68}
69
70/// A document that passed verification, with the bytes it was verified over.
71#[derive(Clone, Debug, PartialEq, Eq)]
72pub struct Verified {
73    pub doc: DesiredDoc,
74    pub bytes: Vec<u8>,
75    pub sig: Vec<u8>,
76}
77
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub enum Poll {
80    /// Nothing at the path, or the same document as the cache.
81    Unchanged,
82    /// A newer valid document: now in force and cached.
83    Accepted(Verified),
84    /// Validly signed but not taken: older than the cache, a second document
85    /// with the cache's sequence number, or unparseable. The cache stays in force.
86    Refused(String),
87    /// Unreadable: the cache stays in force (a stale or unreachable source, §9).
88    Stale(String),
89    /// An invalid signature (§12d Integrity).
90    Integrity(String),
91}
92
93pub struct ExternalSource {
94    key: VerifyingKey,
95    path: PathBuf,
96    cache: PathBuf,
97    pub current: Option<Verified>,
98}
99
100impl ExternalSource {
101    /// The key must parse; a bad key in the generation is a configuration error.
102    pub fn new(cfg: &ExternalSourceConfig, cache_dir: &Path) -> Result<ExternalSource, String> {
103        let raw = unhex(&cfg.public_key).ok_or("externalSource.publicKey: not hex")?;
104        let raw: [u8; 32] = raw.try_into().map_err(|_| "externalSource.publicKey: not 32 bytes")?;
105        let key = VerifyingKey::from_bytes(&raw).map_err(|e| format!("externalSource.publicKey: {e}"))?;
106        Ok(ExternalSource {
107            key,
108            path: cfg.path.clone(),
109            cache: cache_dir.join("external-desired.json"),
110            current: None,
111        })
112    }
113
114    fn verify(&self, bytes: &[u8], sig_hex: &str) -> Result<(), String> {
115        let sig = unhex(sig_hex).ok_or("signature is not hex")?;
116        let sig = Signature::from_slice(&sig).map_err(|e| format!("signature: {e}"))?;
117        self.key.verify(&tagged(DESIRED_TAG, bytes), &sig).map_err(|_| "signature does not verify".to_string())
118    }
119
120    fn read(&self, path: &Path) -> Result<(Vec<u8>, String), String> {
121        let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
122        let sig = std::fs::read_to_string(sig_path(path)).map_err(|e| format!("{}: {e}", sig_path(path).display()))?;
123        Ok((bytes, sig))
124    }
125
126    fn check(&self, bytes: Vec<u8>, sig: &str) -> Result<Result<Verified, String>, String> {
127        self.verify(&bytes, sig)?;
128        let parsed = serde_json::from_slice::<DesiredDoc>(&bytes).map_err(|e| format!("signed but unreadable: {e}"));
129        Ok(parsed.map(|doc| Verified { doc, bytes, sig: unhex(sig).unwrap_or_default() }))
130    }
131
132    /// At start: the cache, re-verified. A cache that no longer verifies was
133    /// altered on disk: Integrity. No cache is fine.
134    pub fn load_cache(&mut self) -> Result<(), String> {
135        if !self.cache.exists() {
136            return Ok(());
137        }
138        let raw = std::fs::read(&self.cache).map_err(|e| format!("external source cache: {e}"))?;
139        // One file: the signature's hex on the first line, then the document.
140        let Some(nl) = raw.iter().position(|b| *b == b'\n') else {
141            return Err("external source cache: no signature line".into());
142        };
143        let sig = String::from_utf8_lossy(&raw[..nl]).into_owned();
144        match self.check(raw[nl + 1..].to_vec(), &sig) {
145            Ok(Ok(v)) => {
146                self.current = Some(v);
147                Ok(())
148            }
149            Ok(Err(e)) | Err(e) => Err(format!("external source cache: {e}")),
150        }
151    }
152
153    pub fn poll(&mut self) -> Poll {
154        self.poll_against(None)
155    }
156
157    /// Whether a verified document is older than, or a different one with
158    /// the same sequence number as, the last the journal accepted (B-16).
159    pub fn replayed(doc: &Verified, journal: Option<(u64, planarian_schema::Hash)>) -> Option<String> {
160        let (seq, digest) = journal?;
161        if doc.doc.seq < seq {
162            return Some(format!(
163                "sequence {} is older than the {seq} the journal accepted (a replayed document)",
164                doc.doc.seq
165            ));
166        }
167        (doc.doc.seq == seq && planarian_schema::Hash::of(&doc.bytes) != digest)
168            .then(|| format!("a different document with sequence {seq}, which the journal accepted"))
169    }
170
171    /// `poll`, held to the journal's record of the last accepted document
172    /// (B-16): the cache is root-writable, the journal is hash-chained.
173    pub fn poll_against(&mut self, journal: Option<(u64, planarian_schema::Hash)>) -> Poll {
174        if !self.path.exists() {
175            return Poll::Unchanged;
176        }
177        let (bytes, sig) = match self.read(&self.path) {
178            Ok(r) => r,
179            Err(e) => return Poll::Stale(e),
180        };
181        if self.current.as_ref().is_some_and(|c| c.bytes == bytes && hex(&c.sig) == sig.trim()) {
182            return Poll::Unchanged;
183        }
184        let v = match self.check(bytes.clone(), &sig) {
185            Err(e) => {
186                // A producer replaces the document and its signature one
187                // after the other: a pair read in between is in flux, not
188                // tampered with. Only a pair that stays the same is.
189                std::thread::sleep(std::time::Duration::from_millis(300));
190                return match self.read(&self.path) {
191                    Ok((b2, s2)) if b2 == bytes && s2 == sig => {
192                        Poll::Integrity(format!("external desired-state source {}: {e}", self.path.display()))
193                    }
194                    Ok(_) => Poll::Stale("the document changed while it was read".into()),
195                    Err(e) => Poll::Stale(e),
196                };
197            }
198            Ok(Err(e)) => return Poll::Refused(e),
199            Ok(Ok(v)) => v,
200        };
201        if let Some(why) = Self::replayed(&v, journal) {
202            return Poll::Refused(why);
203        }
204        if let Some(c) = &self.current {
205            if v.doc.seq < c.doc.seq {
206                return Poll::Refused(format!(
207                    "sequence {} is older than the {} in force (a replayed document)",
208                    v.doc.seq, c.doc.seq
209                ));
210            }
211            if v.doc.seq == c.doc.seq {
212                if v.bytes == c.bytes {
213                    return Poll::Unchanged;
214                }
215                return Poll::Refused(format!("a second, different document with sequence {}", v.doc.seq));
216            }
217        }
218        if let Err(e) = self.store(&v) {
219            return Poll::Stale(format!("could not cache it: {e}"));
220        }
221        self.current = Some(v.clone());
222        Poll::Accepted(v)
223    }
224
225    /// One file, written aside, synced and renamed: a crash leaves the old
226    /// cache or the new one, never a document with another's signature.
227    fn store(&self, v: &Verified) -> std::io::Result<()> {
228        use std::io::Write;
229        let dir = self.cache.parent().map(Path::to_path_buf).unwrap_or_else(|| PathBuf::from("."));
230        std::fs::create_dir_all(&dir)?;
231        let tmp = self.cache.with_extension("tmp");
232        let mut f = std::fs::File::create(&tmp)?;
233        f.write_all(hex(&v.sig).as_bytes())?;
234        f.write_all(b"\n")?;
235        f.write_all(&v.bytes)?;
236        f.sync_all()?;
237        std::fs::rename(&tmp, &self.cache)?;
238        std::fs::File::open(&dir)?.sync_all()
239    }
240}
241
242/// What the core takes from an external document's unit (§9): its desired
243/// state and condition. Probes run by the core with privileges (Exec) come
244/// only from the administrator's manifest, and risk is not the document's to
245/// lower: an externally declared unit is Security-sensitive, so acting on it
246/// asks first, and the diagnoser follows no edge the document drew.
247pub fn sanitized(def: &planarian_schema::UnitDef) -> planarian_schema::UnitDef {
248    use planarian_schema::{ProbeKind, Sensitivities, Sensitivity};
249    let mut d = def.clone();
250    d.probes.retain(|p| !matches!(p.kind, ProbeKind::Exec { .. }));
251    d.sensitivity = Sensitivities::of(&[Sensitivity::Security]);
252    d.edges.clear();
253    d.runtime_repair = None;
254    d
255}
256
257/// Sign a desired-state document (for tests and the gym; Phase A has no producer).
258pub fn sign_desired(key: &SigningKey, bytes: &[u8]) -> String {
259    hex(&key.sign(&tagged(DESIRED_TAG, bytes)).to_bytes())
260}
261
262// ---------- Signed health report (§10) ----------
263
264/// Computed by the core, never chosen by a requester. Phase A has Minimal
265/// only; expanding it is Security-sensitive (it would be a new option under
266/// `services.planarian`).
267#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
268pub enum DisclosureProfile {
269    /// Unit statuses and incident kinds: no versions, paths, probe ids,
270    /// reasons or log content.
271    #[default]
272    Minimal,
273}
274
275#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
276pub struct IncidentSummary {
277    pub kind: String,
278    pub state: String,
279}
280
281#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
282pub struct HealthReport {
283    pub format: u32,
284    pub profile: DisclosureProfile,
285    pub at: Timestamp,
286    /// Generation identities: numbers only, never store paths.
287    pub generation: GenerationId,
288    pub known_good: GenerationId,
289    /// Unit → status kind (`Running`, `Failed`, ...), with no payload.
290    pub units: BTreeMap<String, String>,
291    pub incidents: Vec<IncidentSummary>,
292}
293
294/// The variant name of a status, dropping its payload (probe ids, reasons).
295pub fn status_kind(s: &UnitStatus) -> &'static str {
296    match s {
297        UnitStatus::Running => "Running",
298        UnitStatus::Degraded(_) => "Degraded",
299        UnitStatus::Stopped => "Stopped",
300        UnitStatus::Overridden(_) => "Overridden",
301        UnitStatus::Unknown => "Unknown",
302        UnitStatus::Failed(_) => "Failed",
303        UnitStatus::Absent => "Absent",
304        UnitStatus::Detected => "Detected",
305        UnitStatus::Bound => "Bound",
306        UnitStatus::Transitioning => "Transitioning",
307        UnitStatus::NotExpected => "NotExpected",
308    }
309}
310
311fn incident_state(s: &IncidentState) -> &'static str {
312    match s {
313        IncidentState::Open => "Open",
314        IncidentState::Cooldown { .. } => "Cooldown",
315        IncidentState::Escalated { .. } => "Escalated",
316        IncidentState::Closed => "Closed",
317        IncidentState::Regressed { .. } => "Regressed",
318    }
319}
320
321pub fn report<'a>(
322    profile: DisclosureProfile,
323    s: &SystemState,
324    defs: &UnitDefs,
325    incidents: impl IntoIterator<Item = &'a Incident>,
326) -> HealthReport {
327    match profile {
328        DisclosureProfile::Minimal => HealthReport {
329            format: 1,
330            profile,
331            at: s.observed_at,
332            generation: s.generation,
333            known_good: s.known_good,
334            units: s.units.keys().map(|id| (id.to_string(), status_kind(&s.status(id, defs)).to_string())).collect(),
335            incidents: incidents
336                .into_iter()
337                .filter(|i| !matches!(i.state, IncidentState::Closed))
338                .map(|i| IncidentSummary { kind: format!("{:?}", i.kind), state: incident_state(&i.state).into() })
339                .collect(),
340        },
341    }
342}
343
344/// The core's report key: created on first use, readable by root only. The
345/// public half is written beside it for whoever checks the reports.
346pub fn health_key(dir: &Path) -> std::io::Result<SigningKey> {
347    use std::os::unix::fs::OpenOptionsExt;
348    let p = dir.join("health.key");
349    if let Ok(b) = std::fs::read(&p)
350        && let Ok(seed) = <[u8; 32]>::try_from(b.as_slice())
351    {
352        return Ok(SigningKey::from_bytes(&seed));
353    }
354    let key = SigningKey::generate(&mut rand_core::OsRng);
355    std::fs::create_dir_all(dir)?;
356    let tmp = dir.join("health.key.tmp");
357    let _ = std::fs::remove_file(&tmp);
358    let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&tmp)?;
359    std::io::Write::write_all(&mut f, &key.to_bytes())?;
360    f.sync_all()?;
361    std::fs::rename(&tmp, &p)?;
362    std::fs::write(dir.join("health.pub"), hex(key.verifying_key().as_bytes()))?;
363    Ok(key)
364}
365
366/// Write `health-report.json` and its signature (local only in Phase A).
367pub fn emit(dir: &Path, key: &SigningKey, r: &HealthReport) -> std::io::Result<PathBuf> {
368    let bytes = serde_json::to_vec_pretty(r).map_err(std::io::Error::other)?;
369    let p = dir.join("health-report.json");
370    let tmp = dir.join("health-report.json.tmp");
371    std::fs::write(sig_path(&tmp), hex(&key.sign(&tagged(HEALTH_TAG, &bytes)).to_bytes()))?;
372    std::fs::write(&tmp, &bytes)?;
373    std::fs::rename(sig_path(&tmp), sig_path(&p))?;
374    std::fs::rename(&tmp, &p)?;
375    Ok(p)
376}
377
378/// Check a report against the core's public key.
379pub fn verify_report(public: &VerifyingKey, bytes: &[u8], sig_hex: &str) -> bool {
380    let Some(sig) = unhex(sig_hex).and_then(|s| Signature::from_slice(&s).ok()) else { return false };
381    public.verify(&tagged(HEALTH_TAG, bytes), &sig).is_ok()
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    fn key(n: u8) -> SigningKey {
389        SigningKey::from_bytes(&[n; 32])
390    }
391
392    fn dir(name: &str) -> PathBuf {
393        let d = std::env::temp_dir().join(format!("planarian-hooks-{name}-{}", std::process::id()));
394        let _ = std::fs::remove_dir_all(&d);
395        std::fs::create_dir_all(&d).unwrap();
396        d
397    }
398
399    fn put(path: &Path, k: &SigningKey, doc: &DesiredDoc) {
400        let bytes = serde_json::to_vec(doc).unwrap();
401        std::fs::write(sig_path(path), sign_desired(k, &bytes)).unwrap();
402        std::fs::write(path, bytes).unwrap();
403    }
404
405    fn source(d: &Path, k: &SigningKey) -> ExternalSource {
406        let cfg = ExternalSourceConfig { public_key: hex(k.verifying_key().as_bytes()), path: d.join("in/doc.json") };
407        std::fs::create_dir_all(d.join("in")).unwrap();
408        ExternalSource::new(&cfg, &d.join("cache")).unwrap()
409    }
410
411    fn doc(seq: u64) -> DesiredDoc {
412        DesiredDoc { seq, issued: Timestamp(seq * 1000), issuer: "test".into(), units: Default::default() }
413    }
414
415    /// B-16: with the cache gone (deleted, or rewound by root), the
416    /// journal's record of the last accepted document still refuses an
417    /// older one, and a different one with the same sequence number.
418    #[test]
419    fn the_journal_not_the_cache_decides_what_is_a_replay() {
420        let d = dir("journal-floor");
421        let k = key(1);
422        let mut s = source(&d, &k);
423        put(&d.join("in/doc.json"), &k, &doc(3));
424        let Poll::Accepted(v3) = s.poll() else { panic!() };
425        let journal = Some((3, planarian_schema::Hash::of(&v3.bytes)));
426        // A fresh start with no cache: an older document is a replay.
427        std::fs::remove_dir_all(d.join("cache")).ok();
428        let mut fresh = source(&d, &k);
429        put(&d.join("in/doc.json"), &k, &doc(2));
430        assert!(matches!(fresh.poll_against(journal), Poll::Refused(m) if m.contains("journal")));
431        // As is another document with the journal's sequence number.
432        let mut other = doc(3);
433        other.issuer = "someone else".into();
434        put(&d.join("in/doc.json"), &k, &other);
435        assert!(matches!(fresh.poll_against(journal), Poll::Refused(m) if m.contains("journal")));
436        // The journal's own document is taken back; a newer one is accepted.
437        put(&d.join("in/doc.json"), &k, &doc(3));
438        assert!(matches!(fresh.poll_against(journal), Poll::Accepted(v) if v.doc.seq == 3));
439        put(&d.join("in/doc.json"), &k, &doc(4));
440        assert!(matches!(fresh.poll_against(journal), Poll::Accepted(v) if v.doc.seq == 4));
441    }
442
443    #[test]
444    fn accepts_caches_and_keeps_the_newest() {
445        let d = dir("accept");
446        let k = key(1);
447        let mut s = source(&d, &k);
448        assert_eq!(s.poll(), Poll::Unchanged, "no document yet");
449        put(&d.join("in/doc.json"), &k, &doc(2));
450        assert!(matches!(s.poll(), Poll::Accepted(v) if v.doc.seq == 2));
451        assert_eq!(s.poll(), Poll::Unchanged);
452        // A replayed older document, validly signed, never replaces the newer.
453        put(&d.join("in/doc.json"), &k, &doc(1));
454        assert!(matches!(s.poll(), Poll::Refused(m) if m.contains("older")));
455        assert_eq!(s.current.as_ref().unwrap().doc.seq, 2);
456        // Equivocation: a different document with the same sequence number.
457        let mut other = doc(2);
458        other.issuer = "someone else".into();
459        put(&d.join("in/doc.json"), &k, &other);
460        assert!(matches!(s.poll(), Poll::Refused(m) if m.contains("second")));
461        // The source vanishes: the cache stays in force, across a restart too.
462        std::fs::remove_file(d.join("in/doc.json")).unwrap();
463        assert_eq!(s.poll(), Poll::Unchanged);
464        let mut again = source(&d, &k);
465        again.load_cache().unwrap();
466        assert_eq!(again.current.unwrap().doc.seq, 2);
467    }
468
469    #[test]
470    fn an_invalid_signature_is_an_integrity_failure() {
471        let d = dir("invalid");
472        let k = key(1);
473        let mut s = source(&d, &k);
474        // Signed by another key.
475        put(&d.join("in/doc.json"), &key(2), &doc(5));
476        assert!(matches!(s.poll(), Poll::Integrity(_)));
477        assert!(s.current.is_none());
478        // Signed correctly, then edited.
479        put(&d.join("in/doc.json"), &k, &doc(5));
480        assert!(matches!(s.poll(), Poll::Accepted(_)));
481        let mut bytes = std::fs::read(d.join("in/doc.json")).unwrap();
482        bytes.extend_from_slice(b" ");
483        std::fs::write(d.join("in/doc.json"), bytes).unwrap();
484        assert!(matches!(s.poll(), Poll::Integrity(_)));
485        // A health-report signature over the same bytes never passes (domain tag).
486        let bytes = serde_json::to_vec(&doc(9)).unwrap();
487        std::fs::write(d.join("in/doc.json"), &bytes).unwrap();
488        std::fs::write(sig_path(&d.join("in/doc.json")), hex(&k.sign(&tagged(HEALTH_TAG, &bytes)).to_bytes())).unwrap();
489        assert!(matches!(s.poll(), Poll::Integrity(_)));
490    }
491
492    #[test]
493    fn an_edited_cache_is_an_integrity_failure() {
494        let d = dir("cache");
495        let k = key(1);
496        let mut s = source(&d, &k);
497        put(&d.join("in/doc.json"), &k, &doc(3));
498        assert!(matches!(s.poll(), Poll::Accepted(_)));
499        let c = d.join("cache/external-desired.json");
500        let text = std::fs::read_to_string(&c).unwrap().replace("\"seq\":3", "\"seq\":4");
501        std::fs::write(&c, text).unwrap();
502        assert!(source(&d, &k).load_cache().is_err());
503    }
504
505    #[test]
506    fn a_bad_key_is_refused() {
507        let cfg = ExternalSourceConfig { public_key: "abcd".into(), path: "/nonexistent".into() };
508        assert!(ExternalSource::new(&cfg, Path::new("/nonexistent")).is_err());
509    }
510
511    #[test]
512    fn the_minimal_report_discloses_kinds_only_and_verifies() {
513        use planarian_schema::{Observed, Reason, UnitId};
514        let mut s = planarian_schema::fixtures::empty_state(1000);
515        s.units.insert(
516            UnitId::service("gym-web"),
517            (Observed::Failed(Reason::DriverError("/secret/path v1.2".into())), vec![]),
518        );
519        let r = report(DisclosureProfile::Minimal, &s, &UnitDefs::new(), []);
520        let text = serde_json::to_string(&r).unwrap();
521        assert!(!text.contains("secret") && !text.contains("1.2"), "{text}");
522        assert!(r.units.values().all(|v| !v.contains('(')));
523
524        let d = dir("report");
525        let k = health_key(&d).unwrap();
526        assert_eq!(health_key(&d).unwrap().to_bytes(), k.to_bytes(), "the key persists");
527        use std::os::unix::fs::PermissionsExt;
528        assert_eq!(std::fs::metadata(d.join("health.key")).unwrap().permissions().mode() & 0o777, 0o600);
529        let p = emit(&d, &k, &r).unwrap();
530        let bytes = std::fs::read(&p).unwrap();
531        let sig = std::fs::read_to_string(sig_path(&p)).unwrap();
532        let public = VerifyingKey::from_bytes(
533            &unhex(&std::fs::read_to_string(d.join("health.pub")).unwrap()).unwrap().try_into().unwrap(),
534        )
535        .unwrap();
536        assert!(verify_report(&public, &bytes, &sig));
537        assert!(!verify_report(&public, &[bytes.as_slice(), b" "].concat(), &sig));
538    }
539}