cpex_core/extensions/guarded.rs
1// Location: ./crates/cpex-core/src/extensions/guarded.rs
2// Copyright 2025
3// SPDX-License-Identifier: Apache-2.0
4// Authors: Teryl Taylor
5//
6// Guarded<T> — capability-gated write access.
7//
8// A value that requires a WriteToken for mutable access. Read access
9// is always available (if the plugin can see the extension at all).
10// Write access requires the framework to issue a WriteToken based on
11// the plugin's declared capabilities.
12//
13// Mirrors the spec in rust-implementation-spec.md §2.3.
14
15use serde::{Deserialize, Serialize};
16
17/// A value that requires a WriteToken for mutable access.
18///
19/// Read access via `.read()` is always available. Write access via
20/// `.write(token)` requires a `WriteToken` proving the caller has
21/// the capability.
22///
23/// The framework issues write tokens only to plugins that declared
24/// the corresponding write capability (e.g., `write_headers`).
25/// Plugin code without a token cannot call `.write()`.
26#[derive(Clone, Debug, Serialize, Deserialize)]
27#[serde(transparent)]
28pub struct Guarded<T> {
29 inner: T,
30}
31
32impl<T> Guarded<T> {
33 /// Wrap a value in a guard.
34 pub fn new(value: T) -> Self {
35 Self { inner: value }
36 }
37
38 /// Read access — always available if the plugin can see this extension.
39 pub fn read(&self) -> &T {
40 &self.inner
41 }
42
43 /// Write access — requires a WriteToken proving the caller has capability.
44 ///
45 /// The framework issues WriteTokens only to plugins that declared
46 /// the write capability in their config. Without the token, this
47 /// method is uncallable — the plugin can read but not write.
48 pub fn write(&mut self, _token: &WriteToken) -> &mut T {
49 &mut self.inner
50 }
51
52 /// Consume the guard, returning the inner value.
53 pub fn into_inner(self) -> T {
54 self.inner
55 }
56}
57
58impl<T: Default> Default for Guarded<T> {
59 fn default() -> Self {
60 Self {
61 inner: T::default(),
62 }
63 }
64}
65
66/// Opaque token for write access — only the framework can create one.
67///
68/// `pub(crate)` constructor means plugin crates cannot mint tokens.
69/// The executor creates tokens based on the plugin's declared
70/// capabilities from `PluginConfig`.
71pub struct WriteToken {
72 _private: (),
73}
74
75impl WriteToken {
76 /// Only callable by the framework (pub(crate)).
77 /// Plugin crates cannot construct this.
78 pub(crate) fn new() -> Self {
79 Self { _private: () }
80 }
81}
82
83// WriteToken is not Clone, not Copy — each plugin gets its own from the executor.
84// It's also not Send/Sync by default (no auto-traits on zero-sized private fields).
85// We explicitly mark it safe since it's just a capability proof with no data.
86unsafe impl Send for WriteToken {}
87unsafe impl Sync for WriteToken {}
88
89impl std::fmt::Debug for WriteToken {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 f.write_str("WriteToken")
92 }
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn test_guarded_read_without_token() {
101 let guarded = Guarded::new(42);
102 assert_eq!(*guarded.read(), 42);
103 }
104
105 #[test]
106 fn test_guarded_write_with_token() {
107 let mut guarded = Guarded::new(42);
108 let token = WriteToken::new();
109 *guarded.write(&token) = 100;
110 assert_eq!(*guarded.read(), 100);
111 }
112
113 #[test]
114 fn test_guarded_serde_transparent() {
115 let guarded = Guarded::new("hello".to_string());
116 let json = serde_json::to_string(&guarded).unwrap();
117 assert_eq!(json, "\"hello\"");
118 let deserialized: Guarded<String> = serde_json::from_str(&json).unwrap();
119 assert_eq!(*deserialized.read(), "hello");
120 }
121
122 #[test]
123 fn test_guarded_with_struct() {
124 use std::collections::HashMap;
125
126 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
127 struct Headers {
128 map: HashMap<String, String>,
129 }
130
131 let mut guarded = Guarded::new(Headers::default());
132 let token = WriteToken::new();
133
134 // Read — no token needed
135 assert!(guarded.read().map.is_empty());
136
137 // Write — token required
138 guarded
139 .write(&token)
140 .map
141 .insert("X-Auth".into(), "Bearer tok".into());
142 assert_eq!(guarded.read().map.get("X-Auth").unwrap(), "Bearer tok");
143 }
144}