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 read-snapshot lifecycle operations.
26pub const FEATURE_SNAPSHOTS: &str = "core.snapshots";
27/// Gates inode attributes: writing them, and projecting them onto reads.
28/// Attributes are part of the core plane, not a composed extension, so a
29/// deployment that serves the core profile serves them.
30pub const FEATURE_ATTRIBUTES: &str = "core.attributes";
31/// Gates listing a directory's children by parent inode ID. Part of the core
32/// plane and implemented by the runtime, so current deployments advertise it;
33/// the key exists so inode-driven sync clients can gate on deployments built
34/// before the route.
35pub const FEATURE_INODES_LIST_CHILDREN: &str = "core.inodes.list_children";
36/// Gates direct upload sessions that are authorized with short-lived presigned URLs.
37pub const FEATURE_UPLOADS_DIRECT_PUT: &str = "core.uploads.direct_put";
38/// Starting presigned `direct_multipart` upload sessions. Independent of
39/// [`FEATURE_UPLOADS_DIRECT_PUT`]: a provider may sign whole-object writes
40/// without having an S3-style multipart API at all.
41pub const FEATURE_UPLOADS_DIRECT_MULTIPART: &str = "core.uploads.direct_multipart";
42/// Gates download grants that are authorized with short-lived presigned
43/// URLs. A deployment that offers any direct transfer advertises this one,
44/// because letting a client write an object too large to proxy back means
45/// being able to hand it back.
46pub const FEATURE_DOWNLOADS_DIRECT_GET: &str = "core.downloads.direct_get";
47
48/// Gates grep-index content search: the serving half of the capability;
49/// the namespace's verified active grep root is the data half.
50pub const FEATURE_QUERY_GREP: &str = "query.grep";
51
52/// Gates grep-index administration: enabling a namespace's grep root,
53/// disabling it, collecting its garbage, and reading its lifecycle.
54///
55/// The maintenance half of the same capability, and independent of
56/// [`FEATURE_QUERY_GREP`]: searching an index and keeping one built are
57/// separately deployable, so a deployment may advertise either alone. It is
58/// an `admin.` key because its routes are admin routes, and because a
59/// deployment that maintains an index it does not serve advertises no
60/// `query/v0` profile for a `query.` key to be parented by.
61pub const FEATURE_ADMIN_GREP_INDEX: &str = "admin.grep.index";
62
63/// Advisory limit: the largest request body accepted for service-proxied
64/// upload content requests. This is the proxy's cap, not the provider's.
65pub const LIMIT_UPLOAD_MAX_CONTENT_BYTES: &str = "upload.max_content_bytes";
66/// Advisory limit: the largest object this deployment's provider accepts in
67/// one presigned `direct_put` request.
68///
69/// Unrelated to [`LIMIT_UPLOAD_MAX_CONTENT_BYTES`], which bounds what the
70/// service will buffer on a client's behalf. This one is the provider's own
71/// single-request ceiling, and it is typically far larger; a claim above it
72/// answers `content_too_large` at begin rather than being signed into a
73/// write the provider would reject.
74pub const LIMIT_UPLOAD_DIRECT_PUT_MAX_CONTENT_BYTES: &str = "upload.direct_put_max_content_bytes";
75/// Advisory limit: the largest JSON body accepted when completing an upload.
76/// It is large enough for the maximum number of multipart entries.
77pub const LIMIT_UPLOAD_COMPLETION_MAX_BODY_BYTES: &str = "upload.completion_max_body_bytes";
78/// Advisory limit: the largest file content a service-proxied read will
79/// buffer and return in one response.
80pub const LIMIT_DOWNLOAD_MAX_CONTENT_BYTES: &str = "download.max_content_bytes";
81/// Advisory limit: how many service-proxied upload bodies the deployment
82/// buffers at once; requests past the cap answer `server_busy`.
83pub const LIMIT_UPLOAD_MAX_CONCURRENT: &str = "upload.max_concurrent";
84/// Advisory limit: how many service-proxied content reads the deployment
85/// materializes at once; requests past the cap answer `server_busy`.
86pub const LIMIT_DOWNLOAD_MAX_CONCURRENT: &str = "download.max_concurrent";
87/// Advisory limit: the largest snapshot TTL one request may ask for.
88pub const LIMIT_SNAPSHOT_MAX_TTL_MS: &str = "snapshot.max_ttl_ms";
89/// Advisory limit: the largest snapshot expiry measured from record creation.
90pub const LIMIT_SNAPSHOT_MAX_LIFETIME_MS: &str = "snapshot.max_lifetime_ms";
91/// Advisory limit: the most live snapshots one namespace may hold.
92pub const LIMIT_SNAPSHOT_MAX_LIVE_PER_NAMESPACE: &str = "snapshot.max_live_per_namespace";
93/// Advisory limit: the most path operations one commit may carry; a longer
94/// list answers `invalid_request` before planning.
95pub const LIMIT_COMMIT_MAX_OPERATIONS: &str = "commit.max_operations";
96/// Advisory limit: the most content tokens one commit may carry.
97pub const LIMIT_COMMIT_MAX_CONTENT_TOKENS: &str = "commit.max_content_tokens";
98/// Advisory limit: the most distinct external content refs one commit's
99/// operations may name.
100pub const LIMIT_COMMIT_MAX_EXTERNAL_CONTENT_REFS: &str = "commit.max_external_content_refs";
101/// Advisory limit: the largest accepted commit `message`, in bytes.
102pub const LIMIT_COMMIT_MAX_MESSAGE_BYTES: &str = "commit.max_message_bytes";
103/// Advisory capability key for the default page size applied when callers omit `limit`.
104pub const LIMIT_PAGINATION_DEFAULT: &str = "pagination.default_limit";
105/// Advisory capability key for the largest page size accepted by a deployment.
106pub const LIMIT_PAGINATION_MAX: &str = "pagination.max_limit";
107/// Advisory limit: the smallest accepted `grace_window_ms` on a `gc`
108/// request; smaller values answer `invalid_request`. Derived from the
109/// publication budgets, not tuned.
110pub const LIMIT_GC_MIN_GRACE_WINDOW_MS: &str = "maintenance.gc.min_grace_window_ms";
111/// Advisory limit: matches per grep page when the request omits `limit`.
112pub const LIMIT_QUERY_GREP_DEFAULT: &str = "query.grep.default_limit";
113/// Advisory limit: the largest accepted grep page limit. Distinct from the
114/// pagination keys — a grep item costs a verified file read, not a row.
115pub const LIMIT_QUERY_GREP_MAX: &str = "query.grep.max_limit";
116/// Advisory limit: files a plan-less `allow_scan` grep will scan before
117/// refusing with `query_unindexable`.
118pub const LIMIT_QUERY_GREP_SCAN_BUDGET_FILES: &str = "query.grep.scan_budget_files";
119/// Advisory limit: unindexed-tail revisions one grep scans exhaustively
120/// before failing with `index_lagging`.
121pub const LIMIT_QUERY_GREP_TAIL_BUDGET_FILES: &str = "query.grep.tail_budget_files";
122
123/// A deployment's self-description (API spec, "Capability discovery").
124///
125/// A remote client fetches this from `GET /v0/capabilities` and caches it; an
126/// embedded engine exposes the same document as a constant. SDK gating logic
127/// is therefore identical for both backends: check [`supports`] or
128/// [`has_profile`], and treat a `not_supported` error as authoritative when
129/// the two disagree.
130///
131/// [`supports`]: CapabilityDocument::supports
132/// [`has_profile`]: CapabilityDocument::has_profile
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
134#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
135pub struct CapabilityDocument {
136    /// The protocol generation, currently `v0`.
137    pub protocol_version: String,
138    /// Advertised profiles, each `plane/version`. All-or-nothing: every
139    /// required op of an advertised profile is implemented.
140    pub profiles: Vec<String>,
141    /// Named features and whether this deployment supports them. An absent
142    /// key means unsupported.
143    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
144    pub features: BTreeMap<String, bool>,
145    /// Advisory numeric limits clients may use to pre-validate requests.
146    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
147    pub limits: BTreeMap<String, u64>,
148}
149
150/// Violation of the capability document rules.
151#[derive(Debug, Clone, PartialEq, Eq, Error)]
152pub enum CapabilityDocumentError {
153    /// Reports a feature whose dotted plane prefix has no advertised profile.
154    #[error(
155        "feature `{feature}` is not parented by an advertised profile \
156         (its first dotted segment must be one of the advertised plane names)"
157    )]
158    UnparentedFeature {
159        /// Feature key rejected while validating the deployment document.
160        feature: String,
161    },
162}
163
164impl CapabilityDocument {
165    /// Whether a profile (for example `core/v0`) is advertised.
166    pub fn has_profile(&self, profile: &str) -> bool {
167        self.profiles.iter().any(|advertised| advertised == profile)
168    }
169
170    /// Whether a feature is advertised as supported. Absent keys are
171    /// unsupported.
172    pub fn supports(&self, feature: &str) -> bool {
173        self.features.get(feature).copied().unwrap_or(false)
174    }
175
176    /// The largest object this deployment's provider accepts in one
177    /// `direct_put` request, when it advertises the limit.
178    pub fn direct_put_max_content_bytes(&self) -> Option<u64> {
179        self.limits
180            .get(LIMIT_UPLOAD_DIRECT_PUT_MAX_CONTENT_BYTES)
181            .copied()
182    }
183
184    /// Checks the feature-key rule (API spec, "Capability discovery"): every
185    /// feature key's first dotted segment must be the plane name of an
186    /// advertised profile.
187    pub fn validate(&self) -> Result<(), CapabilityDocumentError> {
188        for feature in self.features.keys() {
189            if !self.feature_is_parented(feature) {
190                return Err(CapabilityDocumentError::UnparentedFeature {
191                    feature: feature.clone(),
192                });
193            }
194        }
195        Ok(())
196    }
197
198    /// Drops feature keys that violate the feature-key rule, the
199    /// client-side "ignore" handling for malformed documents.
200    pub fn retain_well_formed(&mut self) {
201        let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
202        self.features
203            .retain(|feature, _| feature_is_parented(&advertised_planes, feature));
204    }
205
206    fn feature_is_parented(&self, feature: &str) -> bool {
207        let advertised_planes: Vec<&str> = self.profiles.iter().map(|p| plane_name(p)).collect();
208        feature_is_parented(&advertised_planes, feature)
209    }
210}
211
212fn feature_is_parented(planes: &[&str], feature: &str) -> bool {
213    match feature.split('.').next() {
214        Some(plane) if !plane.is_empty() => planes.contains(&plane),
215        _ => false,
216    }
217}
218
219/// The plane name of a profile: `core/v0` has plane `core`.
220fn plane_name(profile: &str) -> &str {
221    profile.split('/').next().unwrap_or(profile)
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    fn document() -> CapabilityDocument {
229        CapabilityDocument {
230            protocol_version: PROTOCOL_VERSION.to_owned(),
231            profiles: vec![PROFILE_CORE_V0.to_owned(), PROFILE_ADMIN_V0.to_owned()],
232            features: BTreeMap::from([
233                (FEATURE_NAMESPACES_CREATE.to_owned(), true),
234                (FEATURE_NAMESPACES_DELETE.to_owned(), false),
235            ]),
236            limits: BTreeMap::new(),
237        }
238    }
239
240    #[test]
241    fn supports_and_has_profile_answer_gating_questions() {
242        let document = document();
243        assert!(document.has_profile(PROFILE_CORE_V0));
244        assert!(!document.has_profile("query/v0"));
245        assert!(document.supports(FEATURE_NAMESPACES_CREATE));
246        // Advertised-false and absent keys are both unsupported.
247        assert!(!document.supports(FEATURE_NAMESPACES_DELETE));
248        assert!(!document.supports(FEATURE_NAMESPACES_FORK));
249    }
250
251    #[test]
252    fn feature_keys_must_be_parented_by_an_advertised_profile() {
253        let mut document = document();
254        document
255            .features
256            .insert("query.index.fulltext".to_owned(), true);
257
258        assert_eq!(
259            document.validate(),
260            Err(CapabilityDocumentError::UnparentedFeature {
261                feature: "query.index.fulltext".to_owned(),
262            })
263        );
264
265        document.retain_well_formed();
266        assert!(document.validate().is_ok());
267        assert!(!document.features.contains_key("query.index.fulltext"));
268        assert!(document.features.contains_key(FEATURE_NAMESPACES_CREATE));
269    }
270
271    #[test]
272    fn capability_document_round_trips_and_tolerates_unknown_fields() {
273        let document = document();
274        let encoded = serde_json::to_string(&document).expect("encode");
275        let decoded: CapabilityDocument = serde_json::from_str(&encoded).expect("decode");
276        assert_eq!(decoded, document);
277
278        let future = encoded.replacen('{', "{\"field_from_the_future\":true,", 1);
279        let decoded: CapabilityDocument =
280            serde_json::from_str(&future).expect("unknown fields are ignored");
281        assert_eq!(decoded, document);
282    }
283}