Skip to main content

loonfs_api/
capability.rs

1//! The capability document (API spec, "Capability discovery"): the
2//! profiles and feature keys a deployment advertises, which clients gate
3//! on instead of guessing from the backend kind.
4
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use thiserror::Error;
8
9/// The protocol generation this build speaks.
10pub const PROTOCOL_VERSION: &str = "v0";
11
12/// The mandatory data plane.
13pub const PROFILE_CORE_V0: &str = "core/v0";
14/// The optional maintenance plane.
15pub const PROFILE_ADMIN_V0: &str = "admin/v0";
16/// The optional derived-index query plane.
17pub const PROFILE_QUERY_V0: &str = "query/v0";
18
19/// Gates namespace creation.
20pub const FEATURE_NAMESPACES_CREATE: &str = "core.namespaces.create";
21/// Gates namespace forking.
22pub const FEATURE_NAMESPACES_FORK: &str = "core.namespaces.fork";
23/// Gates namespace deletion.
24pub const FEATURE_NAMESPACES_DELETE: &str = "core.namespaces.delete";
25/// Gates direct upload sessions that are authorized with short-lived presigned URLs.
26pub const FEATURE_UPLOADS_DIRECT_PUT: &str = "core.uploads.direct_put";
27/// Starting presigned `direct_multipart` upload sessions.
28pub const FEATURE_UPLOADS_DIRECT_MULTIPART: &str = "core.uploads.direct_multipart";
29/// Gates download grants that are authorized with short-lived presigned
30/// URLs. It rests on the same proof the two upload keys do, and is
31/// advertised with them, because a deployment that lets a client write an
32/// object it is too large to proxy back has to be able to hand it back.
33pub const FEATURE_DOWNLOADS_DIRECT_GET: &str = "core.downloads.direct_get";
34/// Gates grep-index content search: the serving half of the capability;
35/// the namespace's verified steady-state grep root is the data half.
36pub const FEATURE_QUERY_GREP: &str = "query.grep";
37
38/// Advisory limit: the largest request body accepted for service-proxied
39/// upload content requests.
40pub const LIMIT_UPLOAD_MAX_CONTENT_BYTES: &str = "upload.max_content_bytes";
41/// Advisory limit: the largest file content a service-proxied read will
42/// buffer and return in one response.
43pub const LIMIT_DOWNLOAD_MAX_CONTENT_BYTES: &str = "download.max_content_bytes";
44/// Advisory limit: how many service-proxied upload bodies the deployment
45/// buffers at once; requests past the cap answer `server_busy`.
46pub const LIMIT_UPLOAD_MAX_CONCURRENT: &str = "upload.max_concurrent";
47/// Advisory limit: how many service-proxied content reads the deployment
48/// materializes at once; requests past the cap answer `server_busy`.
49pub const LIMIT_DOWNLOAD_MAX_CONCURRENT: &str = "download.max_concurrent";
50/// Advisory limit: the most path operations one commit may carry; a longer
51/// list answers `invalid_request` before planning.
52pub const LIMIT_COMMIT_MAX_OPERATIONS: &str = "commit.max_operations";
53/// Advisory limit: the most content tokens one commit may carry.
54pub const LIMIT_COMMIT_MAX_CONTENT_TOKENS: &str = "commit.max_content_tokens";
55/// Advisory limit: the most distinct external content refs one commit's
56/// operations may name.
57pub const LIMIT_COMMIT_MAX_EXTERNAL_CONTENT_REFS: &str = "commit.max_external_content_refs";
58/// Advisory limit: the largest accepted commit `message`, in bytes.
59pub const LIMIT_COMMIT_MAX_MESSAGE_BYTES: &str = "commit.max_message_bytes";
60/// Advisory capability key for the default page size applied when callers omit `limit`.
61pub const LIMIT_PAGINATION_DEFAULT: &str = "pagination.default_limit";
62/// Advisory capability key for the largest page size accepted by a deployment.
63pub const LIMIT_PAGINATION_MAX: &str = "pagination.max_limit";
64/// Advisory limit: the smallest accepted `grace_window_ms` on a `gc`
65/// request; smaller values answer `invalid_request`. Derived from the
66/// publication budgets, not tuned.
67pub const LIMIT_GC_MIN_GRACE_WINDOW_MS: &str = "maintenance.gc.min_grace_window_ms";
68/// Advisory limit: matches per grep page when the request omits `limit`.
69pub const LIMIT_QUERY_GREP_DEFAULT: &str = "query.grep.default_limit";
70/// Advisory limit: the largest accepted grep page limit. Distinct from the
71/// pagination keys — a grep item costs a verified file read, not a row.
72pub const LIMIT_QUERY_GREP_MAX: &str = "query.grep.max_limit";
73/// Advisory limit: files a plan-less `allow_scan` grep will scan before
74/// refusing with `query_unindexable`.
75pub const LIMIT_QUERY_GREP_SCAN_BUDGET_FILES: &str = "query.grep.scan_budget_files";
76/// Advisory limit: unindexed-tail revisions one grep scans exhaustively
77/// before failing with `index_lagging`.
78pub const LIMIT_QUERY_GREP_TAIL_BUDGET_FILES: &str = "query.grep.tail_budget_files";
79
80/// A deployment's self-description (API spec, "Capability discovery").
81///
82/// A remote client fetches this from `GET /v0/capabilities` and caches it; an
83/// embedded engine exposes the same document as a constant. SDK gating logic
84/// is therefore identical for both backends: check [`supports`] or
85/// [`has_profile`], and treat a `not_supported` error as authoritative when
86/// the two disagree.
87///
88/// [`supports`]: CapabilityDocument::supports
89/// [`has_profile`]: CapabilityDocument::has_profile
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
92pub struct CapabilityDocument {
93    /// The protocol generation, currently `v0`.
94    pub protocol_version: String,
95    /// Advertised profiles, each `plane/version`. All-or-nothing: every
96    /// required op of an advertised profile is implemented.
97    pub profiles: Vec<String>,
98    /// Named features and whether this deployment supports them. An absent
99    /// key means unsupported.
100    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
101    pub features: BTreeMap<String, bool>,
102    /// Advisory numeric limits clients may use to pre-validate requests.
103    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
104    pub limits: BTreeMap<String, u64>,
105}
106
107/// Violation of the capability document rules.
108#[derive(Debug, Clone, PartialEq, Eq, Error)]
109pub enum CapabilityDocumentError {
110    /// Reports a feature whose dotted plane prefix has no advertised profile.
111    #[error(
112        "feature `{feature}` is not parented by an advertised profile \
113         (its first dotted segment must be one of the advertised plane names)"
114    )]
115    UnparentedFeature {
116        /// Feature key rejected while validating the deployment document.
117        feature: String,
118    },
119}
120
121impl CapabilityDocument {
122    /// Whether a profile (for example `core/v0`) is advertised.
123    pub fn has_profile(&self, profile: &str) -> bool {
124        self.profiles.iter().any(|advertised| advertised == profile)
125    }
126
127    /// Whether a feature is advertised as supported. Absent keys are
128    /// unsupported.
129    pub fn supports(&self, feature: &str) -> bool {
130        self.features.get(feature).copied().unwrap_or(false)
131    }
132
133    /// Checks the feature-key rule (API spec, "Capability discovery"): every
134    /// feature key's first dotted segment must be the plane name of an
135    /// advertised profile.
136    pub fn validate(&self) -> Result<(), CapabilityDocumentError> {
137        for feature in self.features.keys() {
138            if !self.feature_is_parented(feature) {
139                return Err(CapabilityDocumentError::UnparentedFeature {
140                    feature: feature.clone(),
141                });
142            }
143        }
144        Ok(())
145    }
146
147    /// Drops feature keys that violate the feature-key rule, the
148    /// client-side "ignore" handling for malformed documents.
149    pub fn retain_well_formed(&mut self) {
150        let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
151        self.features
152            .retain(|feature, _| feature_is_parented(&advertised_planes, feature));
153    }
154
155    fn feature_is_parented(&self, feature: &str) -> bool {
156        let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
157        feature_is_parented(&advertised_planes, feature)
158    }
159}
160
161fn feature_is_parented(planes: &[&str], feature: &str) -> bool {
162    match feature.split('.').next() {
163        Some(plane) if !plane.is_empty() => planes.contains(&plane),
164        _ => false,
165    }
166}
167
168/// The plane name of a profile: `core/v0` has plane `core`.
169fn plane_name(profile: &str) -> &str {
170    profile.split('/').next().unwrap_or(profile)
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    fn document() -> CapabilityDocument {
178        CapabilityDocument {
179            protocol_version: PROTOCOL_VERSION.to_owned(),
180            profiles: vec![PROFILE_CORE_V0.to_owned(), PROFILE_ADMIN_V0.to_owned()],
181            features: BTreeMap::from([
182                (FEATURE_NAMESPACES_CREATE.to_owned(), true),
183                (FEATURE_NAMESPACES_DELETE.to_owned(), false),
184            ]),
185            limits: BTreeMap::new(),
186        }
187    }
188
189    #[test]
190    fn supports_and_has_profile_answer_gating_questions() {
191        let document = document();
192        assert!(document.has_profile(PROFILE_CORE_V0));
193        assert!(!document.has_profile("query/v0"));
194        assert!(document.supports(FEATURE_NAMESPACES_CREATE));
195        // Advertised-false and absent keys are both unsupported.
196        assert!(!document.supports(FEATURE_NAMESPACES_DELETE));
197        assert!(!document.supports(FEATURE_NAMESPACES_FORK));
198    }
199
200    #[test]
201    fn feature_keys_must_be_parented_by_an_advertised_profile() {
202        let mut document = document();
203        document
204            .features
205            .insert("query.index.fulltext".to_owned(), true);
206
207        assert_eq!(
208            document.validate(),
209            Err(CapabilityDocumentError::UnparentedFeature {
210                feature: "query.index.fulltext".to_owned(),
211            })
212        );
213
214        document.retain_well_formed();
215        assert!(document.validate().is_ok());
216        assert!(!document.features.contains_key("query.index.fulltext"));
217        assert!(document.features.contains_key(FEATURE_NAMESPACES_CREATE));
218    }
219
220    #[test]
221    fn capability_document_round_trips_and_tolerates_unknown_fields() {
222        let document = document();
223        let encoded = serde_json::to_string(&document).expect("encode");
224        let decoded: CapabilityDocument = serde_json::from_str(&encoded).expect("decode");
225        assert_eq!(decoded, document);
226
227        let future = encoded.replacen('{', "{\"field_from_the_future\":true,", 1);
228        let decoded: CapabilityDocument =
229            serde_json::from_str(&future).expect("unknown fields are ignored");
230        assert_eq!(decoded, document);
231    }
232}