1use serde::{Deserialize, Serialize};
21use sha2::{Digest, Sha256};
22use std::fmt::{self, Write as _};
23
24use super::{
25 REPO_IDENTITY_DOMAIN, require_schema, validate_b64_len, validate_hex64, validate_id,
26 validate_text,
27};
28use crate::ValidationError;
29
30pub const ORG_SALT_SCHEMA: &str = "leanctx.hive.org-salt/v1";
31pub const VAULT_RESOLVE_REQUEST_SCHEMA: &str = "leanctx.hive.vault-resolve-request/v1";
32pub const VAULT_SCHEMA: &str = "leanctx.hive.vault/v1";
33pub const VAULT_MATCH_REQUEST_SCHEMA: &str = "leanctx.hive.vault-match-request/v1";
35pub const VAULT_MATCH_SCHEMA: &str = "leanctx.hive.vault-match/v1";
37
38pub const SEALED_VAULT_UNSUPPORTED: &str = "Hive sent a sealed vault, but sealed vaults are not \
40 supported; self-host Hive or use a private cloud to keep content off the shared service";
41
42pub const MAX_VAULT_LABEL_CHARS: usize = 64;
44
45pub fn stable_identity(repo_identity: &str) -> String {
47 let mut hasher = Sha256::new();
48 hasher.update(REPO_IDENTITY_DOMAIN.as_bytes());
49 hasher.update(repo_identity.as_bytes());
50 let mut hex = String::with_capacity(64);
51 for byte in hasher.finalize() {
52 write!(&mut hex, "{byte:02x}").expect("writing to String cannot fail");
53 }
54 hex
55}
56
57#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct OrgSaltV1 {
60 pub schema: String,
61 pub org_salt_id: String,
62 pub salt: String,
64}
65
66impl fmt::Debug for OrgSaltV1 {
67 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68 formatter
69 .debug_struct("OrgSaltV1")
70 .field("org_salt_id", &self.org_salt_id)
71 .field("salt", &"<redacted>")
72 .finish_non_exhaustive()
73 }
74}
75
76impl OrgSaltV1 {
77 pub fn validate(&self) -> Result<(), ValidationError> {
78 require_schema(&self.schema, ORG_SALT_SCHEMA)?;
79 validate_id(&self.org_salt_id, "org_salt_id")?;
80 validate_b64_len(&self.salt, 32, "salt")
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct VaultResolveRequestV1 {
88 pub schema: String,
89 pub org_salt_id: String,
90 pub identity_digest: String,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub label: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub vault: Option<VaultRefV1>,
98}
99
100impl VaultResolveRequestV1 {
101 pub fn validate(&self) -> Result<(), ValidationError> {
102 require_schema(&self.schema, VAULT_RESOLVE_REQUEST_SCHEMA)?;
103 validate_id(&self.org_salt_id, "org_salt_id")?;
104 validate_hex64(&self.identity_digest, "identity_digest")?;
105 if let Some(label) = &self.label {
106 validate_text(label, MAX_VAULT_LABEL_CHARS, "label", false)?;
107 }
108 if let Some(vault) = &self.vault {
109 vault.validate()?;
110 }
111 Ok(())
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
117pub struct VaultRefV1 {
118 pub ns: String,
119 pub name: String,
120}
121
122impl VaultRefV1 {
123 pub fn validate(&self) -> Result<(), ValidationError> {
124 validate_id(&self.ns, "vault.ns")?;
125 validate_id(&self.name, "vault.name")
126 }
127
128 pub fn parse(value: &str) -> Result<Self, ValidationError> {
130 let (ns, name) = value
131 .split_once('/')
132 .ok_or_else(|| ValidationError::new("vault must be <ns>/<name>"))?;
133 let vault = Self {
134 ns: ns.into(),
135 name: name.into(),
136 };
137 vault.validate()?;
138 Ok(vault)
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct VaultV1 {
145 pub schema: String,
146 pub vault_id: String,
147 pub vault: VaultRefV1,
148 #[serde(default)]
151 pub sealed: bool,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
153 pub label: Option<String>,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub code_graph: Option<super::codegraph::CodeGraphMode>,
158}
159
160impl VaultV1 {
161 pub fn validate(&self) -> Result<(), ValidationError> {
162 require_schema(&self.schema, VAULT_SCHEMA)?;
163 super::validate_vault_id(&self.vault_id)?;
164 validate_id(&self.vault.ns, "vault.ns")?;
165 validate_id(&self.vault.name, "vault.name")?;
166 if self.sealed {
167 return Err(ValidationError::new(SEALED_VAULT_UNSUPPORTED));
168 }
169 if let Some(label) = &self.label {
170 validate_text(label, MAX_VAULT_LABEL_CHARS, "label", false)?;
171 }
172 Ok(())
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn stable_identity_is_domain_separated_sha256() {
182 let id = stable_identity("git:github.com/yvgude/lean-ctx");
184 assert_eq!(id.len(), 64);
185 assert_eq!(id, stable_identity("git:github.com/yvgude/lean-ctx"));
186 assert_ne!(id, stable_identity("git:github.com/yvgude/other"));
187 }
188
189 #[test]
190 fn a_sealed_vault_is_refused() {
191 let vault = |sealed: bool| -> VaultV1 {
192 serde_json::from_value(serde_json::json!({
193 "schema": VAULT_SCHEMA,
194 "vault_id": "3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42",
195 "vault": {"ns": "acme", "name": "payments"},
196 "sealed": sealed,
197 "sealed_conversion": "none",
198 }))
199 .unwrap()
200 };
201 assert!(vault(false).validate().is_ok());
202 let plain: VaultV1 = serde_json::from_value(serde_json::json!({
203 "schema": VAULT_SCHEMA,
204 "vault_id": "3f6c1e9a0b7d4c2e8f5a1b6c9d0e7f42",
205 "vault": {"ns": "acme", "name": "payments"},
206 }))
207 .expect("a vault without `sealed` decodes");
208 assert!(!plain.sealed && plain.validate().is_ok());
209 let error = vault(true).validate().unwrap_err();
210 assert!(
211 error
212 .to_string()
213 .contains("sealed vaults are not supported"),
214 "{error}"
215 );
216 }
217}