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