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