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