1use std::fmt;
34
35use chio_weights::card::{ModelCard, StringSet};
36use chio_weights::error::WeightsError;
37
38pub type WeightsBindingError = WeightsError;
42
43#[derive(Debug, Clone)]
49pub struct WeightsBindingRequest<'a> {
50 pub loaded_weights_hash: &'a str,
54 pub requested_scopes: &'a StringSet,
58 pub requested_tools: &'a StringSet,
62}
63
64pub fn evaluate_weights_binding(
77 card: &ModelCard,
78 request: &WeightsBindingRequest<'_>,
79) -> Result<(), WeightsBindingError> {
80 if card.weights_hash != request.loaded_weights_hash {
84 return Err(WeightsError::CardMismatch {
85 expected: card.weights_hash.clone(),
86 found: request.loaded_weights_hash.to_string(),
87 });
88 }
89
90 for scope in request.requested_scopes.iter() {
93 if !card.allowed_capability_set.contains(scope) {
94 return Err(WeightsError::ScopeNotSubset {
95 scope: scope.to_string(),
96 });
97 }
98 }
99
100 for tool in request.requested_tools.iter() {
102 if card.banned_tools.contains(tool) {
103 return Err(WeightsError::ToolBanned {
104 tool: tool.to_string(),
105 });
106 }
107 }
108
109 Ok(())
110}
111
112pub fn evaluate_weights_binding_with_loaded_hash<H, E>(
119 card: &ModelCard,
120 loaded_weights_hash: Result<H, E>,
121 requested_scopes: &StringSet,
122 requested_tools: &StringSet,
123) -> Result<(), WeightsBindingError>
124where
125 H: AsRef<str>,
126 E: fmt::Display,
127{
128 let loaded_weights_hash = loaded_weights_hash.map_err(|error| {
129 WeightsError::SchemaRejected(format!("loaded weights unavailable: {error}"))
130 })?;
131 let request = WeightsBindingRequest {
132 loaded_weights_hash: loaded_weights_hash.as_ref(),
133 requested_scopes,
134 requested_tools,
135 };
136 evaluate_weights_binding(card, &request)
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use chrono::{TimeZone, Utc};
143
144 fn fixed_issued_at() -> chrono::DateTime<Utc> {
145 match Utc.with_ymd_and_hms(2026, 4, 30, 12, 0, 0) {
146 chrono::LocalResult::Single(t) => t,
147 _ => panic!("fixed_issued_at fixture must construct"),
148 }
149 }
150
151 fn good_card() -> ModelCard {
152 let issued = fixed_issued_at();
153 match ModelCard::new(
154 "0000000000000000000000000000000000000000000000000000000000000001",
155 StringSet::new(["tool:read", "tool:write"]),
156 StringSet::new(["tool:exec"]),
157 "public-internet",
158 "https://example.com/issuer",
159 issued,
160 issued + chrono::Duration::days(30),
161 ) {
162 Ok(c) => c,
163 Err(e) => panic!("good_card must construct: {e}"),
164 }
165 }
166
167 #[test]
168 fn binding_succeeds_when_all_gates_pass() {
169 let card = good_card();
170 let scopes = StringSet::new(["tool:read"]);
171 let tools = StringSet::new(["tool:read"]);
172 let req = WeightsBindingRequest {
173 loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
174 requested_scopes: &scopes,
175 requested_tools: &tools,
176 };
177 assert!(evaluate_weights_binding(&card, &req).is_ok());
178 }
179
180 #[test]
181 fn rejects_card_mismatch() {
182 let card = good_card();
183 let scopes = StringSet::new(["tool:read"]);
184 let tools = StringSet::new(["tool:read"]);
185 let req = WeightsBindingRequest {
186 loaded_weights_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
187 requested_scopes: &scopes,
188 requested_tools: &tools,
189 };
190 let err = match evaluate_weights_binding(&card, &req) {
191 Err(e) => e,
192 Ok(()) => panic!("must reject"),
193 };
194 assert_eq!(err.urn(), "urn:chio:error:weights:card-mismatch");
195 assert!(matches!(err, WeightsError::CardMismatch { .. }));
196 }
197
198 #[test]
199 fn rejects_scope_not_subset() {
200 let card = good_card();
201 let scopes = StringSet::new(["tool:read", "tool:admin"]);
202 let tools = StringSet::default();
203 let req = WeightsBindingRequest {
204 loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
205 requested_scopes: &scopes,
206 requested_tools: &tools,
207 };
208 let err = match evaluate_weights_binding(&card, &req) {
209 Err(e) => e,
210 Ok(()) => panic!("must reject"),
211 };
212 assert_eq!(err.urn(), "urn:chio:error:weights:scope-not-subset");
213 match err {
214 WeightsError::ScopeNotSubset { scope } => {
215 assert_eq!(scope, "tool:admin");
216 }
217 other => panic!("unexpected error: {other:?}"),
218 }
219 }
220
221 #[test]
222 fn rejects_banned_tool() {
223 let card = good_card();
224 let scopes = StringSet::new(["tool:read"]);
225 let tools = StringSet::new(["tool:read", "tool:exec"]);
226 let req = WeightsBindingRequest {
227 loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
228 requested_scopes: &scopes,
229 requested_tools: &tools,
230 };
231 let err = match evaluate_weights_binding(&card, &req) {
232 Err(e) => e,
233 Ok(()) => panic!("must reject"),
234 };
235 assert_eq!(err.urn(), "urn:chio:error:weights:tool-banned");
236 match err {
237 WeightsError::ToolBanned { tool } => assert_eq!(tool, "tool:exec"),
238 other => panic!("unexpected error: {other:?}"),
239 }
240 }
241
242 #[test]
243 fn gate_order_is_card_mismatch_first() {
244 let card = good_card();
249 let scopes = StringSet::new(["tool:admin"]);
250 let tools = StringSet::new(["tool:exec"]);
251 let req = WeightsBindingRequest {
252 loaded_weights_hash: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
253 requested_scopes: &scopes,
254 requested_tools: &tools,
255 };
256 let err = match evaluate_weights_binding(&card, &req) {
257 Err(e) => e,
258 Ok(()) => panic!("must reject"),
259 };
260 assert!(matches!(err, WeightsError::CardMismatch { .. }));
261 }
262
263 #[test]
264 fn empty_scopes_and_tools_pass_when_hash_matches() {
265 let card = good_card();
266 let scopes = StringSet::default();
267 let tools = StringSet::default();
268 let req = WeightsBindingRequest {
269 loaded_weights_hash: "0000000000000000000000000000000000000000000000000000000000000001",
270 requested_scopes: &scopes,
271 requested_tools: &tools,
272 };
273 assert!(evaluate_weights_binding(&card, &req).is_ok());
274 }
275}