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