Skip to main content

greentic_types/
qa.rs

1//! Types used by QA setup contracts.
2use alloc::{string::String, vec::Vec};
3
4use ciborium::value::Value;
5use serde::{Deserialize, Serialize};
6use thiserror::Error;
7
8use crate::{cbor::canonical, cbor_bytes::CborBytes, schema_id::SchemaSource};
9
10/// Where the QA specification lives.
11#[derive(Clone, Debug, PartialEq, Eq)]
12#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
13pub enum QaSpecSource {
14    /// Inline CBOR specification bytes.
15    InlineCbor(CborBytes),
16    #[cfg(feature = "json-compat")]
17    /// Inline JSON spec for transition/debug tooling.
18    InlineJson(String),
19    /// Schema hosted at a remote URI.
20    RefUri(String),
21    /// Schema stored in another pack path.
22    RefPackPath(String),
23}
24
25/// Example answers submitted by a pack author.
26#[derive(Clone, Debug, PartialEq, Eq)]
27#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
28pub struct ExampleAnswers {
29    /// Friendly title for the example answers.
30    pub title: String,
31    /// Canonical CBOR payload.
32    pub answers_cbor: CborBytes,
33    /// Optional annotations or hints.
34    pub notes: Option<String>,
35}
36
37/// Outputs produced by the setup flow.
38#[derive(Clone, Debug, PartialEq, Eq)]
39#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
40pub enum SetupOutput {
41    /// Only configuration data is emitted.
42    ConfigOnly,
43    /// Template-driven scaffold output.
44    TemplateScaffold {
45        /// Reference to the scaffold template.
46        template_ref: String,
47        /// Layout/slot description produced by the scaffold.
48        output_layout: String,
49    },
50}
51
52/// QA setup contract (optional convenience wrapper).
53#[derive(Clone, Debug, PartialEq, Eq)]
54#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
55pub struct SetupContract {
56    /// Reference to the QA spec.
57    pub qa_spec: QaSpecSource,
58    /// Optional schema describing answers.
59    pub answers_schema: Option<SchemaSource>,
60    /// Example answer blobs for documentation.
61    pub examples: Vec<ExampleAnswers>,
62    /// Declared outputs for the setup run.
63    pub outputs: Vec<SetupOutput>,
64}
65
66/// Canonical enforcement policy used by `validate_answers`.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum CanonicalPolicy {
69    /// Skip canonical enforcement.
70    Off,
71    /// Treat non-canonical CBOR as errors.
72    RequireCanonical,
73    /// Re-encode answers into canonical form.
74    Canonicalize,
75}
76
77/// Errors produced during QA answer validation.
78#[derive(Debug, Error)]
79pub enum ValidateAnswersError {
80    /// Failed to decode the answers payload.
81    #[error("CBOR decode failed: {0}")]
82    Decode(String),
83    /// Answers must be represented as a CBOR map/object.
84    #[error("answers must be a CBOR map/object")]
85    NotMap,
86    /// Canonicalization check failed.
87    #[error(transparent)]
88    Canonical(#[from] canonical::CanonicalError),
89}
90
91/// MVP validator that ensures answer CBOR is a map and optionally canonical.
92pub fn validate_answers(
93    schema: &SchemaSource,
94    answers_cbor: &CborBytes,
95    policy: CanonicalPolicy,
96) -> Result<CborBytes, ValidateAnswersError> {
97    let _ = schema;
98    let value: Value = ciborium::de::from_reader(answers_cbor.as_slice())
99        .map_err(|err| ValidateAnswersError::Decode(err.to_string()))?;
100
101    if !matches!(value, Value::Map(_)) {
102        return Err(ValidateAnswersError::NotMap);
103    }
104
105    match policy {
106        CanonicalPolicy::Off => Ok(answers_cbor.clone()),
107        CanonicalPolicy::RequireCanonical => {
108            answers_cbor.ensure_canonical()?;
109            Ok(answers_cbor.clone())
110        }
111        CanonicalPolicy::Canonicalize => {
112            let canonical_bytes = canonical::canonicalize(answers_cbor.as_slice())?;
113            Ok(CborBytes(canonical_bytes))
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use alloc::collections::BTreeMap;
122
123    fn schema_bytes() -> Vec<u8> {
124        let mut map = BTreeMap::new();
125        map.insert("key", "value");
126        match canonical::to_canonical_cbor(&map) {
127            Ok(bytes) => bytes,
128            Err(err) => panic!("schema canonicalization failed: {err:?}"),
129        }
130    }
131
132    fn schema_blob() -> CborBytes {
133        CborBytes(schema_bytes())
134    }
135
136    #[test]
137    fn validate_accepts_map_off_policy() {
138        let bytes = schema_blob();
139        let source = SchemaSource::InlineCbor(bytes.clone());
140        let result = match validate_answers(&source, &bytes, CanonicalPolicy::Off) {
141            Ok(value) => value,
142            Err(err) => panic!("validation failed: {err:?}"),
143        };
144        assert_eq!(result.as_slice(), bytes.as_slice());
145    }
146
147    #[test]
148    fn validate_rejects_non_map() {
149        let bytes = match canonical::to_canonical_cbor(&"string") {
150            Ok(value) => value,
151            Err(err) => panic!("canonicalize string failed: {err:?}"),
152        };
153        let source = SchemaSource::InlineCbor(CborBytes(bytes.clone()));
154        assert!(matches!(
155            validate_answers(&source, &CborBytes(bytes), CanonicalPolicy::Off),
156            Err(ValidateAnswersError::NotMap)
157        ));
158    }
159
160    #[test]
161    fn canonicalize_policy_rewrites_indefinite_map() {
162        let indefinite = vec![0xBF, 0x61, b'a', 0x01, 0xFF];
163        let source = SchemaSource::InlineCbor(CborBytes::new(indefinite.clone()));
164        let canonical_bytes = match validate_answers(
165            &source,
166            &CborBytes(indefinite),
167            CanonicalPolicy::Canonicalize,
168        ) {
169            Ok(bytes) => bytes,
170            Err(err) => panic!("validation failed: {err:?}"),
171        };
172        if let Err(err) = canonical::ensure_canonical(canonical_bytes.as_slice()) {
173            panic!("ensure canonical failed: {err:?}");
174        }
175    }
176
177    #[test]
178    fn require_canonical_rejects_indefinite() {
179        let indefinite = vec![0xBF, 0x61, b'a', 0x01, 0xFF];
180        let source = SchemaSource::InlineCbor(CborBytes::new(indefinite.clone()));
181        assert!(matches!(
182            validate_answers(
183                &source,
184                &CborBytes(indefinite),
185                CanonicalPolicy::RequireCanonical
186            ),
187            Err(ValidateAnswersError::Canonical(_))
188        ));
189    }
190}