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);
126shared_non_empty_id!(LibraryArtifactId);
127
128/// Detached ed25519 signature of the canonicalised manifest body.
129///
130/// Wire format is base64 standard with padding; the type itself is a
131/// passthrough wrapper (no validation) — invalid base64 is rejected
132/// at verification time, not at parse time, so unsigned manifests
133/// can still round-trip.
134#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[serde(transparent)]
136pub struct ManifestSignature(String);
137
138impl ManifestSignature {
139    pub fn new(value: impl Into<String>) -> Self {
140        Self(value.into())
141    }
142
143    pub fn as_str(&self) -> &str {
144        &self.0
145    }
146
147    pub fn into_inner(self) -> String {
148        self.0
149    }
150}
151
152impl fmt::Display for ManifestSignature {
153    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154        write!(f, "{}", self.0)
155    }
156}
157
158impl AsRef<str> for ManifestSignature {
159    fn as_ref(&self) -> &str {
160        &self.0
161    }
162}
163
164impl From<String> for ManifestSignature {
165    fn from(s: String) -> Self {
166        Self(s)
167    }
168}
169
170impl From<&str> for ManifestSignature {
171    fn from(s: &str) -> Self {
172        Self(s.to_owned())
173    }
174}
175
176/// Lowercase hex SHA-256 digest. Validated as exactly 64 hex chars
177/// `[0-9a-f]` so manifest comparisons are normalised — any
178/// upper-case or shorter input is rejected at deserialise time.
179#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
180#[serde(transparent)]
181pub struct Sha256Digest(String);
182
183impl Sha256Digest {
184    pub fn try_new(value: impl Into<String>) -> Result<Self, IdValidationError> {
185        let value = value.into();
186        if value.len() != 64 {
187            return Err(IdValidationError::invalid(
188                "Sha256Digest",
189                format!("expected 64 hex chars, got {}", value.len()),
190            ));
191        }
192        if !value
193            .bytes()
194            .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
195        {
196            return Err(IdValidationError::invalid(
197                "Sha256Digest",
198                "expected lowercase hex characters",
199            ));
200        }
201        Ok(Self(value))
202    }
203
204    pub fn as_str(&self) -> &str {
205        &self.0
206    }
207
208    pub fn into_inner(self) -> String {
209        self.0
210    }
211}
212
213impl fmt::Display for Sha256Digest {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(f, "{}", self.0)
216    }
217}
218
219impl AsRef<str> for Sha256Digest {
220    fn as_ref(&self) -> &str {
221        &self.0
222    }
223}
224
225impl From<Sha256Digest> for String {
226    fn from(id: Sha256Digest) -> Self {
227        id.0
228    }
229}
230
231impl TryFrom<String> for Sha256Digest {
232    type Error = IdValidationError;
233    fn try_from(s: String) -> Result<Self, Self::Error> {
234        Self::try_new(s)
235    }
236}
237
238impl TryFrom<&str> for Sha256Digest {
239    type Error = IdValidationError;
240    fn try_from(s: &str) -> Result<Self, Self::Error> {
241        Self::try_new(s)
242    }
243}
244
245impl std::str::FromStr for Sha256Digest {
246    type Err = IdValidationError;
247    fn from_str(s: &str) -> Result<Self, Self::Err> {
248        Self::try_new(s)
249    }
250}
251
252impl<'de> Deserialize<'de> for Sha256Digest {
253    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
254    where
255        D: serde::Deserializer<'de>,
256    {
257        let s = String::deserialize(deserializer)?;
258        Self::try_new(s).map_err(serde::de::Error::custom)
259    }
260}
261
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
263#[serde(rename_all = "lowercase")]
264pub enum ToolPolicy {
265    Allow,
266    Deny,
267    Prompt,
268}
269
270impl fmt::Display for ToolPolicy {
271    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272        match self {
273            Self::Allow => f.write_str("allow"),
274            Self::Deny => f.write_str("deny"),
275            Self::Prompt => f.write_str("prompt"),
276        }
277    }
278}