Skip to main content

loonfs_api/
capability.rs

1//! API groups, features, and limits advertised by a deployment.
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use thiserror::Error;
6
7/// The protocol generation this build speaks.
8pub const PROTOCOL_VERSION: &str = "v0";
9
10/// The mandatory filesystem API group.
11pub const API_GROUP_FILESYSTEM_V0: &str = "filesystem/v0";
12/// The optional maintenance API group.
13pub const API_GROUP_MAINTENANCE_V0: &str = "maintenance/v0";
14/// The optional derived-index query API group.
15pub const API_GROUP_QUERY_V0: &str = "query/v0";
16
17/// Gates namespace creation.
18pub const FEATURE_NAMESPACES_CREATE: &str = "filesystem.namespaces.create";
19/// Gates namespace forking.
20pub const FEATURE_NAMESPACES_FORK: &str = "filesystem.namespaces.fork";
21/// Gates namespace deletion.
22pub const FEATURE_NAMESPACES_DELETE: &str = "filesystem.namespaces.delete";
23/// Gates read-snapshot lifecycle operations.
24pub const FEATURE_SNAPSHOTS: &str = "filesystem.snapshots";
25/// Gates inode attributes: writing them, and projecting them onto reads.
26/// Attributes are part of the filesystem API group, not a composed extension,
27/// so a deployment that serves the filesystem API group serves them.
28pub const FEATURE_ATTRIBUTES: &str = "filesystem.attributes";
29/// Gates listing a directory's children by parent inode ID. Part of the filesystem
30/// API group and implemented by the runtime, so current deployments advertise it;
31/// the key exists so inode-driven sync clients can gate on deployments built
32/// before the route.
33pub const FEATURE_INODES_LIST_CHILDREN: &str = "filesystem.inodes.list_children";
34/// Gates inline bytes on commit operations.
35pub const FEATURE_COMMIT_INLINE_CONTENT: &str = "filesystem.commits.inline_content";
36/// Gates direct upload sessions that are authorized with short-lived presigned URLs.
37pub const FEATURE_UPLOADS_DIRECT_PUT: &str = "filesystem.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 = "filesystem.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 = "filesystem.downloads.direct_get";
47
48/// Gates grep index content search: the serving half of the capability;
49/// the namespace's verified active grep manifest is the data half.
50pub const FEATURE_QUERY_GREP: &str = "query.grep";
51
52/// Gates grep index maintenance: enabling a namespace's grep manifest,
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/// a `maintenance.` key because its routes are maintenance routes, and because a
59/// deployment that maintains an index it does not serve advertises no
60/// `query/v0` API group for a `query.` key to be parented by.
61pub const FEATURE_MAINTENANCE_GREP_INDEX: &str = "maintenance.grep.index";
62
63macro_rules! limit_keys {
64    ($( $(#[$meta:meta])* $name:ident = $value:literal; )+) => {
65        $(
66            $(#[$meta])*
67            pub const $name: &str = $value;
68        )+
69
70        /// Every registered advisory limit key.
71        pub const ALL_LIMIT_KEYS: &[&str] = &[$($name),+];
72    };
73}
74
75limit_keys! {
76    /// Advisory limit: the largest request body accepted for service-proxied
77    /// upload content requests. This is the proxy's cap, not the provider's.
78    LIMIT_UPLOAD_MAX_CONTENT_BYTES = "upload.max_content_bytes";
79    /// Advisory limit: the largest object this deployment's provider accepts in
80    /// one presigned `direct_put` request.
81    ///
82    /// Unrelated to [`LIMIT_UPLOAD_MAX_CONTENT_BYTES`], which bounds what the
83    /// service will buffer on a client's behalf. This one is the provider's own
84    /// single-request ceiling, and it is typically far larger; a claim above it
85    /// answers `content_too_large` at begin rather than being signed into a
86    /// write the provider would reject.
87    LIMIT_UPLOAD_DIRECT_PUT_MAX_CONTENT_BYTES = "upload.direct_put_max_content_bytes";
88    /// Advisory limit: the largest JSON body accepted when completing an upload.
89    /// It is large enough for the maximum number of multipart entries.
90    LIMIT_UPLOAD_COMPLETION_MAX_BODY_BYTES = "upload.completion_max_body_bytes";
91    /// Advisory limit: the largest file content a service-proxied read will
92    /// buffer and return in one response.
93    LIMIT_DOWNLOAD_MAX_CONTENT_BYTES = "download.max_content_bytes";
94    /// Advisory limit: how many service-proxied upload bodies the deployment
95    /// buffers at once; requests past the cap answer `server_busy`.
96    LIMIT_UPLOAD_MAX_CONCURRENT = "upload.max_concurrent";
97    /// Advisory limit: how many service-proxied content reads the deployment
98    /// materializes at once; requests past the cap answer `server_busy`.
99    LIMIT_DOWNLOAD_MAX_CONCURRENT = "download.max_concurrent";
100    /// Advisory limit: the largest snapshot TTL one request may ask for.
101    LIMIT_SNAPSHOT_MAX_TTL_MS = "snapshot.max_ttl_ms";
102    /// Advisory limit: the largest snapshot expiry measured from record creation.
103    LIMIT_SNAPSHOT_MAX_LIFETIME_MS = "snapshot.max_lifetime_ms";
104    /// Advisory limit: the most live snapshots one namespace may hold.
105    LIMIT_SNAPSHOT_MAX_LIVE_PER_NAMESPACE = "snapshot.max_live_per_namespace";
106    /// Advisory limit: the most path operations one commit may carry; a longer
107    /// list answers `invalid_request` before planning.
108    LIMIT_COMMIT_MAX_OPERATIONS = "commit.max_operations";
109    /// Largest inline value accepted on a commit operation.
110    LIMIT_COMMIT_MAX_INLINE_CONTENT_BYTES = "commit.max_inline_content_bytes";
111    /// Most principal ids one request may act as.
112    LIMIT_ACCESS_MAX_PRINCIPALS = "access.max_principals";
113    /// Counts precondition entries, not resources; a longer list answers
114    /// `invalid_request` before planning.
115    LIMIT_COMMIT_MAX_PRECONDITIONS = "commit.max_preconditions";
116    /// Advisory limit: the most content tokens one commit may carry.
117    LIMIT_COMMIT_MAX_CONTENT_TOKENS = "commit.max_content_tokens";
118    /// Advisory limit: the most distinct external content refs one commit's
119    /// operations may name.
120    LIMIT_COMMIT_MAX_EXTERNAL_CONTENT_REFS = "commit.max_external_content_refs";
121    /// Advisory limit: the largest accepted commit `message`, in bytes.
122    LIMIT_COMMIT_MAX_MESSAGE_BYTES = "commit.max_message_bytes";
123    /// Advisory capability key for the default page size applied when callers omit `limit`.
124    LIMIT_PAGINATION_DEFAULT = "pagination.default_limit";
125    /// Advisory capability key for the largest page size accepted by a deployment.
126    LIMIT_PAGINATION_MAX = "pagination.max_limit";
127    /// Advisory limit: the smallest accepted `grace_window_ms` on a `gc`
128    /// request; smaller values answer `invalid_request`. Derived from the
129    /// publication budgets, not tuned.
130    LIMIT_GC_MIN_GRACE_WINDOW_MS = "maintenance.gc.min_grace_window_ms";
131    /// Advisory limit: matches per grep page when the request omits `limit`.
132    LIMIT_QUERY_GREP_DEFAULT = "query.grep.default_limit";
133    /// Advisory limit: the largest accepted grep page limit. Distinct from the
134    /// pagination keys — a grep item costs a verified file read, not a row.
135    LIMIT_QUERY_GREP_MAX = "query.grep.max_limit";
136    /// Advisory limit: files a plan-less `allow_scan` grep will scan before
137    /// refusing with `query_unindexable`.
138    LIMIT_QUERY_GREP_SCAN_BUDGET_FILES = "query.grep.scan_budget_files";
139    /// Advisory limit: unindexed-tail revisions one grep scans exhaustively
140    /// before failing with `index_lagging`.
141    LIMIT_QUERY_GREP_TAIL_BUDGET_FILES = "query.grep.tail_budget_files";
142}
143
144/// The API groups, features, and limits advertised by a deployment.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
147pub struct CapabilityDocument {
148    /// The protocol generation, currently `v0`.
149    pub protocol_version: String,
150    /// The advertised `group/version` API groups, each with every required operation implemented.
151    pub api_groups: Vec<String>,
152    /// The named features supported by this deployment, with absent keys treated as unsupported.
153    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
154    pub features: BTreeMap<String, bool>,
155    /// Advisory numeric limits clients may use to pre-validate requests.
156    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
157    pub limits: BTreeMap<String, u64>,
158}
159
160/// Violation of the capability document rules.
161#[derive(Debug, Clone, PartialEq, Eq, Error)]
162pub enum CapabilityDocumentError {
163    /// Reports a feature whose dotted group prefix has no advertised API group.
164    #[error(
165        "feature `{feature}` is not parented by an advertised API group \
166         (its first dotted segment must be one of the advertised API group names)"
167    )]
168    UnparentedFeature {
169        /// Feature key rejected while validating the deployment document.
170        feature: String,
171    },
172}
173
174impl CapabilityDocument {
175    /// Whether a feature is advertised as supported. Absent keys are
176    /// unsupported.
177    pub fn supports(&self, feature: &str) -> bool {
178        self.features.get(feature).copied().unwrap_or(false)
179    }
180
181    /// The largest object this deployment's provider accepts in one
182    /// `direct_put` request, when it advertises the limit.
183    pub fn direct_put_max_content_bytes(&self) -> Option<u64> {
184        self.limits
185            .get(LIMIT_UPLOAD_DIRECT_PUT_MAX_CONTENT_BYTES)
186            .copied()
187    }
188
189    /// Checks the feature-key rule (API spec, "Capability discovery"): every
190    /// feature key's first dotted segment must be the group name of an
191    /// advertised API group.
192    pub fn validate(&self) -> Result<(), CapabilityDocumentError> {
193        let advertised_api_groups: Vec<&str> = self
194            .api_groups
195            .iter()
196            .map(|api_group| api_group_name(api_group))
197            .collect();
198        for feature in self.features.keys() {
199            if !feature_is_parented(&advertised_api_groups, feature) {
200                return Err(CapabilityDocumentError::UnparentedFeature {
201                    feature: feature.clone(),
202                });
203            }
204        }
205        Ok(())
206    }
207
208    /// Drops feature keys that violate the feature-key rule, the
209    /// client-side "ignore" handling for malformed documents.
210    pub fn retain_well_formed(&mut self) {
211        let advertised_api_groups: Vec<&str> = self
212            .api_groups
213            .iter()
214            .map(|api_group| api_group_name(api_group))
215            .collect();
216        self.features
217            .retain(|feature, _| feature_is_parented(&advertised_api_groups, feature));
218    }
219}
220
221fn feature_is_parented(api_group_names: &[&str], feature: &str) -> bool {
222    match feature.split('.').next() {
223        Some(api_group) if !api_group.is_empty() => api_group_names.contains(&api_group),
224        _ => false,
225    }
226}
227
228/// The group name of a versioned API group: `filesystem/v0` has group `filesystem`.
229fn api_group_name(api_group: &str) -> &str {
230    api_group.split('/').next().unwrap_or(api_group)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    fn document() -> CapabilityDocument {
238        CapabilityDocument {
239            protocol_version: PROTOCOL_VERSION.to_owned(),
240            api_groups: vec![
241                API_GROUP_FILESYSTEM_V0.to_owned(),
242                API_GROUP_MAINTENANCE_V0.to_owned(),
243            ],
244            features: BTreeMap::from([
245                (FEATURE_NAMESPACES_CREATE.to_owned(), true),
246                (FEATURE_NAMESPACES_DELETE.to_owned(), false),
247            ]),
248            limits: BTreeMap::new(),
249        }
250    }
251
252    #[test]
253    fn supports_answers_gating_questions() {
254        let document = document();
255        assert!(document.supports(FEATURE_NAMESPACES_CREATE));
256        // Advertised-false and absent keys are both unsupported.
257        assert!(!document.supports(FEATURE_NAMESPACES_DELETE));
258        assert!(!document.supports(FEATURE_NAMESPACES_FORK));
259    }
260
261    #[test]
262    fn feature_keys_must_be_parented_by_an_advertised_api_group() {
263        let mut document = document();
264        document
265            .features
266            .insert("query.index.fulltext".to_owned(), true);
267
268        assert_eq!(
269            document.validate(),
270            Err(CapabilityDocumentError::UnparentedFeature {
271                feature: "query.index.fulltext".to_owned(),
272            })
273        );
274
275        document.retain_well_formed();
276        assert!(document.validate().is_ok());
277        assert!(!document.features.contains_key("query.index.fulltext"));
278        assert!(document.features.contains_key(FEATURE_NAMESPACES_CREATE));
279    }
280
281    #[test]
282    fn capability_document_round_trips_and_tolerates_unknown_fields() {
283        let document = document();
284        let encoded = serde_json::to_string(&document).expect("encode");
285        assert!(encoded.contains("\"api_groups\""));
286        let decoded: CapabilityDocument = serde_json::from_str(&encoded).expect("decode");
287        assert_eq!(decoded, document);
288
289        for old_field_name in [concat!("pl", "anes"), "profiles"] {
290            let old_field = encoded.replace("\"api_groups\"", &format!("\"{old_field_name}\""));
291            assert!(serde_json::from_str::<CapabilityDocument>(&old_field).is_err());
292        }
293
294        let future = encoded.replacen('{', "{\"field_from_the_future\":true,", 1);
295        let decoded: CapabilityDocument =
296            serde_json::from_str(&future).expect("unknown fields are ignored");
297        assert_eq!(decoded, document);
298    }
299}