1pub mod anchor;
29pub mod bus;
30pub mod codegraph;
31pub mod ctxpkg;
32pub mod effective_policy;
33pub mod enroll;
34pub mod error;
35pub mod facts;
36pub mod gate;
37pub mod graph;
38pub mod knowledge;
39pub mod lease;
40pub mod managed;
41pub mod metrics_v2;
42pub mod model_catalog;
43pub mod optin;
44pub mod policy;
45pub mod profiles;
46pub mod receipts_v2;
47pub mod root_cert;
48pub mod status;
49pub mod tasks;
50pub mod telemetry;
51pub mod vault;
52
53pub use error::{HiveErrorBody, HiveErrorCode, HiveErrorDetailV1, HiveUploadKind};
54
55use crate::ValidationError;
56use serde_json::Value;
57use std::collections::BTreeMap;
58
59pub const HIVE_PROTOCOL_MAJOR: u16 = 1;
61pub const HIVE_PROTOCOL_MINOR: u16 = 9;
63
64pub const HIVE_DEFAULT_URL: &str = "https://hive.leanctx.com";
66pub const HIVE_API_PREFIX: &str = "/v1";
68pub const HIVE_ENGINE_CLIENT_ID: &str = "lean-ctx-engine";
70
71pub const HEADER_PROTOCOL: &str = "Hive-Protocol";
73pub const HEADER_INSTANCE: &str = "Hive-Instance";
75pub const HEADER_MIN_ENGINE: &str = "Hive-Min-Instance";
77pub const HEADER_IDEMPOTENCY_KEY: &str = "Idempotency-Key";
79pub const HEADER_NAMES: &str = "Hive-Names";
86pub const NAMES_VAULT: &str = "vault";
88
89pub const LEASE_SIGNATURE_DOMAIN: &str = "leanctx.hive.lease.v1\n";
91pub const KEYSET_SIGNATURE_DOMAIN: &str = "leanctx.hive.keyset.v1\n";
93pub const TOKEN_ASSERTION_DOMAIN: &str = "leanctx.hive.token.v1\n";
95pub const FILTER_PACK_SIGNATURE_DOMAIN: &str = "leanctx.filter-pack.v1\n";
97pub const REPO_IDENTITY_DOMAIN: &str = "leanctx.project-identity.v1\n";
101
102pub const MAX_BODY_BYTES: usize = 1 << 20;
105
106pub fn protocol_header_value() -> String {
108 format!("{HIVE_PROTOCOL_MAJOR}.{HIVE_PROTOCOL_MINOR}")
109}
110
111pub fn parse_protocol_header(value: &str) -> Option<(u16, u16)> {
113 let (major, minor) = value.trim().split_once('.')?;
114 let digits = |part: &str| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit());
115 if !digits(major) || !digits(minor) {
116 return None;
117 }
118 Some((major.parse().ok()?, minor.parse().ok()?))
119}
120
121pub fn protocol_compatible(value: &str) -> bool {
123 parse_protocol_header(value).is_some_and(|(major, _)| major == HIVE_PROTOCOL_MAJOR)
124}
125
126pub fn canonical_json(value: &Value) -> Result<Vec<u8>, ValidationError> {
135 let sorted = canonical_value(value)?;
136 serde_json::to_vec(&sorted)
137 .map_err(|error| ValidationError::new(format!("encode canonical JSON: {error}")))
138}
139
140fn canonical_value(value: &Value) -> Result<Value, ValidationError> {
141 Ok(match value {
142 Value::Object(fields) => {
143 let sorted = fields
144 .iter()
145 .map(|(key, value)| Ok((key.clone(), canonical_value(value)?)))
146 .collect::<Result<BTreeMap<_, _>, ValidationError>>()?;
147 Value::Object(sorted.into_iter().collect())
148 }
149 Value::Array(values) => Value::Array(
150 values
151 .iter()
152 .map(canonical_value)
153 .collect::<Result<_, _>>()?,
154 ),
155 Value::Number(number) if !(number.is_i64() || number.is_u64()) => {
156 return Err(ValidationError::new("canonical JSON carries integers only"));
157 }
158 scalar => scalar.clone(),
159 })
160}
161
162pub fn signing_input(domain: &str, value: &Value) -> Result<Vec<u8>, ValidationError> {
164 let mut bytes = domain.as_bytes().to_vec();
165 bytes.extend(canonical_json(value)?);
166 Ok(bytes)
167}
168
169pub fn signing_input_without_signature(
172 domain: &str,
173 value: &Value,
174) -> Result<Vec<u8>, ValidationError> {
175 let Value::Object(fields) = value else {
176 return Err(ValidationError::new("signed value must be a JSON object"));
177 };
178 let mut fields = fields.clone();
179 fields.remove("signature");
180 signing_input(domain, &Value::Object(fields))
181}
182
183pub(crate) fn require_schema(actual: &str, expected: &str) -> Result<(), ValidationError> {
184 if actual == expected {
185 Ok(())
186 } else {
187 Err(ValidationError::new(format!(
188 "schema must be {expected}, got {actual:?}"
189 )))
190 }
191}
192
193pub(crate) fn validate_b64_len(
195 value: &str,
196 decoded_len: usize,
197 field: &str,
198) -> Result<(), ValidationError> {
199 let encoded_len = decoded_len.div_ceil(3) * 4;
200 let padding = (3 - decoded_len % 3) % 3;
201 if value.len() == encoded_len && b64_shape_ok(value, padding) {
202 Ok(())
203 } else {
204 Err(ValidationError::new(format!(
205 "{field} must be base64 of exactly {decoded_len} bytes"
206 )))
207 }
208}
209
210pub(crate) fn validate_b64_bounded(
212 value: &str,
213 max_encoded: usize,
214 field: &str,
215) -> Result<(), ValidationError> {
216 let padding = value.bytes().rev().take_while(|byte| *byte == b'=').count();
217 if !value.is_empty()
218 && value.len() <= max_encoded
219 && value.len().is_multiple_of(4)
220 && padding <= 2
221 && b64_shape_ok(value, padding)
222 {
223 Ok(())
224 } else {
225 Err(ValidationError::new(format!(
226 "{field} must be non-empty padded base64 of at most {max_encoded} characters"
227 )))
228 }
229}
230
231fn b64_shape_ok(value: &str, padding: usize) -> bool {
232 let (body, pad) = value.split_at(value.len() - padding);
233 pad.bytes().all(|byte| byte == b'=')
234 && body
235 .bytes()
236 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/'))
237}
238
239#[must_use]
254pub fn invisible(c: char) -> bool {
255 matches!(
256 c,
257 '\u{00AD}'
258 | '\u{034F}'
259 | '\u{061C}'
260 | '\u{115F}'
261 | '\u{1160}'
262 | '\u{17B4}'
263 | '\u{17B5}'
264 | '\u{180E}'
265 | '\u{200B}'
266 | '\u{200E}'
267 | '\u{200F}'
268 | '\u{2028}'
269 | '\u{2029}'
270 | '\u{202A}'..='\u{202E}'
271 | '\u{2060}'..='\u{206F}'
272 | '\u{3164}'
273 | '\u{FEFF}'
274 | '\u{FFA0}'
275 | '\u{FFF9}'..='\u{FFFB}'
276 | '\u{13430}'..='\u{1343F}'
277 | '\u{1BCA0}'..='\u{1BCA3}'
278 | '\u{1D173}'..='\u{1D17A}'
279 | '\u{E0000}'..='\u{E007F}'
280 | '\u{E0100}'..='\u{E01EF}'
281 )
282}
283
284pub(crate) fn validate_text(
286 value: &str,
287 max: usize,
288 field: &str,
289 allow_newlines: bool,
290) -> Result<(), ValidationError> {
291 let count = value.chars().count();
292 if count == 0 || count > max || value.trim().is_empty() {
293 return Err(ValidationError::new(format!(
294 "{field} must be non-empty text of at most {max} characters"
295 )));
296 }
297 if value
298 .chars()
299 .any(|ch| ch.is_control() && !(allow_newlines && matches!(ch, '\n' | '\t' | '\r')))
300 {
301 return Err(ValidationError::new(format!(
302 "{field} contains control characters"
303 )));
304 }
305 if value.chars().any(invisible) {
306 return Err(ValidationError::new(format!(
307 "{field} contains invisible or direction characters"
308 )));
309 }
310 Ok(())
311}
312
313pub(crate) fn validate_id(value: &str, field: &str) -> Result<(), ValidationError> {
316 let bytes = value.as_bytes();
317 if !bytes.is_empty()
318 && bytes.len() <= 128
319 && bytes[0].is_ascii_alphanumeric()
320 && bytes
321 .iter()
322 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-'))
323 {
324 Ok(())
325 } else {
326 Err(ValidationError::new(format!(
327 "{field} must be 1-128 characters of [A-Za-z0-9._:-] starting alphanumeric"
328 )))
329 }
330}
331
332pub fn validate_vault_id(value: &str) -> Result<(), ValidationError> {
337 if value.len() == 32
338 && value
339 .bytes()
340 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
341 {
342 Ok(())
343 } else {
344 Err(ValidationError::new(
345 "vault_id must be 32 lowercase hexadecimal characters",
346 ))
347 }
348}
349
350pub fn validate_origin(value: &str, field: &str) -> Result<(), ValidationError> {
353 let refuse = || {
354 ValidationError::new(format!(
355 "{field} must be an https origin (http only on loopback)"
356 ))
357 };
358 let (scheme, rest) = value.split_once("://").ok_or_else(refuse)?;
359 if rest.is_empty()
360 || value.len() > 256
361 || rest.contains(['/', '?', '#', '@'])
362 || rest.chars().any(char::is_whitespace)
363 {
364 return Err(refuse());
365 }
366 let host = rest
367 .rsplit_once(':')
368 .filter(|(_, port)| port.bytes().all(|b| b.is_ascii_digit()))
369 .map_or(rest, |(host, _)| host);
370 match scheme {
371 "https" => Ok(()),
372 "http" if matches!(host, "localhost" | "127.0.0.1" | "[::1]") => Ok(()),
373 _ => Err(refuse()),
374 }
375}
376
377pub(crate) fn validate_hex64(value: &str, field: &str) -> Result<(), ValidationError> {
379 if value.len() == 64
380 && value
381 .bytes()
382 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
383 {
384 Ok(())
385 } else {
386 Err(ValidationError::new(format!(
387 "{field} must be 64 lowercase hexadecimal characters"
388 )))
389 }
390}
391
392pub(crate) fn parse_time(
393 value: &crate::UtcTimestamp,
394) -> Result<chrono::DateTime<chrono::FixedOffset>, ValidationError> {
395 chrono::DateTime::parse_from_rfc3339(value.as_str())
396 .map_err(|_| ValidationError::new(format!("invalid timestamp {:?}", value.as_str())))
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use serde_json::json;
403
404 #[test]
405 fn text_refuses_what_a_reviewer_cannot_see() {
406 assert!(validate_text("fine\u{E0068}\u{E0069}", 100, "t", false).is_err());
408 assert!(validate_text("a\u{E0100}", 100, "t", false).is_err());
409 assert!(validate_text("safe \u{202E}cod.exe", 100, "t", false).is_err());
410 assert!(validate_text("a\u{200B}b", 100, "t", true).is_err());
411 assert!(
413 validate_text(
414 "dev \u{1F469}\u{200D}\u{1F4BB} \u{2764}\u{FE0F}",
415 100,
416 "t",
417 false
418 )
419 .is_ok()
420 );
421 assert!(
422 validate_text(
423 "\u{0645}\u{06CC}\u{200C}\u{062E}\u{0648}\u{0627}\u{0647}\u{0645}",
424 100,
425 "t",
426 false
427 )
428 .is_ok()
429 );
430 }
431
432 #[test]
436 fn every_invisible_character_is_refused() {
437 let set = [
438 '\u{00AD}',
439 '\u{034F}',
440 '\u{061C}',
441 '\u{115F}',
442 '\u{1160}',
443 '\u{17B4}',
444 '\u{17B5}',
445 '\u{180E}',
446 '\u{200B}',
447 '\u{200E}',
448 '\u{200F}',
449 '\u{2028}',
450 '\u{2029}',
451 '\u{202A}',
452 '\u{202E}',
453 '\u{2060}',
454 '\u{2064}',
455 '\u{2066}',
456 '\u{2069}',
457 '\u{3164}',
458 '\u{FEFF}',
459 '\u{FFA0}',
460 '\u{E0000}',
461 '\u{E0041}',
462 '\u{E007F}',
463 '\u{E0100}',
464 '\u{E01EF}',
465 '\u{2065}',
466 '\u{206A}',
467 '\u{206F}',
468 '\u{FFF9}',
469 '\u{FFFB}',
470 '\u{13430}',
471 '\u{1343F}',
472 '\u{1BCA0}',
473 '\u{1BCA3}',
474 '\u{1D173}',
475 '\u{1D17A}',
476 ];
477 for c in set {
478 let text = format!("a{c}b");
479 assert!(invisible(c), "{c:?}");
480 assert!(validate_text(&text, 100, "t", false).is_err(), "{c:?}");
481 assert!(validate_text(&text, 100, "t", true).is_err(), "{c:?}");
482 assert!(
483 anchor::validate_anchor_path(&format!("src/{text}.rs")).is_err(),
484 "{c:?}"
485 );
486 }
487 for c in [
489 '\u{200C}', '\u{200D}', '\u{FE0F}', '\u{0600}', '\u{06DD}', '\u{070F}', '\u{08E2}',
490 'a', ' ', 'ü',
491 ] {
492 assert!(!invisible(c), "{c:?}");
493 }
494 assert!(anchor::validate_anchor_path("src/a\u{200D}b.rs").is_err());
495 }
496
497 #[test]
498 fn canonical_json_sorts_keys_at_every_depth() {
499 let value = json!({"b": 1, "a": {"z": [ {"y": 2, "x": 1} ], "c": "ü\n"}});
500 assert_eq!(
501 String::from_utf8(canonical_json(&value).unwrap()).unwrap(),
502 r#"{"a":{"c":"ü\n","z":[{"x":1,"y":2}]},"b":1}"#
503 );
504 }
505
506 #[test]
507 fn canonical_json_refuses_floats() {
508 assert!(canonical_json(&json!({"a": 1.5})).is_err());
509 assert!(canonical_json(&json!({"a": -3})).is_ok());
510 }
511
512 #[test]
513 fn signing_input_without_signature_drops_only_the_top_level_field() {
514 let value = json!({"signature": "x", "keys": [{"signature": "kept"}]});
515 let input = signing_input_without_signature("d\n", &value).unwrap();
516 assert_eq!(input, b"d\n{\"keys\":[{\"signature\":\"kept\"}]}".to_vec());
517 }
518
519 #[test]
520 fn protocol_header_parses_and_checks_major() {
521 assert_eq!(parse_protocol_header("1.8"), Some((1, 8)));
522 assert!(protocol_compatible("1.0"));
523 assert!(!protocol_compatible("2.0"));
524 assert!(!protocol_compatible("1"));
525 assert!(!protocol_compatible("1.x"));
526 assert_eq!(protocol_header_value(), "1.9");
527 }
528
529 #[test]
530 fn base64_validation_checks_length_and_padding() {
531 assert!(validate_b64_len(&"A".repeat(44), 32, "k").is_err());
532 assert!(validate_b64_len(&format!("{}=", "A".repeat(43)), 32, "k").is_ok());
533 assert!(validate_b64_len(&format!("{}==", "A".repeat(86)), 64, "s").is_ok());
534 assert!(validate_b64_len(&format!("{}=", "-".repeat(43)), 32, "k").is_err());
535 assert!(validate_b64_bounded("AAAA", 8, "c").is_ok());
536 assert!(validate_b64_bounded("AAA", 8, "c").is_err());
537 }
538}