Skip to main content

pic_continuity/
proposal.rs

1/*
2 * Copyright Nitro Agility S.r.l.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17//! The Initial Continuity Proposal (Profile 0.2).
18//!
19//! A self-describing JSON protocol object used before PIC continuity
20//! exists: it supplies initialization material — in current Profile 0.2,
21//! the execution contract. Its `type` member identifies the proposal
22//! definition/schema ([`crate::PROPOSAL_TYPE_CONTINUITY_INITIAL`]) and
23//! determines how the remaining payload is interpreted and validated.
24//!
25//! On the wire, the `continuity_proposal` token-exchange parameter value is
26//! produced by serializing the proposal object as compact UTF-8 JSON and
27//! applying unpadded Base64url encoding. The proposal is not a JWT or COSE
28//! artifact.
29//!
30//! Current Profile 0.2 PIC-to-PIC advancement omits `continuity_proposal`;
31//! the proposal is used only at initialization (e.g. OAuth-to-PIC exchange).
32
33use crate::authority::AuthorityValue;
34use crate::error::{ContinuityError, RejectReason};
35use base64::Engine;
36use base64::engine::general_purpose::URL_SAFE_NO_PAD;
37use serde::{Deserialize, Serialize};
38use std::collections::BTreeMap;
39
40/// The Initial Continuity Proposal JSON object:
41/// `{ "type": ..., "executionContract": ... }`.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct InitialContinuityProposal {
44    /// Proposal definition/schema identifier; must equal
45    /// [`crate::PROPOSAL_TYPE_CONTINUITY_INITIAL`].
46    #[serde(rename = "type")]
47    pub proposal_type: String,
48    /// The execution contract supplied by the caller. It constrains
49    /// execution; it does not grant authority. At least one attribute with
50    /// a valid value is required.
51    #[serde(rename = "executionContract")]
52    pub execution_contract: BTreeMap<String, AuthorityValue>,
53}
54
55impl InitialContinuityProposal {
56    /// An Initial Continuity Proposal carrying `execution_contract`.
57    pub fn new(execution_contract: BTreeMap<String, AuthorityValue>) -> Self {
58        Self {
59            proposal_type: crate::PROPOSAL_TYPE_CONTINUITY_INITIAL.to_string(),
60            execution_contract,
61        }
62    }
63
64    /// Validates the proposal: the `type` member identifies the Initial
65    /// Continuity Proposal definition, and the execution contract carries
66    /// at least one attribute whose value is a non-empty string or a
67    /// non-empty array of non-empty strings.
68    pub fn validate(&self) -> Result<(), RejectReason> {
69        if self.proposal_type != crate::PROPOSAL_TYPE_CONTINUITY_INITIAL {
70            return Err(RejectReason::Malformed(format!(
71                "unknown continuity proposal type: {}",
72                self.proposal_type
73            )));
74        }
75        if self.execution_contract.is_empty() {
76            return Err(RejectReason::EmptyExecutionContract);
77        }
78        for (k, v) in &self.execution_contract {
79            v.validate(k)?;
80        }
81        Ok(())
82    }
83
84    /// Encodes the proposal as the `continuity_proposal` parameter value:
85    /// compact UTF-8 JSON, then unpadded Base64url.
86    pub fn to_continuity_proposal(&self) -> Result<String, ContinuityError> {
87        let json = serde_json::to_string(self).map_err(|e| ContinuityError::Json(e.to_string()))?;
88        Ok(URL_SAFE_NO_PAD.encode(json.as_bytes()))
89    }
90
91    /// Decodes and validates a `continuity_proposal` parameter value.
92    pub fn from_continuity_proposal(value: &str) -> Result<Self, ContinuityError> {
93        let bytes = URL_SAFE_NO_PAD.decode(value).map_err(|e| {
94            ContinuityError::Json(format!("continuity_proposal is not valid base64url: {e}"))
95        })?;
96        let proposal: Self = serde_json::from_slice(&bytes)
97            .map_err(|e| ContinuityError::Json(format!("invalid proposal JSON: {e}")))?;
98        proposal.validate()?;
99        Ok(proposal)
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    /// The walkthrough proposal: corporation ACME, department
108    /// sensitive-documents.
109    fn walkthrough_proposal() -> InitialContinuityProposal {
110        let mut contract = BTreeMap::new();
111        contract.insert("corporation".into(), AuthorityValue::One("ACME".into()));
112        contract.insert(
113            "department".into(),
114            AuthorityValue::One("sensitive-documents".into()),
115        );
116        InitialContinuityProposal::new(contract)
117    }
118
119    #[test]
120    fn json_shape_matches_reference() {
121        let proposal = walkthrough_proposal();
122        let json = serde_json::to_value(&proposal).unwrap();
123        assert_eq!(
124            json["type"],
125            "https://pic-protocol.org/definitions/proposal-types/continuity-initial"
126        );
127        assert_eq!(json["executionContract"]["corporation"], "ACME");
128        assert_eq!(
129            json["executionContract"]["department"],
130            "sensitive-documents"
131        );
132    }
133
134    #[test]
135    fn continuity_proposal_roundtrip() {
136        let proposal = walkthrough_proposal();
137        let encoded = proposal.to_continuity_proposal().unwrap();
138        // Unpadded Base64url: no '=', '+', or '/'.
139        assert!(!encoded.contains('=') && !encoded.contains('+') && !encoded.contains('/'));
140        let decoded = InitialContinuityProposal::from_continuity_proposal(&encoded).unwrap();
141        assert_eq!(decoded, proposal);
142    }
143
144    #[test]
145    fn rejects_invalid_contracts() {
146        // {}
147        let empty = InitialContinuityProposal::new(BTreeMap::new());
148        assert_eq!(
149            empty.validate().unwrap_err(),
150            RejectReason::EmptyExecutionContract
151        );
152
153        // { "corporation": "" }
154        let mut contract = BTreeMap::new();
155        contract.insert("corporation".into(), AuthorityValue::One("".into()));
156        assert!(matches!(
157            InitialContinuityProposal::new(contract)
158                .validate()
159                .unwrap_err(),
160            RejectReason::InvalidAuthorityValue(_)
161        ));
162
163        // { "departments": [] }
164        let mut contract = BTreeMap::new();
165        contract.insert("departments".into(), AuthorityValue::Many(vec![]));
166        assert!(matches!(
167            InitialContinuityProposal::new(contract)
168                .validate()
169                .unwrap_err(),
170            RejectReason::InvalidAuthorityValue(_)
171        ));
172
173        // wrong type URI
174        let mut wrong = walkthrough_proposal();
175        wrong.proposal_type = "https://example.com/other".into();
176        assert!(matches!(
177            wrong.validate().unwrap_err(),
178            RejectReason::Malformed(_)
179        ));
180    }
181
182    #[test]
183    fn rejects_unsupported_json_value_types() {
184        // Numbers, booleans, objects, and null are not valid contract values.
185        let json = r#"{
186            "type": "https://pic-protocol.org/definitions/proposal-types/continuity-initial",
187            "executionContract": { "retryCount": 3 }
188        }"#;
189        let encoded = URL_SAFE_NO_PAD.encode(json.as_bytes());
190        assert!(InitialContinuityProposal::from_continuity_proposal(&encoded).is_err());
191    }
192}