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