Skip to main content

systemprompt_models/bridge/
ids.rs

1//! Typed identifiers for bridge manifest wire fields.
2//!
3//! Each newtype is `#[serde(transparent)]` so it serialises to and
4//! from a plain JSON string — the typing is purely a Rust-side
5//! invariant. `non_empty` IDs reject the empty string at deserialise
6//! time; [`Sha256Digest`] additionally enforces 64 lowercase hex
7//! characters; [`ManifestSignature`] is a passthrough wrapper for the
8//! base64-encoded detached ed25519 signature carried alongside every
9//! manifest.
10//!
11//! These IDs are defined here (rather than in `systemprompt-identifiers`)
12//! because they are bridge-protocol-scoped: they appear only inside
13//! `/v1/bridge/*` payloads. They share the same shape as the broader
14//! identifier crate but a parallel definition keeps the bridge wire
15//! contract self-contained.
16
17use std::fmt;
18
19use serde::{Deserialize, Serialize};
20
21#[derive(Debug, thiserror::Error)]
22pub enum IdValidationError {
23    #[error("{type_name} cannot be empty")]
24    Empty { type_name: &'static str },
25    #[error("{type_name} is invalid: {reason}")]
26    Invalid {
27        type_name: &'static str,
28        reason: String,
29    },
30}
31
32impl IdValidationError {
33    #[must_use]
34    pub const fn empty(type_name: &'static str) -> Self {
35        Self::Empty { type_name }
36    }
37
38    pub fn invalid(type_name: &'static str, reason: impl Into<String>) -> Self {
39        Self::Invalid {
40            type_name,
41            reason: reason.into(),
42        }
43    }
44}
45
46macro_rules! shared_non_empty_id {
47    ($name:ident) => {
48        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize)]
49        #[serde(transparent)]
50        pub struct $name(String);
51
52        impl $name {
53            pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
54                let value = value.into();
55                if value.is_empty() {
56                    return Err(IdValidationError::empty(stringify!($name)));
57                }
58                Ok(Self(value))
59            }
60
61            pub fn as_str(&self) -> &str {
62                &self.0
63            }
64
65            pub fn into_inner(self) -> String {
66                self.0
67            }
68        }
69
70        impl fmt::Display for $name {
71            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72                write!(f, "{}", self.0)
73            }
74        }
75
76        impl AsRef<str> for $name {
77            fn as_ref(&self) -> &str {
78                &self.0
79            }
80        }
81
82        impl From<$name> for String {
83            fn from(id: $name) -> Self {
84                id.0
85            }
86        }
87
88        impl TryFrom<String> for $name {
89            type Error = IdValidationError;
90            fn try_from(s: String) -> Result<Self, Self::Error> {
91                Self::try_new(s)
92            }
93        }
94
95        impl TryFrom<&str> for $name {
96            type Error = IdValidationError;
97            fn try_from(s: &str) -> Result<Self, Self::Error> {
98                Self::try_new(s)
99            }
100        }
101
102        impl std::str::FromStr for $name {
103            type Err = IdValidationError;
104            fn from_str(s: &str) -> Result<Self, Self::Err> {
105                Self::try_new(s)
106            }
107        }
108
109        impl<'de> serde::Deserialize<'de> for $name {
110            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
111            where
112                D: serde::Deserializer<'de>,
113            {
114                let s = String::deserialize(deserializer)?;
115                Self::try_new(s).map_err(serde::de::Error::custom)
116            }
117        }
118    };
119}
120
121shared_non_empty_id!(PluginId);
122shared_non_empty_id!(SkillId);
123shared_non_empty_id!(SkillName);
124shared_non_empty_id!(ManagedMcpServerName);
125shared_non_empty_id!(ToolName);
126
127/// Detached ed25519 signature of the canonicalised manifest body.
128///
129/// Wire format is base64 standard with padding; the type itself is a
130/// passthrough wrapper (no validation) — invalid base64 is rejected
131/// at verification time, not at parse time, so unsigned manifests
132/// can still round-trip.
133#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
134#[serde(transparent)]
135pub struct ManifestSignature(String);
136
137impl ManifestSignature {
138    pub fn new(value: impl Into<String>) -> Self {
139        Self(value.into())
140    }
141
142    pub fn as_str(&self) -> &str {
143        &self.0
144    }
145
146    pub fn into_inner(self) -> String {
147        self.0
148    }
149}
150
151impl fmt::Display for ManifestSignature {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        write!(f, "{}", self.0)
154    }
155}
156
157impl AsRef<str> for ManifestSignature {
158    fn as_ref(&self) -> &str {
159        &self.0
160    }
161}
162
163impl From<String> for ManifestSignature {
164    fn from(s: String) -> Self {
165        Self(s)
166    }
167}
168
169impl From<&str> for ManifestSignature {
170    fn from(s: &str) -> Self {
171        Self(s.to_string())
172    }
173}
174
175/// Lowercase hex SHA-256 digest. Validated as exactly 64 hex chars
176/// `[0-9a-f]` so manifest comparisons are normalised — any
177/// upper-case or shorter input is rejected at deserialise time.
178#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
179#[serde(transparent)]
180pub struct Sha256Digest(String);
181
182impl Sha256Digest {
183    pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
184        let value = value.into();
185        if value.len() != 64 {
186            return Err(IdValidationError::invalid(
187                "Sha256Digest",
188                format!("expected 64 hex chars, got {}", value.len()),
189            ));
190        }
191        if !value
192            .bytes()
193            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
194        {
195            return Err(IdValidationError::invalid(
196                "Sha256Digest",
197                "expected lowercase hex characters",
198            ));
199        }
200        Ok(Self(value))
201    }
202
203    pub fn as_str(&self) -> &str {
204        &self.0
205    }
206
207    pub fn into_inner(self) -> String {
208        self.0
209    }
210}
211
212impl fmt::Display for Sha256Digest {
213    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
214        write!(f, "{}", self.0)
215    }
216}
217
218impl AsRef<str> for Sha256Digest {
219    fn as_ref(&self) -> &str {
220        &self.0
221    }
222}
223
224impl From<Sha256Digest> for String {
225    fn from(id: Sha256Digest) -> Self {
226        id.0
227    }
228}
229
230impl TryFrom<String> for Sha256Digest {
231    type Error = IdValidationError;
232    fn try_from(s: String) -> Result<Self, Self::Error> {
233        Self::try_new(s)
234    }
235}
236
237impl TryFrom<&str> for Sha256Digest {
238    type Error = IdValidationError;
239    fn try_from(s: &str) -> Result<Self, Self::Error> {
240        Self::try_new(s)
241    }
242}
243
244impl std::str::FromStr for Sha256Digest {
245    type Err = IdValidationError;
246    fn from_str(s: &str) -> Result<Self, Self::Err> {
247        Self::try_new(s)
248    }
249}
250
251impl<'de> Deserialize<'de> for Sha256Digest {
252    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
253    where
254        D: serde::Deserializer<'de>,
255    {
256        let s = String::deserialize(deserializer)?;
257        Self::try_new(s).map_err(serde::de::Error::custom)
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
262#[serde(rename_all = "lowercase")]
263pub enum ToolPolicy {
264    Allow,
265    Deny,
266    Prompt,
267}
268
269impl fmt::Display for ToolPolicy {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        match self {
272            Self::Allow => f.write_str("allow"),
273            Self::Deny => f.write_str("deny"),
274            Self::Prompt => f.write_str("prompt"),
275        }
276    }
277}