1use std::error::Error;
4use std::fmt;
5
6use crate::limits::{
7 MAX_APPROVAL_ID_BYTES, MAX_APPROVAL_REFERENCE_BYTES, MAX_AUDIENCE_BYTES,
8 MAX_PROVENANCE_REASON_BYTES, MAX_SOURCE_COMMIT_BYTES, MAX_SOURCE_REPOSITORY_BYTES,
9};
10use crate::{ActorId, AgentSessionId, TenantScope};
11
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14pub enum BoundedTextError {
15 Empty,
17 TooLong {
19 maximum: usize,
21 actual: usize,
23 },
24 InvalidCharacter {
26 index: usize,
28 },
29}
30
31impl fmt::Display for BoundedTextError {
32 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
33 match self {
34 Self::Empty => formatter.write_str("value must not be empty"),
35 Self::TooLong { maximum, actual } => write!(
36 formatter,
37 "value is {actual} bytes but the maximum is {maximum} bytes"
38 ),
39 Self::InvalidCharacter { index } => {
40 write!(formatter, "value has an invalid character at byte {index}")
41 }
42 }
43 }
44}
45
46impl Error for BoundedTextError {}
47
48fn validate_bounded_utf8(value: &str, maximum: usize) -> Result<(), BoundedTextError> {
49 if value.is_empty() {
50 return Err(BoundedTextError::Empty);
51 }
52 if value.len() > maximum {
53 return Err(BoundedTextError::TooLong {
54 maximum,
55 actual: value.len(),
56 });
57 }
58 Ok(())
59}
60
61fn validate_bounded_visible_ascii(value: &str, maximum: usize) -> Result<(), BoundedTextError> {
62 validate_bounded_utf8(value, maximum)?;
63 if let Some(index) = value
64 .bytes()
65 .position(|byte| !(0x21..=0x7e).contains(&byte))
66 {
67 return Err(BoundedTextError::InvalidCharacter { index });
68 }
69 Ok(())
70}
71
72macro_rules! bounded_text_type {
73 ($(#[$meta:meta])* $name:ident, $maximum:ident, $validator:ident) => {
74 $(#[$meta])*
75 #[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
76 pub struct $name(String);
77
78 crate::inspect_owned_capacity_tuple_v1!($name, String);
79
80 impl $name {
81 pub fn new(value: impl Into<String>) -> Result<Self, BoundedTextError> {
83 let value = value.into();
84 $validator(&value, $maximum)?;
85 Ok(Self(value))
86 }
87
88 #[must_use]
90 pub fn as_str(&self) -> &str {
91 &self.0
92 }
93
94 #[must_use]
96 pub fn as_bytes(&self) -> &[u8] {
97 self.0.as_bytes()
98 }
99 }
100
101 impl fmt::Debug for $name {
102 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103 formatter.write_str(concat!(stringify!($name), "([REDACTED])"))
104 }
105 }
106 };
107}
108
109bounded_text_type!(
110 Audience,
112 MAX_AUDIENCE_BYTES,
113 validate_bounded_visible_ascii
114);
115
116bounded_text_type!(
117 SourceRepository,
119 MAX_SOURCE_REPOSITORY_BYTES,
120 validate_bounded_utf8
121);
122
123bounded_text_type!(
124 SourceCommit,
126 MAX_SOURCE_COMMIT_BYTES,
127 validate_bounded_visible_ascii
128);
129
130bounded_text_type!(
131 ProvenanceReason,
133 MAX_PROVENANCE_REASON_BYTES,
134 validate_bounded_utf8
135);
136
137bounded_text_type!(
138 ApprovalReference,
140 MAX_APPROVAL_REFERENCE_BYTES,
141 validate_bounded_visible_ascii
142);
143
144bounded_text_type!(
145 ApprovalId,
147 MAX_APPROVAL_ID_BYTES,
148 validate_bounded_visible_ascii
149);
150
151#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
153pub enum ActorKind {
154 Human,
156 Agent,
158 Service,
160}
161
162impl ActorKind {
163 pub const ALL: [Self; 3] = [Self::Human, Self::Agent, Self::Service];
165
166 #[must_use]
168 pub const fn tag(self) -> u8 {
169 match self {
170 Self::Human => 0x01,
171 Self::Agent => 0x02,
172 Self::Service => 0x03,
173 }
174 }
175
176 #[must_use]
178 pub const fn from_tag(tag: u8) -> Option<Self> {
179 match tag {
180 0x01 => Some(Self::Human),
181 0x02 => Some(Self::Agent),
182 0x03 => Some(Self::Service),
183 _ => None,
184 }
185 }
186}
187
188#[derive(Clone, Eq, Hash, Ord, PartialEq, PartialOrd)]
190pub struct AdmittedActorContext {
191 principal_id: ActorId,
192 actor_kind: ActorKind,
193 tenant_scope: TenantScope,
194 agent_session_id: Option<AgentSessionId>,
195}
196
197impl AdmittedActorContext {
198 #[must_use]
200 pub const fn new(
201 principal_id: ActorId,
202 actor_kind: ActorKind,
203 tenant_scope: TenantScope,
204 agent_session_id: Option<AgentSessionId>,
205 ) -> Self {
206 Self {
207 principal_id,
208 actor_kind,
209 tenant_scope,
210 agent_session_id,
211 }
212 }
213
214 #[must_use]
216 pub const fn principal_id(&self) -> &ActorId {
217 &self.principal_id
218 }
219
220 #[must_use]
222 pub const fn actor_kind(&self) -> ActorKind {
223 self.actor_kind
224 }
225
226 #[must_use]
228 pub const fn tenant_scope(&self) -> &TenantScope {
229 &self.tenant_scope
230 }
231
232 #[must_use]
234 pub const fn agent_session_id(&self) -> Option<AgentSessionId> {
235 self.agent_session_id
236 }
237}
238
239impl fmt::Debug for AdmittedActorContext {
240 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
241 formatter.write_str("AdmittedActorContext([REDACTED])")
242 }
243}
244
245crate::inspect_owned_capacity_copy_v1!(ActorKind);
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use crate::TenantId;
252
253 fn test_agent_session_id() -> AgentSessionId {
254 let mut bytes = [0x22; 16];
255 bytes[6] = 0x72;
256 bytes[8] = 0xa2;
257 AgentSessionId::from_bytes(bytes).expect("test UUID is version 7")
258 }
259
260 #[test]
261 fn actor_kind_tags_are_closed_and_stable() {
262 assert_eq!(ActorKind::ALL.map(ActorKind::tag), [0x01, 0x02, 0x03]);
263 for actor_kind in ActorKind::ALL {
264 assert_eq!(ActorKind::from_tag(actor_kind.tag()), Some(actor_kind));
265 }
266 assert_eq!(ActorKind::from_tag(0), None);
267 assert_eq!(ActorKind::from_tag(4), None);
268 assert_eq!(ActorKind::from_tag(u8::MAX), None);
269 }
270
271 #[test]
272 fn visible_ascii_components_enforce_exact_alphabet_and_bounds() {
273 let audience = Audience::new(format!("!{}~", "a".repeat(510)))
274 .expect("visible ASCII at the limit is valid");
275 assert_eq!(audience.as_bytes().len(), MAX_AUDIENCE_BYTES);
276 assert_eq!(Audience::new(""), Err(BoundedTextError::Empty));
277 assert_eq!(
278 Audience::new("a".repeat(MAX_AUDIENCE_BYTES + 1)),
279 Err(BoundedTextError::TooLong {
280 maximum: MAX_AUDIENCE_BYTES,
281 actual: MAX_AUDIENCE_BYTES + 1,
282 })
283 );
284 assert_eq!(
285 Audience::new("valid except space"),
286 Err(BoundedTextError::InvalidCharacter { index: 5 })
287 );
288 assert_eq!(
289 SourceCommit::new("abc\n123"),
290 Err(BoundedTextError::InvalidCharacter { index: 3 })
291 );
292 assert_eq!(
293 ApprovalReference::new("\u{7f}"),
294 Err(BoundedTextError::InvalidCharacter { index: 0 })
295 );
296 assert_eq!(
297 ApprovalId::new("caf\u{e9}"),
298 Err(BoundedTextError::InvalidCharacter { index: 3 })
299 );
300 }
301
302 #[test]
303 fn utf8_components_use_encoded_byte_lengths_without_normalization() {
304 let exact = "\u{e9}".repeat(MAX_SOURCE_REPOSITORY_BYTES / 2);
305 let repository =
306 SourceRepository::new(exact.clone()).expect("exact UTF-8 byte limit is valid");
307 assert_eq!(repository.as_str(), exact);
308 assert_eq!(repository.as_bytes().len(), MAX_SOURCE_REPOSITORY_BYTES);
309
310 let over = format!("{exact}x");
311 assert_eq!(
312 SourceRepository::new(over),
313 Err(BoundedTextError::TooLong {
314 maximum: MAX_SOURCE_REPOSITORY_BYTES,
315 actual: MAX_SOURCE_REPOSITORY_BYTES + 1,
316 })
317 );
318
319 let reason = ProvenanceReason::new("Case and \u{212b} stay exact")
320 .expect("bounded UTF-8 reason is valid");
321 assert_eq!(reason.as_str(), "Case and \u{212b} stay exact");
322 }
323
324 #[test]
325 fn every_text_value_rejects_empty_input() {
326 assert_eq!(Audience::new(""), Err(BoundedTextError::Empty));
327 assert_eq!(SourceRepository::new(""), Err(BoundedTextError::Empty));
328 assert_eq!(SourceCommit::new(""), Err(BoundedTextError::Empty));
329 assert_eq!(ProvenanceReason::new(""), Err(BoundedTextError::Empty));
330 assert_eq!(ApprovalReference::new(""), Err(BoundedTextError::Empty));
331 assert_eq!(ApprovalId::new(""), Err(BoundedTextError::Empty));
332 }
333
334 #[test]
335 fn bounded_values_and_admitted_context_redact_debug_output() {
336 let audience = Audience::new("https://database.invalid").expect("valid audience");
337 let repository = SourceRepository::new("secret/repository").expect("valid repository");
338 let commit = SourceCommit::new("0123456789abcdef").expect("valid commit");
339 let reason = ProvenanceReason::new("sensitive reason").expect("valid reason");
340 let reference = ApprovalReference::new("approval-reference").expect("valid reference");
341 let approval_id = ApprovalId::new("approval-id").expect("valid approval ID");
342
343 for (debug, secret) in [
344 (format!("{audience:?}"), "database.invalid"),
345 (format!("{repository:?}"), "secret/repository"),
346 (format!("{commit:?}"), "0123456789abcdef"),
347 (format!("{reason:?}"), "sensitive reason"),
348 (format!("{reference:?}"), "approval-reference"),
349 (format!("{approval_id:?}"), "approval-id"),
350 ] {
351 assert!(debug.contains("[REDACTED]"));
352 assert!(!debug.contains(secret));
353 }
354
355 let principal_id = ActorId::new("principal-secret").expect("valid principal");
356 let tenant_id = TenantId::new("tenant-secret").expect("valid tenant");
357 let context = AdmittedActorContext::new(
358 principal_id,
359 ActorKind::Agent,
360 TenantScope::Tenant(tenant_id),
361 Some(test_agent_session_id()),
362 );
363 let debug = format!("{context:?}");
364 assert_eq!(debug, "AdmittedActorContext([REDACTED])");
365 assert!(!debug.contains("principal-secret"));
366 assert!(!debug.contains("tenant-secret"));
367 }
368
369 #[test]
370 fn admitted_actor_context_preserves_exact_authorized_components() {
371 let principal_id = ActorId::new("actor-1").expect("valid actor");
372 let tenant_id = TenantId::new("tenant-1").expect("valid tenant");
373 let session_id = test_agent_session_id();
374 let context = AdmittedActorContext::new(
375 principal_id.clone(),
376 ActorKind::Agent,
377 TenantScope::Tenant(tenant_id.clone()),
378 Some(session_id),
379 );
380
381 assert_eq!(context.principal_id(), &principal_id);
382 assert_eq!(context.actor_kind(), ActorKind::Agent);
383 assert_eq!(context.tenant_scope(), &TenantScope::Tenant(tenant_id));
384 assert_eq!(context.agent_session_id(), Some(session_id));
385 }
386}