Skip to main content

lean_ctx_protocol/hive/
vault.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Vault resolution: the engine never sends a path, remote or name, only
4//! an org-salted digest of its git repository's stable identity. One git
5//! repository maps to exactly one vault.
6//!
7//! - `GET /v1/org/salt` → [`OrgSaltV1`]
8//! - `POST /v1/vaults/resolve` [`VaultResolveRequestV1`] → [`VaultV1`]
9//!
10//! `identity_digest = hex(HMAC-SHA256(salt, stable_identity))`, where
11//! `stable_identity` is [`stable_identity`] of the engine's repository identity
12//! (`.lean-ctx-id` > normalized git origin without credentials > manifests).
13//!
14//! Since 1.1 the request may name a `vault` (written by `lean-ctx hive link
15//! <ns>/<name>`) to bind the repository to a vault created on the web. The
16//! service binds on first sight when the instance may contribute to that
17//! vault, otherwise answers 403 `forbidden_right`. Without `vault`, resolve
18//! creates a vault on first sight.
19
20use 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";
33/// Request body of `POST /v1/vaults/match` (C0 fixtures `routes/vault_match.*`).
34pub const VAULT_MATCH_REQUEST_SCHEMA: &str = "leanctx.hive.vault-match-request/v1";
35/// Answer of `POST /v1/vaults/match`.
36pub const VAULT_MATCH_SCHEMA: &str = "leanctx.hive.vault-match/v1";
37
38/// Why a vault that says it is sealed is refused.
39pub 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
42/// Longest vault label, in characters.
43pub const MAX_VAULT_LABEL_CHARS: usize = 64;
44
45/// `sha256_hex("leanctx.project-identity.v1\n" + repo_identity)`.
46pub 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/// `GET /v1/org/salt`.
58#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct OrgSaltV1 {
60    pub schema: String,
61    pub org_salt_id: String,
62    /// 32 random bytes, base64. Org-confidential: never logged.
63    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/// `POST /v1/vaults/resolve`. Creates the vault on first sight.
85#[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    /// 64 lowercase hex characters.
91    pub identity_digest: String,
92    /// Display label, only when `[hive] vault_labels = true`.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub label: Option<String>,
95    /// 1.1: the vault to bind to (from `lean-ctx hive link`).
96    #[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/// The vault a git repository maps to, as `@<ns>/<name>`.
116#[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    /// Parse `<ns>/<name>` as `lean-ctx hive link` takes it.
129    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/// Response of `POST /v1/vaults/resolve`.
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
144pub struct VaultV1 {
145    pub schema: String,
146    pub vault_id: String,
147    pub vault: VaultRefV1,
148    /// `false` or absent: a `true` fails [`VaultV1::validate`] with
149    /// [`SEALED_VAULT_UNSUPPORTED`].
150    #[serde(default)]
151    pub sealed: bool,
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub label: Option<String>,
154    /// 1.9 (section 8): `full` when the owner publishes the code graph;
155    /// absent means `anchors`.
156    #[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        // sha256("leanctx.project-identity.v1\ngit:github.com/yvgude/lean-ctx")
183        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}