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