Skip to main content

confium_signatif/
pipeline.rs

1//! The verification pipeline (SIGNATIF §14).
2//!
3//! An ordered sequence of checks classified as **hard** or **soft**.
4//! Any hard failure short-circuits to the scheme's rejected label;
5//! soft results accumulate into the [`CoverageReport`]. Inputs are the
6//! artifact, the trust anchor bundle, the trust graph, the registries,
7//! a revocation view, and the classification/acceptance policies —
8//! everything an offline verifier holds or caches.
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::artifact::TrustedArtifact;
14use crate::bundle::TrustAnchorBundle;
15use crate::coverage::{
16    Acceptance, AcceptancePolicy, ClassificationLabel, ClassificationPolicy, CoverageReport,
17    HardCheckStatus, ReferenceClassificationPolicy,
18};
19use crate::error::{SignatifError, SignatifResult};
20use crate::graph::{SignatureVerifier, TrustGraph};
21use crate::registry::Registry;
22use crate::revocation::{DEFAULT_GRACE_PERIOD, RevocationView};
23use crate::scope::ScopeDimensions;
24
25/// Soft-check inputs the pipeline cannot derive on its own.
26#[derive(Debug, Clone, Default)]
27pub struct TransparencyInputs {
28    /// Whether inclusion proofs were verified for the artifact against
29    /// a recognized log (from the bundle's log set).
30    pub artifact_included: bool,
31    /// Whether the M-of-K multi-log quorum was met.
32    pub multi_log_quorum: bool,
33    /// Whether a time-anchor attestation was verified (see [`crate::time`]).
34    pub time_anchored: bool,
35    /// The externally-attested time from a verified [`crate::time::
36    /// TimeAttestation`] — the freshness source preferred over the
37    /// signer's self-asserted block timestamps (§8.8).
38    pub time_attested_at: Option<DateTime<Utc>>,
39    /// Downgrade reasons the caller's soft checks produced (e.g.
40    /// "transparency_missing", "time_anchor_absent").
41    pub downgrades: Vec<String>,
42}
43
44/// The freshness window applied to time attestations (§14
45/// `time-freshness-window`): attestations inside the window are fresh;
46/// inside `grace` but outside the window they downgrade; older reject.
47#[derive(Debug, Clone, Copy)]
48pub struct FreshnessWindow {
49    /// Maximum age of a time attestation.
50    pub window: chrono::Duration,
51    /// Secondary grace period at downgraded classification.
52    pub grace: chrono::Duration,
53}
54
55impl Default for FreshnessWindow {
56    fn default() -> Self {
57        Self {
58            window: chrono::Duration::seconds(30 * 60),
59            grace: chrono::Duration::hours(24),
60        }
61    }
62}
63
64/// The outcome of a pipeline run.
65#[derive(Debug, Clone)]
66pub struct VerificationOutcome {
67    /// The objective coverage report.
68    pub report: CoverageReport,
69    /// The scheme's classification of the report.
70    pub label: ClassificationLabel,
71    /// The verifier's acceptance decision.
72    pub acceptance: Acceptance,
73}
74
75/// The ordered verification pipeline.
76pub struct Pipeline<'a> {
77    /// Anchor bundle (offline trust starting point).
78    pub bundle: &'a TrustAnchorBundle,
79    /// The trust graph (delegation DAG).
80    pub graph: &'a TrustGraph,
81    /// Scheme registries.
82    pub registry: &'a Registry,
83    /// Signature verifier fleet.
84    pub verifier: &'a dyn SignatureVerifier,
85    /// Revocation state view (CRLs and hash bindings).
86    pub revocation: &'a dyn RevocationView,
87    /// Soft-check inputs from transparency and time verification.
88    pub transparency: TransparencyInputs,
89    /// Time-freshness window.
90    pub freshness: FreshnessWindow,
91    /// Classification policy (scheme-defined).
92    pub classification: &'a dyn ClassificationPolicy,
93    /// Acceptance policy (verifier-defined).
94    pub acceptance: &'a AcceptancePolicy,
95}
96
97impl<'a> Pipeline<'a> {
98    /// A pipeline with the reference classification policy.
99    #[allow(clippy::too_many_arguments)]
100    pub fn new(
101        bundle: &'a TrustAnchorBundle,
102        graph: &'a TrustGraph,
103        registry: &'a Registry,
104        verifier: &'a dyn SignatureVerifier,
105        revocation: &'a dyn RevocationView,
106        transparency: TransparencyInputs,
107        acceptance: &'a AcceptancePolicy,
108    ) -> Self {
109        Self {
110            bundle,
111            graph,
112            registry,
113            verifier,
114            revocation,
115            transparency,
116            freshness: FreshnessWindow::default(),
117            classification: &ReferenceClassificationPolicy,
118            acceptance,
119        }
120    }
121
122    /// Override the classification policy.
123    pub fn with_classification(mut self, policy: &'a dyn ClassificationPolicy) -> Self {
124        self.classification = policy;
125        self
126    }
127
128    /// Override the freshness window.
129    pub fn with_freshness(mut self, window: FreshnessWindow) -> Self {
130        self.freshness = window;
131        self
132    }
133
134    /// Run the pipeline. Hard checks in order: bundle validity, format
135    /// validity + registry status, co-signature verification, chain
136    /// path-finding (scope narrowing per link), revocation status.
137    /// Soft checks accumulate: transparency, time anchor, multi-log
138    /// quorum, dimension coverage, root diversity.
139    ///
140    /// # Errors
141    ///
142    /// Returns the first hard failure encountered; the error is the
143    /// machine-readable reason, the caller short-circuits to the
144    /// rejected label.
145    pub fn run(
146        &self,
147        artifact: &TrustedArtifact,
148        now: DateTime<Utc>,
149    ) -> SignatifResult<VerificationOutcome> {
150        // Hard 1: anchor bundle validity.
151        self.bundle
152            .verify(now, self.verifier)
153            .map_err(|e| SignatifError::HardCheck(format!("bundle_validity: {e}")))?;
154
155        // Hard 2: format validity, version compatibility, registry
156        // status, self-description (canonical hash binding), and every
157        // co-signature independently.
158        let supported = crate::artifact::ArtifactVersion { major: 1, minor: 0 };
159        if !supported.accepts(&artifact.version) {
160            return Err(SignatifError::HardCheck(format!(
161                "format_version: artifact {} exceeds supported {}",
162                artifact.version, supported
163            )));
164        }
165        artifact
166            .verify_self(self.registry, self.verifier)
167            .map_err(|e| SignatifError::HardCheck(format!("signature_validity: {e}")))?;
168
169        // Hard 3: chain integrity — at least one path from a signer to
170        // an anchor for each distinct chain_ref, scope narrowing
171        // enforced per link by the graph walk.
172        let mut paths_found = 0usize;
173        let mut roots: Vec<String> = Vec::new();
174        for block in &artifact.co_signatures {
175            let node = self.graph.node(&block.signer_cert_ref).ok_or_else(|| {
176                SignatifError::HardCheck(format!(
177                    "chain_integrity: unknown signer {}",
178                    block.signer_cert_ref
179                ))
180            })?;
181            let paths = self
182                .graph
183                .find_paths(&node.id, self.bundle, self.verifier)
184                .map_err(|e| SignatifError::HardCheck(format!("chain_integrity: {e}")))?;
185            if paths.is_empty() {
186                return Err(SignatifError::HardCheck(format!(
187                    "chain_integrity: no path from {} to an anchor",
188                    node.id
189                )));
190            }
191            paths_found += paths.len();
192            for p in &paths {
193                if !roots.contains(&p.root.id) {
194                    roots.push(p.root.id.clone());
195                }
196            }
197        }
198
199        // Hard 4: scope conditions — every signer node's executable
200        // conditions are evaluated against the artifact and its chain
201        // (§11): failing conditions hard-fail regardless of signature
202        // validity.
203        for block in &artifact.co_signatures {
204            if let Some(node) = self.graph.node(&block.signer_cert_ref) {
205                let ctx = crate::conditions::ConditionContext::new(
206                    &artifact.payload,
207                    &artifact.artifact_id,
208                    &block.signer_cert_ref,
209                    block.dimension.as_str(),
210                    &block.timestamp.to_rfc3339(),
211                );
212                crate::conditions::evaluate_all(&node.scope.conditions, &ctx)
213                    .map_err(|e| SignatifError::HardCheck(format!("scope_conditions: {e}")))?;
214            }
215        }
216
217        // Hard 5: revocation of every authority on every valid path.
218        for block in &artifact.co_signatures {
219            let status = self
220                .revocation
221                .authority_status(&block.signer_cert_ref, now);
222            match status {
223                crate::revocation::RevocationStatus::Revoked => {
224                    return Err(SignatifError::HardCheck(format!(
225                        "revocation: signer {} is revoked",
226                        block.signer_cert_ref
227                    )));
228                }
229                crate::revocation::RevocationStatus::GraceDowngrade => {
230                    // soft: recorded as downgrade below
231                }
232                crate::revocation::RevocationStatus::Good => {}
233            }
234        }
235
236        // Soft checks: accumulate into the coverage report. Deprecated
237        // algorithms (§20) downgrade; retired already hard-failed in
238        // verify_self's registry check.
239        let mut downgrades = self.transparency.downgrades.clone();
240        for block in &artifact.co_signatures {
241            if self.registry.algorithms.status(&block.algorithm)
242                == Some(crate::registry::Status::Deprecated)
243            {
244                downgrades.push(format!("deprecated_algorithm:{}", block.algorithm));
245            }
246        }
247        if !self.transparency.artifact_included {
248            downgrades.push("transparency_missing".into());
249        }
250        if !self.transparency.time_anchored {
251            downgrades.push("time_anchor_absent".into());
252        }
253        if self.revocation.max_crl_age(now) > DEFAULT_GRACE_PERIOD {
254            downgrades.push("crl_stale".into());
255        }
256        // Freshness: the externally-attested time when a verified time
257        // authority attestation exists; otherwise the newest
258        // time-dimension block timestamp.
259        let freshness_source = self.transparency.time_attested_at.or_else(|| {
260            artifact
261                .co_signatures
262                .iter()
263                .filter(|b| b.dimension.as_str() == crate::registry::DimensionTag::TIME)
264                .map(|b| b.timestamp)
265                .max()
266        });
267        if let Some(newest) = freshness_source {
268            let age = now.signed_duration_since(newest);
269            if age > self.freshness.window + self.freshness.grace {
270                return Err(SignatifError::HardCheck(
271                    "time_freshness: time attestation outside window and grace".into(),
272                ));
273            }
274            if age > self.freshness.window {
275                downgrades.push("time_attestation_stale".into());
276            }
277        }
278
279        let report = CoverageReport {
280            hard_checks: HardCheckStatus::Pass,
281            transparency_included: self.transparency.artifact_included,
282            time_anchored: self.transparency.time_anchored,
283            dimensions_verified: artifact
284                .dimensions_verified()
285                .iter()
286                .map(|d| d.as_str().to_string())
287                .collect(),
288            dimension_count: artifact.dimensions_verified().len(),
289            independent_roots: roots.len(),
290            multi_log_quorum: self.transparency.multi_log_quorum,
291            paths_found,
292            downgrades,
293        };
294        let label = self.classification.classify(&report);
295        let acceptance = self.acceptance.decide(&label);
296        Ok(VerificationOutcome {
297            report,
298            label,
299            acceptance,
300        })
301    }
302}
303
304/// The scope of the signer on the (first) verified path — policy input
305/// for downstream decisions.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct SignerScopeSummary {
308    /// Signer node identifier.
309    pub signer: String,
310    /// The signer's scope.
311    pub scope: ScopeDimensions,
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::artifact::ArtifactVersion;
318    use crate::bundle::AnchorRoot;
319    use crate::graph::{AuthorityKind, AuthorityNode, DelegationEdge, Quorum as GraphQuorum};
320    use crate::registry::DimensionTag;
321    use crate::registry::Registry;
322    use crate::revocation::NoRevocations;
323    use ed25519_dalek::{Signer, SigningKey};
324
325    fn generate_key() -> ed25519_dalek::SigningKey {
326        use rand_core::RngCore;
327        let mut seed = [0u8; 32];
328        rand_core::OsRng.fill_bytes(&mut seed);
329        ed25519_dalek::SigningKey::from_bytes(&seed)
330    }
331
332    struct Ed25519Verifier;
333
334    impl SignatureVerifier for Ed25519Verifier {
335        fn verify(&self, pk: &[u8], msg: &[u8], sig: &[u8]) -> bool {
336            use ed25519_dalek::Signature;
337            use ed25519_dalek::Verifier;
338            let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pk.try_into().unwrap()) else {
339                return false;
340            };
341            let Ok(signature) = Signature::from_slice(sig) else {
342                return false;
343            };
344            vk.verify(msg, &signature).is_ok()
345        }
346    }
347
348    struct Fixture {
349        graph: TrustGraph,
350        bundle: TrustAnchorBundle,
351        registry: Registry,
352        artifact: TrustedArtifact,
353        end_sk: SigningKey,
354        root_sk: SigningKey,
355    }
356
357    fn build() -> Fixture {
358        let registry = Registry::with_initial_values();
359        let root_sk = generate_key();
360        let root = AuthorityNode {
361            id: "root".into(),
362            kind: AuthorityKind::Root,
363            public_key: root_sk.verifying_key().as_bytes().to_vec(),
364            quorum: Some(GraphQuorum::new(2, 3).unwrap()),
365            scope: crate::scope::ScopeDimensions::unconstrained(),
366        };
367        let end_sk = generate_key();
368        let end = AuthorityNode {
369            id: "end".into(),
370            kind: AuthorityKind::EndCertificate,
371            public_key: end_sk.verifying_key().as_bytes().to_vec(),
372            quorum: None,
373            scope: crate::scope::ScopeDimensions::unconstrained(),
374        };
375        let mut graph = TrustGraph::new();
376        graph.add_node(root.clone());
377        graph.add_node(end.clone());
378        graph
379            .add_delegation(DelegationEdge {
380                parent: "root".into(),
381                child: "end".into(),
382                signature: root_sk
383                    .sign(&end.binding_bytes().unwrap())
384                    .to_bytes()
385                    .to_vec(),
386            })
387            .unwrap();
388
389        let bundle = TrustAnchorBundle {
390            bundle_version: "2026.08".into(),
391            valid_from: Utc::now() - chrono::Duration::hours(1),
392            valid_until: Utc::now() + chrono::Duration::days(30),
393            roots: vec![AnchorRoot {
394                name: "root".into(),
395                aggregate_key: root.public_key.clone(),
396                fingerprint: "00".into(),
397                quorum: root.quorum,
398            }],
399            transparency_logs: vec![],
400            bundle_signature: vec![],
401            update_log: None,
402        };
403        // Bundle signature unused by pipeline? It verifies signatures —
404        // sign the bundle with the root key so hard check 1 passes.
405        let mut bundle = bundle;
406        let mut signed = bundle.clone();
407        signed.bundle_signature = Vec::new();
408        let msg = crate::jcs::canonicalize(&serde_json::to_value(&signed).unwrap()).unwrap();
409        bundle.bundle_signature = root_sk.sign(msg.as_bytes()).to_bytes().to_vec();
410
411        let mut artifact = TrustedArtifact::new(
412            ArtifactVersion { major: 1, minor: 0 },
413            "art-1",
414            serde_json::json!({"v": 1}),
415            None,
416        )
417        .unwrap();
418        artifact
419            .sign(
420                DimensionTag::data(),
421                "Ed25519",
422                "end",
423                end_sk.verifying_key().as_bytes().to_vec(),
424                "root",
425                &|m| end_sk.sign(m).to_bytes().to_vec(),
426                &registry,
427            )
428            .unwrap();
429
430        Fixture {
431            graph,
432            bundle,
433            registry,
434            artifact,
435            end_sk,
436            root_sk,
437        }
438    }
439
440    #[test]
441    fn full_pipeline_rejects_without_transparency_then_ladders_up() {
442        let f = build();
443        static NO_REVOCATIONS: NoRevocations = NoRevocations;
444        let accept_all =
445            AcceptancePolicy::accept(&["unverified", "basic", "verified", "attested", "certified"]);
446
447        let pipe = Pipeline::new(
448            &f.bundle,
449            &f.graph,
450            &f.registry,
451            &Ed25519Verifier,
452            &NO_REVOCATIONS,
453            TransparencyInputs::default(),
454            &accept_all,
455        );
456        let out = pipe.run(&f.artifact, Utc::now()).unwrap();
457        assert_eq!(out.label.0, "unverified");
458        assert_eq!(out.acceptance, Acceptance::Accept);
459
460        let with_transparency = pipe_with(&f, true, false, &accept_all);
461        let out = with_transparency.run(&f.artifact, Utc::now()).unwrap();
462        assert_eq!(out.label.0, "basic");
463
464        let with_time = pipe_with(&f, true, true, &accept_all);
465        let out = with_time.run(&f.artifact, Utc::now()).unwrap();
466        assert_eq!(out.label.0, "verified");
467    }
468
469    fn pipe_with<'p>(
470        f: &'p Fixture,
471        transparency: bool,
472        time: bool,
473        acceptance: &'p AcceptancePolicy,
474    ) -> Pipeline<'p> {
475        static NO_REVOCATIONS: NoRevocations = NoRevocations;
476        Pipeline::new(
477            &f.bundle,
478            &f.graph,
479            &f.registry,
480            &Ed25519Verifier,
481            &NO_REVOCATIONS,
482            TransparencyInputs {
483                artifact_included: transparency,
484                time_anchored: time,
485                time_attested_at: None,
486                multi_log_quorum: false,
487                downgrades: vec![],
488            },
489            acceptance,
490        )
491    }
492
493    #[test]
494    fn tampered_artifact_short_circuits_to_hard_failure() {
495        let f = build();
496        let mut broken = f.artifact.clone();
497        broken.payload["v"] = serde_json::json!(2);
498        static NO_REVOCATIONS: NoRevocations = NoRevocations;
499        let accept_all = AcceptancePolicy::accept(&["verified"]);
500        let pipe = Pipeline::new(
501            &f.bundle,
502            &f.graph,
503            &f.registry,
504            &Ed25519Verifier,
505            &NO_REVOCATIONS,
506            TransparencyInputs::default(),
507            &accept_all,
508        );
509        let err = pipe.run(&broken, Utc::now()).unwrap_err();
510        assert!(err.to_string().contains("signature_validity"));
511    }
512
513    #[test]
514    fn scope_conditions_hard_fail_when_unmet() {
515        let f = build();
516        static NO_REVOCATIONS: NoRevocations = NoRevocations;
517        let accept_all = AcceptancePolicy::accept(&["verified"]);
518        // The end node carries an executable condition on the payload.
519        // The conditions are part of the node binding, so the root's
520        // delegation credential must cover them (four-layer scope
521        // enforcement, layers 1-2).
522        let mut end = f.graph.node("end").unwrap().clone();
523        end.scope.conditions = vec![serde_json::json!({
524            ">=": [ {"var": "payload.v"}, 5 ]
525        })];
526        let root_node = f.graph.node("root").unwrap().clone();
527        use ed25519_dalek::Signer as _;
528        let mut graph = TrustGraph::new();
529        graph.add_node(root_node);
530        graph.add_node(end.clone());
531        graph
532            .add_delegation(DelegationEdge {
533                parent: "root".into(),
534                child: "end".into(),
535                signature: f
536                    .root_sk
537                    .sign(&end.binding_bytes().unwrap())
538                    .to_bytes()
539                    .to_vec(),
540            })
541            .unwrap();
542        let pipe = Pipeline::new(
543            &f.bundle,
544            &graph,
545            &f.registry,
546            &Ed25519Verifier,
547            &NO_REVOCATIONS,
548            TransparencyInputs::default(),
549            &accept_all,
550        );
551        // payload.v == 1 -> condition unmet -> hard failure even though
552        // every signature and the chain verify. The positive path is
553        // exercised in the conditions module tests.
554        let err = pipe.run(&f.artifact, Utc::now()).unwrap_err();
555        assert!(err.to_string().contains("scope_conditions"), "got {err}");
556    }
557
558    #[test]
559    fn deprecated_algorithm_downgrades_and_caps_label() {
560        let f = build();
561        static NO_REVOCATIONS: NoRevocations = NoRevocations;
562        let mut agile = f.registry.clone();
563        agile
564            .algorithms
565            .set_status("Ed25519", crate::registry::Status::Deprecated)
566            .unwrap();
567        let accept_all =
568            AcceptancePolicy::accept(&["unverified", "basic", "verified", "attested", "certified"]);
569        // Full soft coverage: without deprecation this reaches
570        // "attested" (data + person? only data here -> "verified");
571        // with the deprecated algorithm the label stays "verified"
572        // but the downgrade is recorded. Person present would cap too.
573        let pipe = Pipeline::new(
574            &f.bundle,
575            &f.graph,
576            &agile,
577            &Ed25519Verifier,
578            &NO_REVOCATIONS,
579            TransparencyInputs {
580                artifact_included: true,
581                time_anchored: true,
582                time_attested_at: None,
583                multi_log_quorum: false,
584                downgrades: vec![],
585            },
586            &accept_all,
587        );
588        let out = pipe.run(&f.artifact, Utc::now()).unwrap();
589        assert!(
590            out.report
591                .downgrades
592                .iter()
593                .any(|d| d == "deprecated_algorithm:Ed25519"),
594            "downgrades: {:?}",
595            out.report.downgrades
596        );
597
598        // With a person dimension the cap bites: attested -> verified.
599        let person = ed25519_dalek::SigningKey::from_bytes(&{
600            use rand_core::RngCore as _;
601            let mut seed = [0u8; 32];
602            rand_core::OsRng.fill_bytes(&mut seed);
603            seed
604        });
605        use ed25519_dalek::Signer as _;
606        let mut rich = f.artifact.clone();
607        let input = rich.cosign_input(&DimensionTag::person());
608        rich.co_signatures.push(crate::artifact::CoSignatureBlock {
609            dimension: DimensionTag::person(),
610            algorithm: "Ed25519".into(),
611            signer_cert_ref: "end".into(),
612            signer_pubkey: person.verifying_key().as_bytes().to_vec(),
613            chain_ref: "root".into(),
614            signature: person.sign(&input).to_bytes().to_vec(),
615            timestamp: Utc::now(),
616        });
617        let out = pipe.run(&rich, Utc::now()).unwrap();
618        assert_eq!(out.label.0, "verified", "deprecated caps attested");
619
620        // Retired hard-fails outright.
621        let mut retired = agile.clone();
622        retired
623            .algorithms
624            .set_status("Ed25519", crate::registry::Status::Retired)
625            .unwrap();
626        let pipe = Pipeline::new(
627            &f.bundle,
628            &f.graph,
629            &retired,
630            &Ed25519Verifier,
631            &NO_REVOCATIONS,
632            TransparencyInputs::default(),
633            &accept_all,
634        );
635        assert!(pipe.run(&f.artifact, Utc::now()).is_err());
636    }
637
638    #[test]
639    fn stale_time_attestation_beyond_grace_is_hard_failure() {
640        let f = build();
641        static NO_REVOCATIONS: NoRevocations = NoRevocations;
642        let accept_all = AcceptancePolicy::accept(&["verified"]);
643        let pipe = Pipeline::new(
644            &f.bundle,
645            &f.graph,
646            &f.registry,
647            &Ed25519Verifier,
648            &NO_REVOCATIONS,
649            TransparencyInputs {
650                artifact_included: true,
651                time_anchored: true,
652                time_attested_at: None,
653                multi_log_quorum: false,
654                downgrades: vec![],
655            },
656            &accept_all,
657        )
658        .with_freshness(FreshnessWindow {
659            window: chrono::Duration::seconds(60),
660            grace: chrono::Duration::seconds(60),
661        });
662        // A properly signed TIME block whose timestamp is beyond
663        // window + grace: hard failure.
664        let mut old = f.artifact.clone();
665        let input = old.cosign_input(&DimensionTag::time());
666        use ed25519_dalek::Signer as _;
667        old.co_signatures.push(crate::artifact::CoSignatureBlock {
668            dimension: DimensionTag::time(),
669            algorithm: "Ed25519".into(),
670            signer_cert_ref: "end".into(),
671            signer_pubkey: f.end_sk.verifying_key().as_bytes().to_vec(),
672            chain_ref: "root".into(),
673            signature: f.end_sk.sign(&input).to_bytes().to_vec(),
674            timestamp: Utc::now() - chrono::Duration::hours(2),
675        });
676        let err = pipe.run(&old, Utc::now()).unwrap_err();
677        assert!(err.to_string().contains("time_freshness"), "got {err}");
678    }
679}