Skip to main content

loonfs_client/
lib.rs

1//! Async HTTP client for a LoonFS server.
2//!
3//! Use this crate when your process should talk to a hosted LoonFS runtime
4//! instead of embedding the runtime directly. The client keeps paths simple:
5//! pass a [`NamespacePath`] for filesystem operations and use explicit commit
6//! helpers when you need retry control.
7//!
8//! The public surface is [`Client`] and the values it takes: [`ClientConfig`],
9//! [`ClientError`], [`NamespacePath`], and the per-operation option structs
10//! re-exported below. There is no transport abstraction to implement — a
11//! process that wants the runtime in-process uses the `loonfs` crate instead,
12//! and the two surfaces share one definition of every option struct (they
13//! live in `loonfs-api`) so their arguments cannot drift apart.
14
15mod config;
16mod error;
17mod payload;
18mod transport;
19
20use bytes::Bytes;
21use futures::StreamExt as _;
22use loonfs_api::{
23    v0::{
24        AbortUploadResponse, BeginDownloadRequest, BeginDownloadResponse, BeginUploadRequest,
25        BeginUploadResponse, ChangesResponse, CommitResponse as ApiCommitResponse, CommittedChange,
26        CompleteUploadRequest, CompleteUploadResponse, CompletedUploadPart,
27        DirectMultipartContentClaim, DirectMultipartUploadOptions, DirectPutContentClaim,
28        DisableGrepIndexResponse, EnableGrepIndexResponse, FilesystemChange, GrepGcRequest,
29        GrepGcResponse, GrepIndexStatusResponse, ObjectTransferAccess, SignUploadPartsRequest,
30        SignUploadPartsResponse, SignedUploadPart, StoreProbeRequest, StoreProbeResponse,
31        UploadContentResponse, UploadPartChecksumClaim, UploadStatusResponse,
32        ValidatedContentToken,
33    },
34    AbsolutePath, AuthoritativePathEntry, CapabilityDocument, ChangeSeq, CheckpointId,
35    ChecksumAlgorithm, CommitId, CommitRequest, ContentRef, Crc64Nvme, CreateCheckpointRequest,
36    CreateCheckpointResponse, CreateNamespaceRequest, DeleteNamespaceResponse, ErrorCode,
37    FilesystemOperation, ForkNamespaceRequest, GrepRequest, GrepResponse, InodeId,
38    ListCheckpointsResponse, ListFileRevisionsResponse, ListPathEntriesResponse, ListTrashResponse,
39    MaintenanceStepRequest, MaintenanceStepResponse, NamespaceId, NamespaceStatusResponse,
40    NamespaceSummary, ReleaseCheckpointResponse, RevisionNo, Sha256, StorageChecksum, UploadId,
41    FEATURE_DOWNLOADS_DIRECT_GET, FEATURE_UPLOADS_DIRECT_MULTIPART,
42    LIMIT_DOWNLOAD_MAX_CONTENT_BYTES,
43};
44use payload::PartReader;
45use std::sync::{Arc, OnceLock};
46
47/// Payload size from which a put stops holding its bytes whole.
48///
49/// It mirrors the server's multipart part size, and that one number answers
50/// two questions the same way: below it a direct multipart upload would be a
51/// one-part upload with extra round trips and nothing to gain, and a payload
52/// that fits in a single part is not worth streaming either. At or above it
53/// — and for any payload whose length is not known in advance — a put reads
54/// its source once, in bounded pieces.
55pub const STREAMING_PUT_MIN_BYTES: u64 = 8 * 1024 * 1024;
56
57/// Parts in flight at once. Each holds its bytes, so a one-pass upload's
58/// memory is this many parts and no more.
59const DIRECT_MULTIPART_PARTS_IN_FLIGHT: usize = 4;
60
61/// Attempts one part gets before its upload gives up. A retry re-asks for
62/// the part's URL, because the first thing that goes stale about a part is
63/// its signature.
64const DIRECT_MULTIPART_PART_ATTEMPTS: usize = 3;
65
66/// What a retry can still say about the payload it just uploaded.
67///
68/// A buffered put can answer any question about its bytes by hashing them
69/// again. A one-pass put cannot: its payload went by once and is gone, and
70/// what it kept instead is the verified description the server gave back.
71/// Both are evidence about the same bytes; they differ only in which
72/// questions they can answer, and a question neither can answer is reported
73/// as such rather than guessed at.
74#[derive(Debug, Clone, Copy)]
75enum UploadedContent<'a> {
76    /// The payload itself, which can produce any digest this build knows.
77    Bytes(&'a [u8]),
78    /// The reference the server minted for a payload that was streamed
79    /// past: its length, and the digest whoever hashed it reported.
80    Streamed(&'a ContentRef),
81}
82
83impl UploadedContent<'_> {
84    /// Whether the upload's bytes produce this checksum.
85    ///
86    /// `None` is a refusal to answer — the algorithm is one this build
87    /// cannot recompute, or one nobody computed over the streamed payload —
88    /// and a caller must never read it as agreement.
89    fn matches(&self, expected: &StorageChecksum) -> Option<bool> {
90        match self {
91            Self::Bytes(bytes) => expected.matches(bytes),
92            Self::Streamed(content_ref) => {
93                let observed = digest_of(content_ref, expected.algorithm)?;
94                Some(observed == expected.value)
95            }
96        }
97    }
98}
99
100/// The digest a reference carries under one algorithm, if it carries one.
101fn digest_of(content_ref: &ContentRef, algorithm: ChecksumAlgorithm) -> Option<&str> {
102    if content_ref.storage_checksum.algorithm == algorithm {
103        return Some(&content_ref.storage_checksum.value);
104    }
105    match algorithm {
106        ChecksumAlgorithm::Sha256 => content_ref.whole_file_sha256.as_deref(),
107        _ => None,
108    }
109}
110
111/// Whether the committed reference provably holds the bytes just uploaded.
112///
113/// The evidence is the trusted whole-file digest when the server has one,
114/// and otherwise the reference's own storage checksum — which for a
115/// provider-assembled multipart object is the only full-object evidence
116/// that exists. Only a digest this client can recompute, over bytes that
117/// agree, proves anything: a digest that disagrees and a digest this client
118/// cannot recompute are both unproven, and both leave the conflict
119/// standing.
120fn uploaded_matches_committed(uploaded: &UploadedContent<'_>, content_ref: &ContentRef) -> bool {
121    let evidence = match &content_ref.whole_file_sha256 {
122        Some(digest) => StorageChecksum {
123            algorithm: ChecksumAlgorithm::Sha256,
124            value: digest.clone(),
125        },
126        None => content_ref.storage_checksum.clone(),
127    };
128    uploaded.matches(&evidence) == Some(true)
129}
130
131/// What a reuse conflict says the commit id already landed as: where, and
132/// the semantic identity of the mutation that landed there.
133///
134/// The server decides the conflict against a durable receipt, and the
135/// receipt holds both, so the error body carries both. They are absent only
136/// when nothing has committed under the id yet — two conflicting requests
137/// claiming it at once — and then there is nothing to read back.
138fn reported_commit_receipt(error: &ClientError) -> Option<(ChangeSeq, String)> {
139    match error {
140        ClientError::Api { details, .. } => {
141            let details = details.as_ref()?;
142            Some((
143                details.committed_seq?,
144                details.committed_fingerprint.clone()?,
145            ))
146        }
147        _ => None,
148    }
149}
150
151/// The content one committed change wrote, when it wrote exactly one.
152///
153/// A single put's commit produces exactly one content-bearing event: the
154/// file's `created` or `content_changed`. Auto-created parent directories
155/// appear as `created` without a content ref, and no other change kind
156/// carries content at all, so "exactly one" holds for a put however many
157/// directories it had to make. Anything else — nothing, or several — is not
158/// the commit a single put produces, and the caller leaves the conflict
159/// standing rather than picking one.
160fn sole_committed_content_ref(change: &CommittedChange) -> Option<&ContentRef> {
161    let mut content = change.events.iter().filter_map(|event| match event {
162        FilesystemChange::Created { content_ref, .. } => content_ref.as_ref(),
163        FilesystemChange::ContentChanged { content_ref, .. } => Some(content_ref),
164        _ => None,
165    });
166    let only = content.next()?;
167    content.next().is_none().then_some(only)
168}
169
170pub use config::ClientConfig;
171pub use error::ClientError;
172pub use payload::{PayloadSource, PayloadStream};
173use transport::{WireRequest, IO_INACTIVITY_TIMEOUT};
174pub use ClientError as Error;
175
176/// Per-operation options, defined once in `loonfs-api` and shared with the
177/// embedded `loonfs` runtime so the two surfaces cannot drift a field apart.
178pub use loonfs_api::options::{
179    CopyOptions, CreateDirectoryOptions, DeleteOptions, MoveOptions, PutFileOptions,
180    RestoreRevisionOptions, UndeleteOptions,
181};
182
183/// Result type returned by the client.
184pub type Result<T> = std::result::Result<T, ClientError>;
185
186/// Async HTTP client for LoonFS.
187///
188/// Cloning is cheap: clones share one connection pool and one capability
189/// cache.
190#[derive(Debug, Clone)]
191pub struct Client {
192    base_url: String,
193    auth_token: Option<String>,
194    http: reqwest::Client,
195    /// Whether transient server errors are retried (see
196    /// [`ClientConfig::disable_transient_retry`]).
197    transient_retry: bool,
198    /// Capability document cache, shared by clones and filled on first use.
199    capabilities: Arc<OnceLock<CapabilityDocument>>,
200}
201
202/// One direct download response, delivered in bounded chunks and verified
203/// against the content reference carried by its grant.
204///
205/// Verification completes only when [`Self::next_chunk`] returns `None`.
206/// A caller that stops earlier has received provisional bytes, just as with
207/// any streaming read whose digest cannot be known until the end.
208pub struct DirectDownloadStream {
209    body: payload::PayloadStream,
210    expected: ContentRef,
211    path: AbsolutePath,
212    sha256: Option<Sha256>,
213    size_bytes: u64,
214    /// Offset this stream was opened at: zero for the whole object, and the
215    /// length of what the caller already holds for a resumed download.
216    resumed_from: u64,
217    /// How much of that head start the caller has folded in. Nothing is
218    /// read until it reaches `resumed_from`, because the verdict is over
219    /// the whole object either way.
220    prefix_folded: u64,
221    finished: bool,
222}
223
224impl DirectDownloadStream {
225    /// Hands the stream part of what the caller already holds, in order,
226    /// from the object's first byte.
227    ///
228    /// A resumed download still checks the whole object's digest, so the
229    /// bytes it will never receive have to be folded into the same hash as
230    /// the ones it does. Feeding the wrong bytes fails the download at its
231    /// end, which is right: the grant's reference is the authority on what
232    /// the object holds, not the partial copy on the caller's disk.
233    pub fn fold_resumed_prefix(&mut self, bytes: &[u8]) {
234        if let Some(sha256) = self.sha256.as_mut() {
235            sha256.update(bytes);
236        }
237        self.prefix_folded = self.prefix_folded.saturating_add(bytes.len() as u64);
238    }
239
240    /// Returns the next response-body chunk, or `None` once the complete
241    /// object has passed its declared-length and whole-file digest checks.
242    pub async fn next_chunk(&mut self) -> Result<Option<Bytes>> {
243        if self.prefix_folded != self.resumed_from {
244            return Err(ClientError::Http(format!(
245                "a download of `{}` resumed at offset {} was given {} bytes of what it \
246                 skipped; verification covers the whole object, so all of them are needed \
247                 first",
248                self.path, self.resumed_from, self.prefix_folded
249            )));
250        }
251        if self.finished {
252            return Ok(None);
253        }
254        match self.body.next().await {
255            Some(Ok(chunk)) => {
256                self.size_bytes = self.size_bytes.saturating_add(chunk.len() as u64);
257                if self.size_bytes > self.expected.size_bytes {
258                    self.finished = true;
259                    return Err(ClientError::Http(format!(
260                        "direct download of `{}` sent more than the {} bytes the grant named",
261                        self.path, self.expected.size_bytes
262                    )));
263                }
264                if let Some(sha256) = self.sha256.as_mut() {
265                    sha256.update(&chunk);
266                }
267                Ok(Some(chunk))
268            }
269            Some(Err(error)) => {
270                self.finished = true;
271                Err(ClientError::Io(format!(
272                    "read of `{}` failed: {error}",
273                    self.path
274                )))
275            }
276            None => {
277                self.finished = true;
278                if self.size_bytes != self.expected.size_bytes {
279                    return Err(ClientError::Http(format!(
280                        "direct download of `{}` ended after {} bytes, not the {} the grant named",
281                        self.path, self.size_bytes, self.expected.size_bytes
282                    )));
283                }
284                if let (Some(sha256), Some(expected_sha256)) = (
285                    self.sha256.take(),
286                    self.expected.whole_file_sha256.as_deref(),
287                ) {
288                    let observed = sha256.finish().value;
289                    if observed != expected_sha256 {
290                        return Err(ClientError::Http(format!(
291                            "direct download of `{}` hashed to {observed}, not the \
292                             {expected_sha256} the grant named",
293                            self.path
294                        )));
295                    }
296                }
297                Ok(None)
298            }
299        }
300    }
301}
302
303/// What a caller keeps so an interrupted direct multipart upload can pick
304/// up rather than start over.
305///
306/// The parts are the caller's own bookkeeping, deliberately: an upload
307/// session records the geometry it was opened with and nothing per part, so
308/// the only account of which parts landed is the one the uploading client
309/// kept. Resuming with fewer parts than actually landed re-sends them,
310/// which is harmless; resuming with parts that did not land fails the
311/// completion, which is the assembly refusing to be wrong.
312#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct MultipartUploadResume {
314    pub upload_id: UploadId,
315    /// The part size the session was opened with. A resumed upload must cut
316    /// the payload exactly as the interrupted one did, or the parts it
317    /// sends will not line up with the ones already there.
318    pub part_size_bytes: u64,
319    pub parts: Vec<CompletedUploadPart>,
320}
321
322/// Where a caller records a direct multipart upload as it happens, so a
323/// later run can resume it.
324///
325/// Both methods are called on the thread driving the upload, between
326/// network round trips, so an implementation that writes to disk is doing
327/// it at the right moment: after the part it describes is durable, and
328/// before the next one starts.
329pub trait MultipartUploadJournal: Send + Sync {
330    /// A session was opened, with the part geometry the server chose.
331    fn began(&self, upload_id: &UploadId, part_size_bytes: u64);
332    /// One part landed in object storage.
333    fn part_completed(&self, part: &CompletedUploadPart);
334}
335
336/// How one upload survives an interruption: what an earlier run got
337/// through, and where this one writes down what it gets through.
338#[derive(Clone, Copy, Default)]
339struct UploadContinuity<'a> {
340    resume: Option<&'a MultipartUploadResume>,
341    journal: Option<&'a dyn MultipartUploadJournal>,
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
345struct StagedContent {
346    content_ref: ContentRef,
347    validated_content_token: Option<ValidatedContentToken>,
348}
349
350/// What a multipart upload has to work with.
351///
352/// The two arms exist so that neither caller pays for the other's shape: a
353/// caller holding its payload should not have it copied whole to be
354/// uploaded, and a caller reading a stream cannot be asked for a length it
355/// does not have. Past this point the upload cannot tell them apart.
356enum MultipartPayload<'a> {
357    /// A payload the caller already holds.
358    Held(&'a [u8]),
359    /// A payload read once, in pieces, as it is uploaded.
360    Streamed(PayloadStream),
361}
362
363impl<'a> MultipartPayload<'a> {
364    /// Binds the payload to the geometry the server chose.
365    fn into_parts_of(self, part_bytes: usize) -> MultipartParts<'a> {
366        match self {
367            Self::Held(bytes) => MultipartParts::Held {
368                bytes,
369                offset: 0,
370                part_bytes: part_bytes.max(1),
371            },
372            Self::Streamed(stream) => MultipartParts::Streamed(PartReader::new(stream, part_bytes)),
373        }
374    }
375}
376
377/// A payload cut into the parts it will be uploaded as.
378enum MultipartParts<'a> {
379    Held {
380        bytes: &'a [u8],
381        offset: usize,
382        part_bytes: usize,
383    },
384    Streamed(PartReader),
385}
386
387impl MultipartParts<'_> {
388    /// The next part, or `None` once the payload is spent. Both arms hand
389    /// out one part's worth of bytes and no more.
390    async fn next_part(&mut self) -> Result<Option<Bytes>> {
391        match self {
392            Self::Held {
393                bytes,
394                offset,
395                part_bytes,
396            } => {
397                if *offset >= bytes.len() {
398                    return Ok(None);
399                }
400                let end = bytes.len().min(*offset + *part_bytes);
401                let part = Bytes::copy_from_slice(&bytes[*offset..end]);
402                *offset = end;
403                Ok(Some(part))
404            }
405            Self::Streamed(reader) => reader
406                .next_part()
407                .await
408                .map_err(|error| ClientError::Http(format!("reading the payload failed: {error}"))),
409        }
410    }
411}
412
413/// One part waiting to be uploaded, with the checksum its URL is signed
414/// against.
415struct PendingPart {
416    claim: UploadPartChecksumClaim,
417    bytes: Bytes,
418}
419
420/// What one pass over a payload produced: the assembled object's length and
421/// digest, and the parts it was written as.
422struct UploadedObject {
423    size_bytes: u64,
424    crc64nvme: StorageChecksum,
425    parts: Vec<CompletedUploadPart>,
426}
427
428/// Picks one part's authorization out of a signing response.
429fn signed_access(signed: &[SignedUploadPart], part_number: u32) -> Result<ObjectTransferAccess> {
430    signed
431        .iter()
432        .find(|part| part.part_number == part_number)
433        .map(|part| part.access.clone())
434        .ok_or_else(|| {
435            ClientError::Http(format!(
436                "server authorized no upload for part {part_number}"
437            ))
438        })
439}
440
441/// A path qualified by namespace.
442///
443/// Both parts are validated at construction — [`NamespacePath::parse`] for
444/// strings, [`NamespacePath::new`] for already-typed parts — so a value of
445/// this type always names a well-formed target.
446#[derive(Debug, Clone, PartialEq, Eq)]
447pub struct NamespacePath {
448    namespace: NamespaceId,
449    absolute_path: AbsolutePath,
450}
451
452impl Client {
453    /// Creates a client, validating the config exactly as
454    /// [`ClientConfig::load`] does — direct Rust construction cannot bypass
455    /// validation.
456    pub fn new(config: ClientConfig) -> Result<Self> {
457        config.validate()?;
458        let mut builder = reqwest::Client::builder()
459            // Bounds a stalled connection without cutting off a slow but
460            // progressing transfer, which a whole-request deadline would.
461            .read_timeout(IO_INACTIVITY_TIMEOUT)
462            .connect_timeout(IO_INACTIVITY_TIMEOUT);
463        if let Some(timeout_ms) = config.request_timeout_ms {
464            builder = builder.timeout(std::time::Duration::from_millis(timeout_ms));
465        }
466        // Additive: the platform roots stay in place, so one configured
467        // private CA does not cost this client every public one.
468        for certificate in config.extra_root_certificates()? {
469            builder = builder.add_root_certificate(certificate);
470        }
471        Ok(Self {
472            base_url: config.server_url.trim().trim_end_matches('/').to_owned(),
473            auth_token: config.auth_token,
474            http: builder
475                .build()
476                .map_err(|err| ClientError::Http(err.to_string()))?,
477            transient_retry: !config.disable_transient_retry,
478            capabilities: Arc::new(OnceLock::new()),
479        })
480    }
481
482    /// Returns the server's capability document, fetched once and cached for
483    /// the life of this client and its clones (API spec, "Capability
484    /// discovery").
485    ///
486    /// Feature keys that are not parented by an advertised profile are
487    /// dropped rather than trusted, per the spec's client guidance for
488    /// malformed documents.
489    pub async fn capabilities(&self) -> Result<CapabilityDocument> {
490        if let Some(document) = self.capabilities.get() {
491            return Ok(document.clone());
492        }
493        let url = format!("{}/v0/capabilities", self.base_url);
494        let mut document: CapabilityDocument =
495            self.request_json::<(), _>(self.get(&url), None).await?;
496        document.retain_well_formed();
497        // If a racing clone fetched first, keep its copy; both came from the
498        // same server.
499        let _ = self.capabilities.set(document);
500        Ok(self
501            .capabilities
502            .get()
503            .expect("capability cache was just filled")
504            .clone())
505    }
506
507    pub async fn create_namespace(&self, namespace_id: &NamespaceId) -> Result<NamespaceSummary> {
508        let url = format!("{}/v0/namespaces", self.base_url);
509        // Namespace creation has no durable request identity to reconcile an ambiguous success.
510        self.request_json_once::<_, NamespaceSummary>(
511            self.post(&url),
512            Some(&CreateNamespaceRequest {
513                namespace_id: namespace_id.clone(),
514            }),
515        )
516        .await
517    }
518
519    pub async fn namespace_status(
520        &self,
521        namespace_id: &NamespaceId,
522    ) -> Result<NamespaceStatusResponse> {
523        // Validated namespace ids are URL-safe by construction, like the
524        // other parsed id segments interpolated into paths here and below.
525        let url = format!("{}/v0/namespaces/{namespace_id}", self.base_url);
526        self.request_json::<(), NamespaceStatusResponse>(self.get(&url), None)
527            .await
528    }
529
530    /// Deletes a namespace (feature `core.namespaces.delete`): terminal,
531    /// and the id is permanently retired. Pass `expected_head_seq` to delete
532    /// only if the namespace is still where you last observed it
533    /// (`stale_head` on mismatch). Deleting an already-deleted namespace
534    /// fails with `namespace_deleted`.
535    pub async fn delete_namespace(
536        &self,
537        namespace_id: &NamespaceId,
538        expected_head_seq: Option<ChangeSeq>,
539    ) -> Result<DeleteNamespaceResponse> {
540        let mut url = format!("{}/v0/namespaces/{namespace_id}", self.base_url);
541        if let Some(expected) = expected_head_seq {
542            url.push_str(&format!("?expected_head_seq={}", expected.0));
543        }
544        // The expected head is a precondition, not an idempotency key for an ambiguous delete.
545        self.request_json_once::<(), DeleteNamespaceResponse>(self.delete(&url), None)
546            .await
547    }
548
549    pub async fn fork_namespace(
550        &self,
551        source_namespace_id: &NamespaceId,
552        new_namespace_id: &NamespaceId,
553    ) -> Result<NamespaceSummary> {
554        let url = format!(
555            "{}/v0/namespaces/{source_namespace_id}/forks",
556            self.base_url
557        );
558        // Namespace forks have no durable request identity to replay after an ambiguous success.
559        self.request_json_once::<_, NamespaceSummary>(
560            self.post(&url),
561            Some(&ForkNamespaceRequest {
562                new_namespace_id: new_namespace_id.clone(),
563            }),
564        )
565        .await
566    }
567
568    /// Lists a directory by aggregating every page into one response.
569    ///
570    /// Listing cursors tolerate commits landing mid-listing — each page
571    /// resumes in name-key order against the head the server has loaded —
572    /// so aggregation never restarts. The envelope's `head_seq` reports the
573    /// newest head that served a page. Use
574    /// [`Self::list_path_entries_page`] for page-level control.
575    pub async fn list_path_entries_all(
576        &self,
577        spec: &NamespacePath,
578    ) -> Result<ListPathEntriesResponse> {
579        let mut entries = Vec::new();
580        let mut envelope = None;
581        let mut cursor = None;
582        loop {
583            let page = self
584                .list_path_entries_page(spec, None, cursor.as_deref())
585                .await?;
586            let envelope_ref = envelope.get_or_insert_with(|| ListPathEntriesResponse {
587                namespace_id: page.namespace_id.clone(),
588                absolute_path: page.absolute_path.clone(),
589                head_seq: page.head_seq,
590                entries: Vec::new(),
591                next_cursor: None,
592            });
593            envelope_ref.head_seq = envelope_ref.head_seq.max(page.head_seq);
594            entries.extend(page.entries);
595            cursor = page.next_cursor;
596            if cursor.is_none() {
597                // Pages arrive in canonical name-key order; concatenation
598                // preserves it, so aggregation must not re-sort.
599                envelope_ref.entries = entries;
600                return Ok(envelope.expect("first page initializes response envelope"));
601            }
602        }
603    }
604
605    pub async fn list_path_entries_page(
606        &self,
607        spec: &NamespacePath,
608        limit: Option<u32>,
609        cursor: Option<&str>,
610    ) -> Result<ListPathEntriesResponse> {
611        let mut url = format!(
612            "{}/v0/namespaces/{}/filesystem/list?path={}",
613            self.base_url,
614            spec.namespace().as_str(),
615            urlencoding::encode(spec.absolute_path().as_str())
616        );
617        let has_query = true;
618        append_optional_pagination_query(&mut url, has_query, limit, cursor);
619        self.request_json::<(), ListPathEntriesResponse>(self.get(&url), None)
620            .await
621    }
622
623    pub async fn stat_path(&self, spec: &NamespacePath) -> Result<AuthoritativePathEntry> {
624        let url = format!(
625            "{}/v0/namespaces/{}/filesystem/stat?path={}",
626            self.base_url,
627            spec.namespace().as_str(),
628            urlencoding::encode(spec.absolute_path().as_str())
629        );
630        self.request_json::<(), AuthoritativePathEntry>(self.get(&url), None)
631            .await
632    }
633
634    pub async fn get_file_bytes(&self, spec: &NamespacePath) -> Result<Vec<u8>> {
635        let url = format!(
636            "{}/v0/namespaces/{}/filesystem/content?path={}",
637            self.base_url,
638            spec.namespace().as_str(),
639            urlencoding::encode(spec.absolute_path().as_str())
640        );
641        self.request_bytes(&url).await
642    }
643
644    pub async fn get_file_revision_bytes(
645        &self,
646        spec: &NamespacePath,
647        revision_no: RevisionNo,
648    ) -> Result<Vec<u8>> {
649        let url = format!(
650            "{}/v0/namespaces/{}/filesystem/content?path={}&revision_no={}",
651            self.base_url,
652            spec.namespace().as_str(),
653            urlencoding::encode(spec.absolute_path().as_str()),
654            revision_no.0
655        );
656        self.request_bytes(&url).await
657    }
658
659    /// Whether this deployment would refuse to proxy a file of this size
660    /// but can authorize a direct read of it.
661    ///
662    /// The two halves are one question. Under the advertised proxy cap the
663    /// proxied read is the simpler path and stays the default; over it the
664    /// proxied read answers `content_too_large`, and a deployment that
665    /// advertises `core.downloads.direct_get` can hand the object back
666    /// instead — which is the whole point of the capability, because that
667    /// same deployment is one that let a client create the object directly.
668    ///
669    /// A deployment that advertises no cap is left on the proxied path:
670    /// nothing here knows it would refuse.
671    pub async fn offers_direct_download(&self, size_bytes: u64) -> bool {
672        let Ok(capabilities) = self.capabilities().await else {
673            return false;
674        };
675        capabilities.supports(FEATURE_DOWNLOADS_DIRECT_GET)
676            && capabilities
677                .limits
678                .get(LIMIT_DOWNLOAD_MAX_CONTENT_BYTES)
679                .is_some_and(|proxy_cap| size_bytes > *proxy_cap)
680    }
681
682    /// Asks for one short-lived capability to read a file's content object
683    /// straight from the store.
684    pub async fn begin_download(
685        &self,
686        spec: &NamespacePath,
687        revision_no: Option<RevisionNo>,
688    ) -> Result<BeginDownloadResponse> {
689        let url = format!(
690            "{}/v0/namespaces/{}/filesystem/downloads",
691            self.base_url,
692            spec.namespace().as_str()
693        );
694        let request = match revision_no {
695            Some(revision_no) => {
696                BeginDownloadRequest::for_revision(spec.absolute_path().clone(), revision_no)
697            }
698            None => BeginDownloadRequest::for_path(spec.absolute_path().clone()),
699        };
700        // A grant creates nothing and names nothing new, so asking twice
701        // costs two URLs and changes no state: this one may be resent.
702        self.request_json::<_, BeginDownloadResponse>(self.post(&url), Some(&request))
703            .await
704    }
705
706    /// Opens the response body authorized by a download grant as a bounded,
707    /// verified stream.
708    pub async fn open_direct_download(
709        &self,
710        download: &BeginDownloadResponse,
711    ) -> Result<DirectDownloadStream> {
712        self.open_direct_download_at(download, 0).await
713    }
714
715    /// Opens a download grant's body from `start_offset`, for a caller that
716    /// already holds the bytes below it.
717    ///
718    /// The offset rides a `Range` header, which the presigned signature does
719    /// not cover: one grant serves the whole object or any part of it, so a
720    /// resumed download needs no different grant than a fresh one. The
721    /// stream still reports on the whole object, so a nonzero offset obliges
722    /// the caller to hand over what it holds through
723    /// [`DirectDownloadStream::fold_resumed_prefix`] before driving it.
724    pub async fn open_direct_download_at(
725        &self,
726        download: &BeginDownloadResponse,
727        start_offset: u64,
728    ) -> Result<DirectDownloadStream> {
729        let ObjectTransferAccess::PresignedUrl {
730            method,
731            url,
732            headers,
733            ..
734        } = &download.access;
735        if method != "GET" {
736            return Err(ClientError::Http(format!(
737                "unsupported presigned download method `{method}`"
738            )));
739        }
740        if start_offset > download.content_ref.size_bytes {
741            return Err(ClientError::Http(format!(
742                "cannot resume a download of `{}` at offset {start_offset} of {} bytes",
743                download.absolute_path, download.content_ref.size_bytes
744            )));
745        }
746        let mut request = WireRequest::presigned(reqwest::Method::GET, url);
747        for (name, value) in headers {
748            request = request.header(name, value);
749        }
750        if start_offset > 0 {
751            request = request.header("range", format!("bytes={start_offset}-"));
752        }
753        let body = self.call_for_response_stream(&request).await?;
754        Ok(DirectDownloadStream {
755            body,
756            expected: download.content_ref.clone(),
757            path: download.absolute_path.clone(),
758            sha256: download
759                .content_ref
760                .whole_file_sha256
761                .as_ref()
762                .map(|_| Sha256::new()),
763            // The counter measures the whole object, not this response, so
764            // the length check at the end lands where it always did.
765            size_bytes: start_offset,
766            resumed_from: start_offset,
767            prefix_folded: 0,
768            finished: false,
769        })
770    }
771
772    /// Streams a granted object's bytes into `sink`, checking them against
773    /// the reference the grant carried, and reports how many arrived.
774    ///
775    /// The payload is never held: each chunk is hashed and written as it
776    /// arrives, so this costs one chunk of memory whatever the object's
777    /// length. That is the entire reason the grant exists — a file past the
778    /// deployment's proxy cap has no other way home.
779    ///
780    /// Verification is what keeps a direct read no weaker than a proxied
781    /// one: the length always, and the whole-file SHA-256 whenever the
782    /// reference carries one. A direct-multipart object carries none —
783    /// nobody ever hashed it that way — and its length is then the whole
784    /// check, exactly as it is for the server's own reads.
785    ///
786    /// A failure is reported *after* the sink has already received bytes,
787    /// because that is the only order a streamed read allows. Callers must
788    /// treat the sink as provisional until this returns: write to a
789    /// temporary and install it on success.
790    pub async fn download_via_presigned_url<W>(
791        &self,
792        download: &BeginDownloadResponse,
793        sink: &mut W,
794    ) -> Result<u64>
795    where
796        W: tokio::io::AsyncWrite + Unpin,
797    {
798        use tokio::io::AsyncWriteExt as _;
799        let path = &download.absolute_path;
800        let mut download = self.open_direct_download(download).await?;
801        let mut size_bytes = 0u64;
802        while let Some(chunk) = download.next_chunk().await? {
803            size_bytes += chunk.len() as u64;
804            sink.write_all(&chunk)
805                .await
806                .map_err(|err| ClientError::Io(format!("write of `{path}` failed: {err}")))?;
807        }
808        sink.flush()
809            .await
810            .map_err(|err| ClientError::Io(format!("write of `{path}` failed: {err}")))?;
811        Ok(size_bytes)
812    }
813
814    pub async fn list_file_revisions_page(
815        &self,
816        spec: &NamespacePath,
817        limit: Option<u32>,
818        cursor: Option<&str>,
819    ) -> Result<ListFileRevisionsResponse> {
820        let mut url = format!(
821            "{}/v0/namespaces/{}/filesystem/revisions?path={}",
822            self.base_url,
823            spec.namespace().as_str(),
824            urlencoding::encode(spec.absolute_path().as_str())
825        );
826        let has_query = true;
827        append_optional_pagination_query(&mut url, has_query, limit, cursor);
828        self.request_json::<(), ListFileRevisionsResponse>(self.get(&url), None)
829            .await
830    }
831
832    pub async fn list_trash_page(
833        &self,
834        namespace_id: &NamespaceId,
835        limit: Option<u32>,
836        cursor: Option<&str>,
837    ) -> Result<ListTrashResponse> {
838        let mut url = format!(
839            "{}/v0/namespaces/{}/filesystem/trash",
840            self.base_url,
841            namespace_id.as_str()
842        );
843        let has_query = false;
844        append_optional_pagination_query(&mut url, has_query, limit, cursor);
845        self.request_json::<(), ListTrashResponse>(self.get(&url), None)
846            .await
847    }
848
849    pub async fn health(&self) -> Result<()> {
850        let url = format!("{}/health", self.base_url);
851        self.call_with_transient_retry(&self.get(&url), None)
852            .await?;
853        Ok(())
854    }
855
856    pub async fn begin_upload(
857        &self,
858        namespace_id: &NamespaceId,
859        request: &BeginUploadRequest,
860    ) -> Result<BeginUploadResponse> {
861        let url = format!("{}/v0/namespaces/{namespace_id}/uploads", self.base_url);
862        // Beginning an upload mints a new session id, so a resend could create a second session.
863        self.request_json_once::<_, BeginUploadResponse>(self.post(&url), Some(request))
864            .await
865    }
866
867    /// Starts a direct upload of bytes the caller already has.
868    ///
869    /// The claim is what the client can know about its own bytes; the
870    /// server answers with the content object it minted for them, and that
871    /// reference — not this claim — is what completion and the later commit
872    /// name.
873    pub async fn begin_direct_put(
874        &self,
875        namespace_id: &NamespaceId,
876        claim: DirectPutContentClaim,
877    ) -> Result<BeginUploadResponse> {
878        self.begin_upload(
879            namespace_id,
880            &BeginUploadRequest::DirectPut { content: claim },
881        )
882        .await
883    }
884
885    /// Opens a direct multipart upload session.
886    ///
887    /// Nothing about the payload is declared here — not its length, not its
888    /// digest — so one pass over the bytes is enough and a stream of unknown
889    /// length can start uploading immediately. The server answers with the
890    /// part geometry to cut to and nothing else: not the bucket, not the
891    /// key, not the provider's upload id, and not the content identity,
892    /// which it names back at completion.
893    pub async fn begin_direct_multipart(
894        &self,
895        namespace_id: &NamespaceId,
896        options: DirectMultipartUploadOptions,
897    ) -> Result<BeginUploadResponse> {
898        self.begin_upload(
899            namespace_id,
900            &BeginUploadRequest::DirectMultipart {
901                multipart: Some(options),
902            },
903        )
904        .await
905    }
906
907    /// Asks for one wave of checksum-bound part-upload capabilities.
908    ///
909    /// Asking again for a part already uploaded is how a client retries it:
910    /// a repeated part is last-write-wins at the provider, and the object's
911    /// checksum follows the bytes that stuck.
912    pub async fn sign_upload_parts(
913        &self,
914        namespace_id: &NamespaceId,
915        upload_id: &UploadId,
916        parts: Vec<UploadPartChecksumClaim>,
917    ) -> Result<SignUploadPartsResponse> {
918        let url = format!(
919            "{}/v0/namespaces/{namespace_id}/uploads/{upload_id}/parts",
920            self.base_url
921        );
922        // Signing writes nothing down, so asking twice costs two signatures
923        // and changes nothing.
924        self.request_json::<_, SignUploadPartsResponse>(
925            self.post(&url),
926            Some(&SignUploadPartsRequest { parts }),
927        )
928        .await
929    }
930
931    /// Uploads one part and reports what the provider recorded for it.
932    ///
933    /// The etag comes back to the caller rather than to the server: parts
934    /// are the uploader's bookkeeping all the way to completion, exactly as
935    /// they are in the provider's own multipart API.
936    pub async fn upload_part_via_presigned_url(
937        &self,
938        part_number: u32,
939        access: &ObjectTransferAccess,
940        crc64nvme: String,
941        bytes: Bytes,
942    ) -> Result<CompletedUploadPart> {
943        let ObjectTransferAccess::PresignedUrl {
944            method,
945            url,
946            headers,
947            ..
948        } = access;
949        if method != "PUT" {
950            return Err(ClientError::Http(format!(
951                "unsupported presigned part method `{method}`"
952            )));
953        }
954        let mut request = WireRequest::presigned(reqwest::Method::PUT, url);
955        for (name, value) in headers {
956            request = request.header(name, value);
957        }
958        // A part upload is safe to repeat: it is not create-only, the
959        // provider takes the last write, and the checksum rides the
960        // signature either way.
961        let response = self
962            .call_with_transient_retry_headers(&request, Some(&bytes))
963            .await?;
964        let etag = response
965            .get(http::header::ETAG)
966            .and_then(|value| value.to_str().ok())
967            .ok_or_else(|| {
968                ClientError::Http(format!("part {part_number} upload returned no etag"))
969            })?
970            .to_owned();
971        Ok(CompletedUploadPart {
972            part_number,
973            etag,
974            crc64nvme,
975        })
976    }
977
978    pub async fn upload_via_presigned_url(
979        &self,
980        access: &ObjectTransferAccess,
981        bytes: &[u8],
982    ) -> Result<()> {
983        let (method, url, headers) = match access {
984            ObjectTransferAccess::PresignedUrl {
985                method,
986                url,
987                headers,
988                ..
989            } => (method, url, headers),
990        };
991        if method != "PUT" {
992            return Err(ClientError::Http(format!(
993                "unsupported presigned upload method `{method}`"
994            )));
995        }
996        let mut request = WireRequest::presigned(reqwest::Method::PUT, url);
997        for (name, value) in headers {
998            request = request.header(name, value);
999        }
1000        // A successful create-only PUT may replay as a provider precondition error, not success.
1001        self.call_once(&request, Some(&Bytes::copy_from_slice(bytes)))
1002            .await
1003            .map(|_| ())
1004    }
1005
1006    pub async fn upload_content(
1007        &self,
1008        namespace_id: &NamespaceId,
1009        upload_id: &UploadId,
1010        bytes: &[u8],
1011    ) -> Result<UploadContentResponse> {
1012        let request = self.upload_content_request(namespace_id, upload_id);
1013        // Proxied uploads are the request most likely to hit the server's
1014        // concurrency cap; staging the same bytes again is idempotent.
1015        let response = self
1016            .call_with_transient_retry(&request, Some(&Bytes::copy_from_slice(bytes)))
1017            .await?;
1018        serde_json::from_slice(&response).map_err(|err| ClientError::Json(err.to_string()))
1019    }
1020
1021    /// Stages a payload that arrives in pieces, forwarding it to the server
1022    /// as it is read.
1023    ///
1024    /// This is [`Self::upload_content`] for a caller that does not hold its
1025    /// bytes: the payload crosses the client in bounded chunks and the
1026    /// server hashes it as it forwards it on, so neither side ever holds the
1027    /// object. A source whose length is unknown is sent with chunked
1028    /// transfer encoding, and the server's own limit is what bounds it.
1029    ///
1030    /// Unlike the buffered call this one never resends: a stream is consumed
1031    /// by the attempt that reads it, so a failure here is the caller's to
1032    /// handle with a fresh source.
1033    pub async fn upload_streamed_content(
1034        &self,
1035        namespace_id: &NamespaceId,
1036        upload_id: &UploadId,
1037        source: PayloadSource,
1038    ) -> Result<UploadContentResponse> {
1039        let request = self.upload_content_request(namespace_id, upload_id);
1040        let (stream, size_bytes) = source.into_stream();
1041        let response = self
1042            .call_streamed_once(&request, stream, size_bytes)
1043            .await?;
1044        serde_json::from_slice(&response).map_err(|err| ClientError::Json(err.to_string()))
1045    }
1046
1047    fn upload_content_request(
1048        &self,
1049        namespace_id: &NamespaceId,
1050        upload_id: &UploadId,
1051    ) -> WireRequest {
1052        let url = format!(
1053            "{}/v0/namespaces/{namespace_id}/uploads/{upload_id}/content",
1054            self.base_url
1055        );
1056        self.put(&url)
1057            .header("content-type", "application/octet-stream")
1058    }
1059
1060    /// Ends an open upload session and deletes the object it was writing.
1061    ///
1062    /// Repeating it succeeds and reports the abort that stands. This is what
1063    /// a one-pass upload does when its source fails partway: the session it
1064    /// opened must not be left holding a half-written object.
1065    pub async fn abort_upload(
1066        &self,
1067        namespace_id: &NamespaceId,
1068        upload_id: &UploadId,
1069    ) -> Result<AbortUploadResponse> {
1070        let url = format!(
1071            "{}/v0/namespaces/{namespace_id}/uploads/{upload_id}/abort",
1072            self.base_url
1073        );
1074        // Aborting is idempotent: a repeat reports the abort that stands.
1075        self.request_json::<(), AbortUploadResponse>(self.post(&url), None)
1076            .await
1077    }
1078
1079    /// Reads one upload session back.
1080    ///
1081    /// A completed session answers with the exact content reference it
1082    /// settled on and a freshly minted validation token, so a caller that
1083    /// lost its completion response — or the whole process — can commit
1084    /// that content without re-uploading a byte. The upload id is the only
1085    /// thing it has to have kept.
1086    pub async fn read_upload_status(
1087        &self,
1088        namespace_id: &NamespaceId,
1089        upload_id: &UploadId,
1090    ) -> Result<UploadStatusResponse> {
1091        let url = format!(
1092            "{}/v0/namespaces/{namespace_id}/uploads/{upload_id}",
1093            self.base_url
1094        );
1095        self.request_json::<(), UploadStatusResponse>(self.get(&url), None)
1096            .await
1097    }
1098
1099    pub async fn complete_upload(
1100        &self,
1101        namespace_id: &NamespaceId,
1102        upload_id: &UploadId,
1103        request: &CompleteUploadRequest,
1104    ) -> Result<CompleteUploadResponse> {
1105        let url = format!(
1106            "{}/v0/namespaces/{namespace_id}/uploads/{upload_id}/complete",
1107            self.base_url
1108        );
1109        // The durable completed-session record replays an identical completion without new effect.
1110        self.request_json::<_, CompleteUploadResponse>(self.post(&url), Some(request))
1111            .await
1112    }
1113
1114    pub async fn list_changes(
1115        &self,
1116        namespace_id: &NamespaceId,
1117        after_seq: ChangeSeq,
1118        limit: Option<u32>,
1119    ) -> Result<ChangesResponse> {
1120        let mut url = format!(
1121            "{}/v0/namespaces/{namespace_id}/changes?after_seq={}",
1122            self.base_url, after_seq.0
1123        );
1124        if let Some(limit) = limit {
1125            url.push_str(&format!("&limit={limit}"));
1126        }
1127        self.request_json::<(), ChangesResponse>(self.get(&url), None)
1128            .await
1129    }
1130
1131    /// Creates or reuses a named, user-owned checkpoint pinning the
1132    /// namespace's current view (admin plane). This is a maintenance
1133    /// operation, not a file mutation. The record is a garbage-collection
1134    /// root until released or expired.
1135    pub async fn create_checkpoint(
1136        &self,
1137        namespace_id: &NamespaceId,
1138        request: &CreateCheckpointRequest,
1139    ) -> Result<CreateCheckpointResponse> {
1140        let url = format!(
1141            "{}/v0/admin/namespaces/{namespace_id}/checkpoints",
1142            self.base_url
1143        );
1144        self.request_json(self.post(&url), Some(request)).await
1145    }
1146
1147    /// Lists the namespace's active checkpoint records, oldest first (admin
1148    /// plane).
1149    ///
1150    /// A checkpoint name is a label rather than a key, so this is how a pin
1151    /// is found again once its creation response is gone. An expired record
1152    /// that no collection pass has released yet is still listed, with its
1153    /// expiry in the entry.
1154    pub async fn list_checkpoints(
1155        &self,
1156        namespace_id: &NamespaceId,
1157    ) -> Result<ListCheckpointsResponse> {
1158        let url = format!(
1159            "{}/v0/admin/namespaces/{namespace_id}/checkpoints",
1160            self.base_url
1161        );
1162        self.request_json::<(), ListCheckpointsResponse>(self.get(&url), None)
1163            .await
1164    }
1165
1166    /// Releases a user-owned checkpoint pin by id (admin plane). Idempotent:
1167    /// releasing an already-released or reaped record succeeds.
1168    pub async fn release_checkpoint(
1169        &self,
1170        namespace_id: &NamespaceId,
1171        checkpoint_id: &CheckpointId,
1172    ) -> Result<ReleaseCheckpointResponse> {
1173        let url = format!(
1174            "{}/v0/admin/namespaces/{namespace_id}/checkpoints/{checkpoint_id}/release",
1175            self.base_url
1176        );
1177        self.request_json::<(), ReleaseCheckpointResponse>(self.post(&url), None)
1178            .await
1179    }
1180
1181    /// Runs one bounded maintenance step against a namespace (admin plane).
1182    /// Absent request fields use the server's defaults; garbage collection
1183    /// runs only when the request opts in.
1184    pub async fn maintenance_step(
1185        &self,
1186        namespace_id: &NamespaceId,
1187        request: &MaintenanceStepRequest,
1188    ) -> Result<MaintenanceStepResponse> {
1189        let url = format!(
1190            "{}/v0/admin/namespaces/{namespace_id}/maintenance/step",
1191            self.base_url
1192        );
1193        self.request_json(self.post(&url), Some(request)).await
1194    }
1195
1196    /// Proves the server's backing store honours the object-store contract
1197    /// LoonFS depends on (admin plane).
1198    ///
1199    /// The probe writes and deletes objects under a scratch prefix, so it
1200    /// runs only when asked. A store that fails a check answers with that
1201    /// check reported failed rather than with an error: the probe ran, and
1202    /// the answer is that the store is wrong.
1203    pub async fn probe_store(&self, request: &StoreProbeRequest) -> Result<StoreProbeResponse> {
1204        let url = format!("{}/v0/admin/store/probe", self.base_url);
1205        self.request_json(self.post(&url), Some(request)).await
1206    }
1207
1208    /// Content search over the namespace's grep index (query plane).
1209    /// Gate on the `query.grep` capability before calling against unknown
1210    /// deployments; the namespace must also have a materialized steady-state
1211    /// grep root or the server answers `not_supported`.
1212    pub async fn grep(
1213        &self,
1214        namespace_id: &NamespaceId,
1215        request: &GrepRequest,
1216    ) -> Result<GrepResponse> {
1217        let url = format!("{}/v0/namespaces/{namespace_id}/query/grep", self.base_url);
1218        self.request_json(self.post(&url), Some(request)).await
1219    }
1220
1221    /// Reads the namespace's grep-index lifecycle (admin plane): disabled,
1222    /// backfilling toward a captured sequence, or steady at a watermark.
1223    /// One grep root read on the server, with no side effects.
1224    pub async fn grep_index_status(
1225        &self,
1226        namespace_id: &NamespaceId,
1227    ) -> Result<GrepIndexStatusResponse> {
1228        let url = format!(
1229            "{}/v0/admin/namespaces/{namespace_id}/grep/index",
1230            self.base_url
1231        );
1232        self.request_json::<(), GrepIndexStatusResponse>(self.get(&url), None)
1233            .await
1234    }
1235
1236    /// Enables the namespace's grep root (admin plane); embedded mode starts
1237    /// that namespace's event-driven backfill. Idempotent.
1238    pub async fn enable_grep_index(
1239        &self,
1240        namespace_id: &NamespaceId,
1241    ) -> Result<EnableGrepIndexResponse> {
1242        let url = format!(
1243            "{}/v0/admin/namespaces/{namespace_id}/grep/index/enable",
1244            self.base_url
1245        );
1246        self.request_json::<(), EnableGrepIndexResponse>(self.post(&url), None)
1247            .await
1248    }
1249
1250    /// Disables the namespace's grep root (admin plane); garbage collection
1251    /// reclaims the segments. Idempotent.
1252    pub async fn disable_grep_index(
1253        &self,
1254        namespace_id: &NamespaceId,
1255    ) -> Result<DisableGrepIndexResponse> {
1256        let url = format!(
1257            "{}/v0/admin/namespaces/{namespace_id}/grep/index/disable",
1258            self.base_url
1259        );
1260        self.request_json::<(), DisableGrepIndexResponse>(self.post(&url), None)
1261            .await
1262    }
1263
1264    /// Runs one explicit grep-index garbage-collection pass for a namespace.
1265    ///
1266    /// `max_objects` bounds the reads the pass spends; when keys remain the
1267    /// response carries a `next_cursor` to resume from.
1268    pub async fn gc_grep_index(
1269        &self,
1270        namespace_id: &NamespaceId,
1271        request: &GrepGcRequest,
1272    ) -> Result<GrepGcResponse> {
1273        let url = format!(
1274            "{}/v0/admin/namespaces/{namespace_id}/grep/index/gc",
1275            self.base_url
1276        );
1277        self.request_json(self.post(&url), Some(request)).await
1278    }
1279
1280    /// Applies one commit: its operations land together, in order, under
1281    /// one commit id.
1282    ///
1283    /// The convenience methods below are the one-element case of this call.
1284    /// Operations that introduce new external content carry their proofs in
1285    /// the request's `content_tokens`; stage the bytes with the upload
1286    /// methods first.
1287    pub async fn commit(
1288        &self,
1289        namespace_id: &NamespaceId,
1290        request: &CommitRequest,
1291    ) -> Result<ApiCommitResponse> {
1292        let url = format!("{}/v0/namespaces/{namespace_id}/commits", self.base_url);
1293        // The request's commit id resolves an ambiguous resend through a durable receipt.
1294        self.request_json::<_, ApiCommitResponse>(self.post(&url), Some(request))
1295            .await
1296    }
1297
1298    /// Makes bytes durable, choosing the transport the payload and the
1299    /// deployment allow.
1300    ///
1301    /// A large payload goes straight to object storage in parallel parts
1302    /// where the server can authorize that; everything else goes through
1303    /// the server. Either way the caller gets back one content reference
1304    /// plus the receipt that admits it at commit.
1305    async fn stage_bytes_as_content_ref(
1306        &self,
1307        namespace_id: &NamespaceId,
1308        bytes: &[u8],
1309    ) -> Result<StagedContent> {
1310        if bytes.len() as u64 >= STREAMING_PUT_MIN_BYTES && self.offers_direct_multipart().await {
1311            return self
1312                .stage_via_multipart(
1313                    namespace_id,
1314                    MultipartPayload::Held(bytes),
1315                    UploadContinuity::default(),
1316                )
1317                .await;
1318        }
1319        self.stage_bytes_via_server(namespace_id, bytes).await
1320    }
1321
1322    /// Makes a streamed payload durable, choosing the transport the source
1323    /// and the deployment allow.
1324    ///
1325    /// Either way the source is read once, forward, and never held whole.
1326    /// A deployment that can authorize direct part uploads gets them; one
1327    /// that cannot receives the same source as a streaming request body.
1328    async fn stage_source_as_content_ref(
1329        &self,
1330        namespace_id: &NamespaceId,
1331        source: PayloadSource,
1332        continuity: UploadContinuity<'_>,
1333    ) -> Result<StagedContent> {
1334        // A source that knows it is small has nothing to gain from parts,
1335        // exactly as a held payload of that size does not. A source that
1336        // does not know its length cannot make that judgement, so it takes
1337        // the transport that can carry any length.
1338        let known_small = source
1339            .size_bytes()
1340            .is_some_and(|size_bytes| size_bytes < STREAMING_PUT_MIN_BYTES);
1341        if !known_small && self.offers_direct_multipart().await {
1342            let (stream, _) = source.into_stream();
1343            return self
1344                .stage_via_multipart(namespace_id, MultipartPayload::Streamed(stream), continuity)
1345                .await;
1346        }
1347        // Nothing to resume off this path: a proxied upload is one request
1348        // with no session behind it, so there are no parts to have landed.
1349        self.stage_source_via_server(namespace_id, source).await
1350    }
1351
1352    /// Whether this deployment authorizes direct part uploads.
1353    async fn offers_direct_multipart(&self) -> bool {
1354        self.capabilities()
1355            .await
1356            .is_ok_and(|capabilities| capabilities.supports(FEATURE_UPLOADS_DIRECT_MULTIPART))
1357    }
1358
1359    /// Uploads one object straight to object storage in bounded waves of
1360    /// parts.
1361    ///
1362    /// The whole-object checksum is folded part by part as the payload is
1363    /// cut, so the same pass that produces what the provider enforces on
1364    /// each part also produces what completion verifies the assembly
1365    /// against — and because the claim is only needed at completion, that
1366    /// one pass is the only pass over the bytes anyone has to make. Nothing
1367    /// here needs the payload's length in advance, which is what lets a
1368    /// stream with no length take this path unchanged.
1369    ///
1370    /// A session that fails partway is aborted rather than left open.
1371    async fn stage_via_multipart(
1372        &self,
1373        namespace_id: &NamespaceId,
1374        payload: MultipartPayload<'_>,
1375        continuity: UploadContinuity<'_>,
1376    ) -> Result<StagedContent> {
1377        // A resumed upload rejoins the session a previous run opened, at the
1378        // part size that run was given. Asking for a new one would open a
1379        // second session and orphan the parts already in object storage.
1380        let (upload_id, part_size_bytes) = match continuity.resume {
1381            Some(resume) => (resume.upload_id.clone(), resume.part_size_bytes),
1382            None => {
1383                let begin = self
1384                    .begin_direct_multipart(namespace_id, DirectMultipartUploadOptions::default())
1385                    .await?;
1386                let Some(multipart) = begin.direct_multipart else {
1387                    return Err(ClientError::Http(
1388                        "server accepted direct_multipart without part geometry".to_owned(),
1389                    ));
1390                };
1391                if let Some(journal) = continuity.journal {
1392                    journal.began(&begin.upload_id, multipart.part_size_bytes);
1393                }
1394                (begin.upload_id, multipart.part_size_bytes)
1395            }
1396        };
1397        let uploaded = self
1398            .upload_every_part(
1399                namespace_id,
1400                &upload_id,
1401                payload,
1402                part_size_bytes,
1403                continuity,
1404            )
1405            .await;
1406        let uploaded = match uploaded {
1407            Ok(uploaded) => uploaded,
1408            Err(error) => {
1409                // The session owns an object this upload will never finish
1410                // writing. Ending it is best-effort: the original failure is
1411                // what the caller needs to see, and abandoned sessions are
1412                // collected either way.
1413                let _ = self.abort_upload(namespace_id, &upload_id).await;
1414                return Err(error);
1415            }
1416        };
1417        if uploaded.parts.is_empty() {
1418            // The source was empty, and a provider has no empty assembly to
1419            // make. The payload is nothing, so staging it costs nothing.
1420            let _ = self.abort_upload(namespace_id, &upload_id).await;
1421            return self.stage_bytes_via_server(namespace_id, &[]).await;
1422        }
1423
1424        let response = self
1425            .complete_upload(
1426                namespace_id,
1427                &upload_id,
1428                &CompleteUploadRequest::for_multipart(
1429                    DirectMultipartContentClaim {
1430                        size_bytes: uploaded.size_bytes,
1431                        crc64nvme: uploaded.crc64nvme.value,
1432                    },
1433                    uploaded.parts,
1434                ),
1435            )
1436            .await?;
1437        Ok(Self::staged_from_completion(response))
1438    }
1439
1440    /// Cuts the payload into parts and uploads them, holding at most
1441    /// [`DIRECT_MULTIPART_PARTS_IN_FLIGHT`] of them at a time.
1442    ///
1443    /// The window is the memory bound: each in-flight part holds its bytes,
1444    /// and nothing outside the window does. One wave asks for its part URLs
1445    /// in a single request, uploads them together, and only then reads the
1446    /// next wave — so the payload's length never enters into how much of it
1447    /// is resident.
1448    /// A resumed upload still reads every byte: the whole-object checksum
1449    /// completion verifies the assembly against is folded over the payload
1450    /// in one forward pass, so a part already in object storage is cut,
1451    /// folded, and then let go rather than sent again. What resuming saves
1452    /// is the network, which is the part that was expensive.
1453    async fn upload_every_part(
1454        &self,
1455        namespace_id: &NamespaceId,
1456        upload_id: &UploadId,
1457        payload: MultipartPayload<'_>,
1458        part_size_bytes: u64,
1459        continuity: UploadContinuity<'_>,
1460    ) -> Result<UploadedObject> {
1461        let part_size = usize::try_from(part_size_bytes)
1462            .map_err(|_| ClientError::Http("part size does not fit this platform".to_owned()))?;
1463        let landed = continuity
1464            .resume
1465            .map_or::<&[CompletedUploadPart], _>(&[], |resume| &resume.parts);
1466        let mut source = payload.into_parts_of(part_size);
1467        let mut whole_object = Crc64Nvme::new();
1468        let mut size_bytes = 0u64;
1469        let mut parts = Vec::new();
1470        let mut next_part_number = 1u32;
1471
1472        loop {
1473            let mut wave = Vec::with_capacity(DIRECT_MULTIPART_PARTS_IN_FLIGHT);
1474            let mut source_ended = false;
1475            while wave.len() < DIRECT_MULTIPART_PARTS_IN_FLIGHT {
1476                let Some(bytes) = source.next_part().await? else {
1477                    source_ended = true;
1478                    break;
1479                };
1480                whole_object.update(&bytes);
1481                size_bytes += bytes.len() as u64;
1482                let part_number = next_part_number;
1483                next_part_number += 1;
1484                if let Some(landed) = landed.iter().find(|part| part.part_number == part_number) {
1485                    parts.push(landed.clone());
1486                    continue;
1487                }
1488                wave.push(PendingPart {
1489                    claim: UploadPartChecksumClaim {
1490                        part_number,
1491                        crc64nvme: StorageChecksum::crc64nvme(&bytes).value,
1492                    },
1493                    bytes,
1494                });
1495            }
1496            if !wave.is_empty() {
1497                let uploaded = self.upload_wave(namespace_id, upload_id, wave).await?;
1498                if let Some(journal) = continuity.journal {
1499                    for part in &uploaded {
1500                        journal.part_completed(part);
1501                    }
1502                }
1503                parts.extend(uploaded);
1504            }
1505            if source_ended {
1506                break;
1507            }
1508        }
1509
1510        parts.sort_by_key(|part| part.part_number);
1511        Ok(UploadedObject {
1512            size_bytes,
1513            crc64nvme: whole_object.finish(),
1514            parts,
1515        })
1516    }
1517
1518    /// Authorizes and uploads one wave of parts together.
1519    async fn upload_wave(
1520        &self,
1521        namespace_id: &NamespaceId,
1522        upload_id: &UploadId,
1523        wave: Vec<PendingPart>,
1524    ) -> Result<Vec<CompletedUploadPart>> {
1525        let claims = wave.iter().map(|part| part.claim.clone()).collect();
1526        let signed = self
1527            .sign_upload_parts(namespace_id, upload_id, claims)
1528            .await?;
1529        let mut in_flight = tokio::task::JoinSet::new();
1530        for part in wave {
1531            let access = signed_access(&signed.parts, part.claim.part_number)?;
1532            let client = self.clone();
1533            let namespace_id = namespace_id.clone();
1534            let upload_id = upload_id.clone();
1535            in_flight.spawn(async move {
1536                client
1537                    .upload_one_part(&namespace_id, &upload_id, part, access)
1538                    .await
1539            });
1540        }
1541        let mut uploaded = Vec::new();
1542        let mut failure = None;
1543        while let Some(joined) = in_flight.join_next().await {
1544            match joined.map_err(|err| ClientError::Http(format!("part upload task failed: {err}")))
1545            {
1546                // Every task is drained before the first failure surfaces,
1547                // so no part upload outlives the wave that started it.
1548                Ok(Ok(part)) => uploaded.push(part),
1549                Ok(Err(error)) | Err(error) => failure = failure.or(Some(error)),
1550            }
1551        }
1552        match failure {
1553            Some(error) => Err(error),
1554            None => Ok(uploaded),
1555        }
1556    }
1557
1558    /// Uploads one part, re-asking for its URL if the upload fails.
1559    ///
1560    /// Re-asking is the retry: a part's signature is the first thing about
1561    /// it that goes stale, and a repeated part is last-write-wins at the
1562    /// provider, so nothing is lost by writing it again.
1563    async fn upload_one_part(
1564        &self,
1565        namespace_id: &NamespaceId,
1566        upload_id: &UploadId,
1567        part: PendingPart,
1568        mut access: ObjectTransferAccess,
1569    ) -> Result<CompletedUploadPart> {
1570        let part_number = part.claim.part_number;
1571        for attempt in 1..=DIRECT_MULTIPART_PART_ATTEMPTS {
1572            let result = self
1573                .upload_part_via_presigned_url(
1574                    part_number,
1575                    &access,
1576                    part.claim.crc64nvme.clone(),
1577                    part.bytes.clone(),
1578                )
1579                .await;
1580            match result {
1581                Ok(uploaded) => return Ok(uploaded),
1582                Err(error) if attempt == DIRECT_MULTIPART_PART_ATTEMPTS => return Err(error),
1583                Err(_) => {
1584                    let signed = self
1585                        .sign_upload_parts(namespace_id, upload_id, vec![part.claim.clone()])
1586                        .await?;
1587                    access = signed_access(&signed.parts, part_number)?;
1588                }
1589            }
1590        }
1591        // The loop above returns on its last attempt.
1592        Err(ClientError::Http(format!(
1593            "part {part_number} upload made no attempt"
1594        )))
1595    }
1596
1597    async fn stage_bytes_via_server(
1598        &self,
1599        namespace_id: &NamespaceId,
1600        bytes: &[u8],
1601    ) -> Result<StagedContent> {
1602        let upload = self
1603            .begin_upload(namespace_id, &BeginUploadRequest::ServiceProxied {})
1604            .await?;
1605        let staged = self
1606            .upload_content(namespace_id, &upload.upload_id, bytes)
1607            .await?;
1608        self.complete_staged(namespace_id, &upload.upload_id, staged)
1609            .await
1610    }
1611
1612    /// Stages a streamed payload through the server, which hashes it as it
1613    /// forwards it on.
1614    ///
1615    /// The session is aborted if the transfer fails, for the same reason a
1616    /// multipart session is: it owns an object nothing will finish writing.
1617    async fn stage_source_via_server(
1618        &self,
1619        namespace_id: &NamespaceId,
1620        source: PayloadSource,
1621    ) -> Result<StagedContent> {
1622        let upload = self
1623            .begin_upload(namespace_id, &BeginUploadRequest::ServiceProxied {})
1624            .await?;
1625        let staged = self
1626            .upload_streamed_content(namespace_id, &upload.upload_id, source)
1627            .await;
1628        let staged = match staged {
1629            Ok(staged) => staged,
1630            Err(error) => {
1631                let _ = self.abort_upload(namespace_id, &upload.upload_id).await;
1632                return Err(error);
1633            }
1634        };
1635        self.complete_staged(namespace_id, &upload.upload_id, staged)
1636            .await
1637    }
1638
1639    async fn complete_staged(
1640        &self,
1641        namespace_id: &NamespaceId,
1642        upload_id: &UploadId,
1643        staged: UploadContentResponse,
1644    ) -> Result<StagedContent> {
1645        let response = self
1646            .complete_upload(
1647                namespace_id,
1648                upload_id,
1649                &CompleteUploadRequest::for_content_ref(staged.content_ref),
1650            )
1651            .await?;
1652        Ok(Self::staged_from_completion(response))
1653    }
1654
1655    fn staged_from_completion(response: CompleteUploadResponse) -> StagedContent {
1656        let validated_content_token =
1657            response
1658                .validated_content_token
1659                .map(|token| ValidatedContentToken {
1660                    content_ref: response.content_ref.clone(),
1661                    token,
1662                });
1663        StagedContent {
1664            content_ref: response.content_ref,
1665            validated_content_token,
1666        }
1667    }
1668
1669    /// Uploads bytes and commits them at a path.
1670    ///
1671    /// Re-running this with a `commit_id` that already committed is safe
1672    /// when the request is the same. A commit's identity names *which*
1673    /// content object it wrote, and a re-run necessarily uploads a fresh
1674    /// one, so the server sees a different commit and reports
1675    /// `commit_id_reuse_conflict`. This resolves that the only honest way
1676    /// available: it reads back what the commit id actually committed and
1677    /// compares it against the request just made. The same content under
1678    /// the same message means the operation had already succeeded and the
1679    /// original answer is returned; anything else — different bytes, a
1680    /// different message, or content this build cannot compare — surfaces
1681    /// the conflict.
1682    ///
1683    /// The freshly uploaded duplicate object is then referenced by nothing.
1684    /// That is by design, not a leak: content garbage collection reclaims an
1685    /// unreferenced completed upload once its grace passes.
1686    pub async fn put_file_bytes(
1687        &self,
1688        spec: &NamespacePath,
1689        bytes: &[u8],
1690        options: &PutFileOptions,
1691    ) -> Result<ApiCommitResponse> {
1692        let staged = self
1693            .stage_bytes_as_content_ref(spec.namespace(), bytes)
1694            .await?;
1695        self.commit_staged_file(spec, staged, options, UploadedContent::Bytes(bytes))
1696            .await
1697    }
1698
1699    /// Uploads a payload read once from its source and commits it at a path.
1700    ///
1701    /// This is [`Self::put_file_bytes`] for a caller that should not hold
1702    /// its payload: the source is read forward in bounded pieces, hashed as
1703    /// it goes, and never assembled. What the memory costs is the transport's
1704    /// window and nothing about the payload's size — so a file larger than
1705    /// this process could hold, or a pipe whose length nobody knows, both
1706    /// upload the same way.
1707    ///
1708    /// Where the deployment authorizes direct part uploads the payload goes
1709    /// straight to object storage in parts, and otherwise it streams through
1710    /// the server. One bound is worth knowing on the direct path: a provider
1711    /// assembles at most 10,000 parts, so a session carries at most
1712    /// `part_size_bytes × 10_000` and a longer payload is refused when it
1713    /// asks to authorize the part past that ceiling. A caller that knows its
1714    /// payload is enormous should not be taking the default geometry.
1715    ///
1716    /// Retrying with a `commit_id` that already committed is safe here in
1717    /// the same way it is for [`Self::put_file_bytes`], and by the same
1718    /// evidence: this pass measured the payload's length and folded its
1719    /// digest, which is what the reconciliation compares. The digest is
1720    /// CRC-64/NVME on the direct path, because that is what a provider
1721    /// computes over an assembled object.
1722    pub async fn put_file_stream(
1723        &self,
1724        spec: &NamespacePath,
1725        source: PayloadSource,
1726        options: &PutFileOptions,
1727    ) -> Result<ApiCommitResponse> {
1728        self.put_file_stream_continuing(spec, source, options, UploadContinuity::default())
1729            .await
1730    }
1731
1732    /// [`Self::put_file_stream`] for a caller that intends to survive an
1733    /// interruption.
1734    ///
1735    /// `journal` is told the session's id and geometry when it opens and
1736    /// told every part as it lands; `resume` hands back what an earlier run
1737    /// of the same upload recorded, so this one sends only the parts still
1738    /// missing. Both apply to the direct multipart transport alone: a
1739    /// proxied upload is a single request with no session behind it and
1740    /// nothing to resume from, and it ignores them.
1741    ///
1742    /// The source is read from its first byte either way. That is not
1743    /// waste — the checksum the assembly is verified against covers the
1744    /// whole object, so every byte has to be folded whether or not it also
1745    /// has to be sent — and it means the source has to be one that can be
1746    /// opened again. A pipe cannot, which is why nothing that reads one
1747    /// should be calling this.
1748    pub async fn put_file_stream_resumable(
1749        &self,
1750        spec: &NamespacePath,
1751        source: PayloadSource,
1752        options: &PutFileOptions,
1753        journal: &dyn MultipartUploadJournal,
1754        resume: Option<&MultipartUploadResume>,
1755    ) -> Result<ApiCommitResponse> {
1756        self.put_file_stream_continuing(
1757            spec,
1758            source,
1759            options,
1760            UploadContinuity {
1761                resume,
1762                journal: Some(journal),
1763            },
1764        )
1765        .await
1766    }
1767
1768    async fn put_file_stream_continuing(
1769        &self,
1770        spec: &NamespacePath,
1771        source: PayloadSource,
1772        options: &PutFileOptions,
1773        continuity: UploadContinuity<'_>,
1774    ) -> Result<ApiCommitResponse> {
1775        let staged = self
1776            .stage_source_as_content_ref(spec.namespace(), source, continuity)
1777            .await?;
1778        // The staged reference is what the server verified about the bytes
1779        // that just went past, and with the payload gone it is the only
1780        // description of them that still exists.
1781        let uploaded = staged.content_ref.clone();
1782        self.commit_staged_file(spec, staged, options, UploadedContent::Streamed(&uploaded))
1783            .await
1784    }
1785
1786    /// Commits content an upload session already completed, for a caller
1787    /// that finished the transfer and was interrupted before the commit.
1788    ///
1789    /// The reference and the token come from
1790    /// [`Self::read_upload_status`] reporting the session `Completed`: the
1791    /// bytes are in object storage and admitted, so the only thing left to
1792    /// do is name them at a path. Nothing is re-uploaded.
1793    pub async fn commit_completed_upload(
1794        &self,
1795        spec: &NamespacePath,
1796        content_ref: ContentRef,
1797        validated_content_token: Option<String>,
1798        options: &PutFileOptions,
1799    ) -> Result<ApiCommitResponse> {
1800        let uploaded = content_ref.clone();
1801        let staged = StagedContent {
1802            validated_content_token: validated_content_token.map(|token| ValidatedContentToken {
1803                content_ref: content_ref.clone(),
1804                token,
1805            }),
1806            content_ref,
1807        };
1808        self.commit_staged_file(spec, staged, options, UploadedContent::Streamed(&uploaded))
1809            .await
1810    }
1811
1812    /// Commits one staged payload at a path, reconciling a reused commit id
1813    /// against what was just uploaded.
1814    async fn commit_staged_file(
1815        &self,
1816        spec: &NamespacePath,
1817        staged: StagedContent,
1818        options: &PutFileOptions,
1819        uploaded: UploadedContent<'_>,
1820    ) -> Result<ApiCommitResponse> {
1821        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
1822        let response = self
1823            .commit(
1824                spec.namespace(),
1825                &CommitRequest {
1826                    commit_id: commit_id.clone(),
1827                    message: options.message.clone(),
1828                    content_tokens: staged.validated_content_token.into_iter().collect(),
1829                    operations: vec![FilesystemOperation::PutFile {
1830                        path: spec.absolute_path().clone(),
1831                        content_ref: staged.content_ref,
1832                        behavior: options.behavior,
1833                        expected_revision_no: options.expected_revision_no,
1834                    }],
1835                },
1836            )
1837            .await;
1838        match response {
1839            Ok(response) => Ok(response),
1840            Err(error) if error.code() == Some(ErrorCode::CommitIdReuseConflict) => {
1841                self.reconcile_commit_id_reuse(spec, &commit_id, options, uploaded, error)
1842                    .await
1843            }
1844            Err(error) => Err(error),
1845        }
1846    }
1847
1848    /// Decides whether a reused commit id already did this exact work.
1849    ///
1850    /// The proof is the commit's whole semantic identity, not a selection of
1851    /// its parts. The conflict reports the fingerprint the server's receipt
1852    /// holds; this reads the one change that commit id landed, rebuilds this
1853    /// request's fingerprint with the committed content reference in place
1854    /// of the freshly uploaded one, and requires the two to be equal. That
1855    /// covers the path, the replacement behavior, the expected revision, the
1856    /// annotation, and that the original commit was this one put — every
1857    /// field a future request gains is covered the day it joins the
1858    /// preimage. What the fingerprint cannot speak to is whether the two
1859    /// content objects hold the same bytes, so digest evidence answers that
1860    /// separately.
1861    ///
1862    /// Nothing weaker counts: every way of failing to prove the two requests
1863    /// are the same — including a comparison this client cannot make —
1864    /// leaves the original conflict standing, never agreement.
1865    async fn reconcile_commit_id_reuse(
1866        &self,
1867        spec: &NamespacePath,
1868        commit_id: &CommitId,
1869        options: &PutFileOptions,
1870        uploaded: UploadedContent<'_>,
1871        conflict: ClientError,
1872    ) -> Result<ApiCommitResponse> {
1873        let namespace_id = spec.namespace();
1874        let Some((committed_seq, committed_fingerprint)) = reported_commit_receipt(&conflict)
1875        else {
1876            return Err(conflict);
1877        };
1878        let Some(committed) = self
1879            .read_committed_change(namespace_id, commit_id, committed_seq)
1880            .await?
1881        else {
1882            return Err(conflict);
1883        };
1884        let Some(content_ref) = sole_committed_content_ref(&committed) else {
1885            return Err(conflict);
1886        };
1887        let retried = loonfs_api::put_retry_fingerprint(
1888            namespace_id,
1889            spec.absolute_path(),
1890            options.behavior,
1891            options.expected_revision_no,
1892            options.message.as_deref(),
1893            content_ref,
1894        );
1895        if retried.ok().as_deref() != Some(committed_fingerprint.as_str()) {
1896            return Err(conflict);
1897        }
1898        if !uploaded_matches_committed(&uploaded, content_ref) {
1899            return Err(conflict);
1900        }
1901        Ok(ApiCommitResponse {
1902            namespace_id: namespace_id.clone(),
1903            commit_id: committed.commit_id,
1904            committed_seq: committed.seq,
1905        })
1906    }
1907
1908    /// Reads the one change a reuse conflict said the commit id landed at.
1909    ///
1910    /// There is no by-commit-id read on the wire, but the conflict already
1911    /// named the sequence, so this is one feed page positioned on it rather
1912    /// than a search. `None` means the evidence is not there to compare —
1913    /// the sequence has fallen below the retention floor, or the row at it
1914    /// belongs to some other commit — and the caller turns that into the
1915    /// conflict rather than into a guess.
1916    async fn read_committed_change(
1917        &self,
1918        namespace_id: &NamespaceId,
1919        commit_id: &CommitId,
1920        committed_seq: ChangeSeq,
1921    ) -> Result<Option<CommittedChange>> {
1922        let after_seq = ChangeSeq(committed_seq.0.saturating_sub(1));
1923        let page = match self.list_changes(namespace_id, after_seq, Some(1)).await {
1924            Ok(page) => page,
1925            // Retention gave up the replay promise below the floor: the
1926            // commit landed, but what it wrote can no longer be read back.
1927            Err(error) if error.code() == Some(ErrorCode::RebootstrapRequired) => {
1928                return Ok(None);
1929            }
1930            Err(error) => return Err(error),
1931        };
1932        Ok(page
1933            .changes
1934            .into_iter()
1935            .find(|change| change.seq == committed_seq && &change.commit_id == commit_id))
1936    }
1937
1938    pub async fn create_directory(
1939        &self,
1940        spec: &NamespacePath,
1941        options: &CreateDirectoryOptions,
1942    ) -> Result<ApiCommitResponse> {
1943        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
1944        let response = self
1945            .commit(
1946                spec.namespace(),
1947                &CommitRequest::single(
1948                    commit_id,
1949                    options.message.clone(),
1950                    FilesystemOperation::CreateDirectory {
1951                        path: spec.absolute_path().clone(),
1952                        parents: options.parents,
1953                    },
1954                ),
1955            )
1956            .await?;
1957        Ok(response)
1958    }
1959
1960    pub async fn delete_path(
1961        &self,
1962        spec: &NamespacePath,
1963        options: &DeleteOptions,
1964    ) -> Result<ApiCommitResponse> {
1965        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
1966        let response = self
1967            .commit(
1968                spec.namespace(),
1969                &CommitRequest::single(
1970                    commit_id,
1971                    options.message.clone(),
1972                    FilesystemOperation::DeletePath {
1973                        path: spec.absolute_path().clone(),
1974                        behavior: options.behavior,
1975                        expected_inode_id: options.expected_inode_id,
1976                    },
1977                ),
1978            )
1979            .await?;
1980        Ok(response)
1981    }
1982
1983    pub async fn move_path(
1984        &self,
1985        from: &NamespacePath,
1986        to: &NamespacePath,
1987        options: &MoveOptions,
1988    ) -> Result<ApiCommitResponse> {
1989        if from.namespace() != to.namespace() {
1990            return Err(ClientError::InvalidNamespacePath(format!(
1991                "cannot move across namespaces: {} -> {}",
1992                from.namespace(),
1993                to.namespace()
1994            )));
1995        }
1996        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
1997        let response = self
1998            .commit(
1999                from.namespace(),
2000                &CommitRequest::single(
2001                    commit_id,
2002                    options.message.clone(),
2003                    FilesystemOperation::MovePath {
2004                        from_path: from.absolute_path().clone(),
2005                        to_path: to.absolute_path().clone(),
2006                        behavior: options.behavior,
2007                    },
2008                ),
2009            )
2010            .await?;
2011        Ok(response)
2012    }
2013
2014    pub async fn copy_path(
2015        &self,
2016        from: &NamespacePath,
2017        to: &NamespacePath,
2018        options: &CopyOptions,
2019    ) -> Result<ApiCommitResponse> {
2020        if from.namespace() != to.namespace() {
2021            return Err(ClientError::InvalidNamespacePath(format!(
2022                "cannot copy across namespaces: {} -> {}",
2023                from.namespace(),
2024                to.namespace()
2025            )));
2026        }
2027        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
2028        let response = self
2029            .commit(
2030                from.namespace(),
2031                &CommitRequest::single(
2032                    commit_id,
2033                    options.message.clone(),
2034                    FilesystemOperation::CopyPath {
2035                        from_path: from.absolute_path().clone(),
2036                        to_path: to.absolute_path().clone(),
2037                        behavior: options.behavior,
2038                    },
2039                ),
2040            )
2041            .await?;
2042        Ok(response)
2043    }
2044
2045    /// Recovers a deleted file or subtree: clears the tombstone rooted at
2046    /// `inode_id` (the id the delete reported) and re-binds it at the spec's
2047    /// path.
2048    pub async fn undelete(
2049        &self,
2050        namespace: &NamespaceId,
2051        inode_id: InodeId,
2052        deleted_at_seq: ChangeSeq,
2053        path: Option<&AbsolutePath>,
2054        options: &UndeleteOptions,
2055    ) -> Result<ApiCommitResponse> {
2056        // An absent destination restores in place: the entry re-binds under
2057        // the parent and name its deletion recorded.
2058        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
2059        let response = self
2060            .commit(
2061                namespace,
2062                &CommitRequest::single(
2063                    commit_id,
2064                    options.message.clone(),
2065                    FilesystemOperation::Undelete {
2066                        inode_id,
2067                        deleted_at_seq,
2068                        path: path.cloned(),
2069                    },
2070                ),
2071            )
2072            .await?;
2073        Ok(response)
2074    }
2075
2076    pub async fn restore_file_revision(
2077        &self,
2078        spec: &NamespacePath,
2079        source_revision_no: RevisionNo,
2080        options: &RestoreRevisionOptions,
2081    ) -> Result<ApiCommitResponse> {
2082        let commit_id = options.commit_id.clone().unwrap_or_else(CommitId::generate);
2083        let response = self
2084            .commit(
2085                spec.namespace(),
2086                &CommitRequest::single(
2087                    commit_id,
2088                    options.message.clone(),
2089                    FilesystemOperation::RestoreRevision {
2090                        path: spec.absolute_path().clone(),
2091                        source_revision_no,
2092                    },
2093                ),
2094            )
2095            .await?;
2096        Ok(response)
2097    }
2098}
2099
2100impl NamespacePath {
2101    /// Parses and validates both parts of a namespace-qualified path.
2102    pub fn parse(namespace: &str, absolute_path: &str) -> Result<Self> {
2103        let namespace = NamespaceId::parse(namespace)
2104            .map_err(|error| ClientError::InvalidNamespacePath(error.to_string()))?;
2105        let absolute_path = AbsolutePath::parse(absolute_path)
2106            .map_err(|error| ClientError::InvalidNamespacePath(error.to_string()))?;
2107        Ok(Self {
2108            namespace,
2109            absolute_path,
2110        })
2111    }
2112
2113    /// Pairs already-validated parts without re-parsing.
2114    pub fn new(namespace: NamespaceId, absolute_path: AbsolutePath) -> Self {
2115        Self {
2116            namespace,
2117            absolute_path,
2118        }
2119    }
2120
2121    /// Namespace the path is scoped to.
2122    pub fn namespace(&self) -> &NamespaceId {
2123        &self.namespace
2124    }
2125
2126    /// Absolute path inside the namespace.
2127    pub fn absolute_path(&self) -> &AbsolutePath {
2128        &self.absolute_path
2129    }
2130}
2131
2132fn append_optional_pagination_query(
2133    url: &mut String,
2134    has_query: bool,
2135    limit: Option<u32>,
2136    cursor: Option<&str>,
2137) {
2138    let mut has_query = has_query;
2139    if let Some(limit) = limit {
2140        append_query_param(url, &mut has_query, "limit", &limit.to_string());
2141    }
2142    if let Some(cursor) = cursor {
2143        append_query_param(url, &mut has_query, "cursor", cursor);
2144    }
2145}
2146
2147fn append_query_param(url: &mut String, has_query: &mut bool, name: &str, value: &str) {
2148    url.push(if *has_query { '&' } else { '?' });
2149    *has_query = true;
2150    url.push_str(name);
2151    url.push('=');
2152    url.push_str(&urlencoding::encode(value));
2153}
2154
2155#[cfg(test)]
2156mod download_tests;
2157
2158#[cfg(test)]
2159mod streaming_tests;
2160
2161#[cfg(test)]
2162mod tests {
2163    use super::*;
2164    use crate::transport::{transient_failure, MAX_TRANSIENT_ATTEMPTS};
2165    use loonfs_api::{ContentId, ErrorCode, ErrorKind};
2166    use std::fs;
2167    use tempfile::tempdir;
2168
2169    fn test_content_ref(bytes: &[u8]) -> ContentRef {
2170        ContentRef::blob_v1(ContentId::generate(), bytes)
2171    }
2172
2173    fn direct_put_claim(bytes: &[u8]) -> DirectPutContentClaim {
2174        let content_ref = test_content_ref(bytes);
2175        DirectPutContentClaim {
2176            size_bytes: content_ref.size_bytes,
2177            sha256: content_ref.storage_checksum.value,
2178        }
2179    }
2180
2181    /// A reference whose only full-object evidence is a digest this client
2182    /// has no implementation for. No server mints one today, which is
2183    /// exactly why the reconciliation has to say what it does when one
2184    /// arrives.
2185    fn crc32c_content_ref(bytes: &[u8]) -> ContentRef {
2186        ContentRef {
2187            kind: loonfs_api::ContentRefKind::BlobV1,
2188            content_id: ContentId::generate(),
2189            size_bytes: bytes.len() as u64,
2190            storage_checksum: StorageChecksum {
2191                algorithm: ChecksumAlgorithm::Crc32c,
2192                value: "0f5c0a1e".to_owned(),
2193            },
2194            whole_file_sha256: None,
2195        }
2196    }
2197
2198    #[test]
2199    fn a_digest_this_client_cannot_compare_leaves_the_reuse_conflict_standing() {
2200        let bytes = b"retried payload";
2201        let committed = crc32c_content_ref(bytes);
2202        let uploaded = UploadedContent::Bytes(bytes);
2203
2204        assert_eq!(
2205            uploaded.matches(&committed.storage_checksum),
2206            None,
2207            "the fixture must reach the refusal, not a comparison"
2208        );
2209        assert!(!uploaded_matches_committed(&uploaded, &committed));
2210    }
2211
2212    #[test]
2213    fn a_whole_file_digest_over_the_same_bytes_proves_the_retry_did_this_work() {
2214        let bytes = b"retried payload";
2215        let committed = test_content_ref(bytes);
2216
2217        assert!(uploaded_matches_committed(
2218            &UploadedContent::Bytes(bytes),
2219            &committed
2220        ));
2221        assert!(!uploaded_matches_committed(
2222            &UploadedContent::Bytes(b"some other payload"),
2223            &committed
2224        ));
2225    }
2226
2227    /// `Client::new` runs the same validation as `ClientConfig::load`, so a
2228    /// directly built config cannot bypass it.
2229    #[test]
2230    fn construction_validates_config_like_load_does() {
2231        let error = super::Client::new(super::ClientConfig {
2232            server_url: "ftp://example.com".to_owned(),
2233            auth_token: None,
2234            request_timeout_ms: None,
2235            disable_transient_retry: false,
2236            ca_cert_path: None,
2237        })
2238        .expect_err("ftp scheme must be rejected");
2239        assert!(
2240            matches!(
2241                &error,
2242                super::ClientError::ConfigValidation {
2243                    field: "server_url",
2244                    ..
2245                }
2246            ),
2247            "unexpected error: {error:?}"
2248        );
2249    }
2250
2251    /// A CA bundle that cannot be read or is not PEM fails at construction,
2252    /// naming the path. Falling back to the platform roots would move the
2253    /// failure to the first request and blame the server for it.
2254    #[test]
2255    fn an_unusable_ca_bundle_fails_construction_and_names_the_path() {
2256        let dir = tempdir().expect("tempdir");
2257        let missing = dir.path().join("absent.crt");
2258        let garbage = dir.path().join("garbage.crt");
2259        fs::write(&garbage, b"this is not a certificate\n").expect("write garbage");
2260
2261        for path in [missing, garbage] {
2262            let display = path.display().to_string();
2263            let error = super::Client::new(super::ClientConfig {
2264                server_url: "https://example.com".to_owned(),
2265                auth_token: None,
2266                request_timeout_ms: None,
2267                disable_transient_retry: false,
2268                ca_cert_path: Some(display.clone()),
2269            })
2270            .expect_err("unusable ca bundle");
2271            match &error {
2272                super::ClientError::ConfigValidation {
2273                    field: "ca_cert_path",
2274                    reason,
2275                } => assert!(
2276                    reason.contains(&display),
2277                    "the reason must name the path, got: {reason}"
2278                ),
2279                other => unreachable!("unexpected error: {other:?}"),
2280            }
2281        }
2282    }
2283
2284    /// Configs are strict like everywhere else in the workspace: a typo'd
2285    /// key fails decode instead of silently producing an unauthenticated
2286    /// client.
2287    #[test]
2288    fn client_config_rejects_unknown_keys() {
2289        let error = toml::from_str::<ClientConfig>(
2290            "server_url = \"http://localhost:1\"\nauth_tokn = \"oops\"\n",
2291        )
2292        .expect_err("unknown key must fail decode");
2293        assert!(error.to_string().contains("auth_tokn"), "{error}");
2294
2295        let config: ClientConfig =
2296            toml::from_str("server_url = \"http://localhost:1\"\n").expect("minimal config");
2297        assert!(config.auth_token.is_none());
2298    }
2299    /// The retry policy in one place: network-level transport failures and
2300    /// the retryable-unavailability codes resend; everything else — including
2301    /// a served status whose body was not the error envelope — surfaces
2302    /// immediately.
2303    #[test]
2304    fn transient_failure_covers_transport_and_retryable_unavailability_only() {
2305        let api = |code: &str| ClientError::Api {
2306            status: 503,
2307            code: code.to_owned(),
2308            feature: None,
2309            message: String::new(),
2310            request_id: None,
2311            details: None,
2312        };
2313        assert!(transient_failure(
2314            true,
2315            &ClientError::Http("reset".to_owned())
2316        ));
2317        assert!(transient_failure(false, &api("server_busy")));
2318        assert!(transient_failure(false, &api("commit_queue_full")));
2319        assert!(transient_failure(false, &api("shutting_down")));
2320        assert!(!transient_failure(false, &api("server_error")));
2321        assert!(!transient_failure(false, &api("maintenance_required")));
2322        assert!(!transient_failure(
2323            false,
2324            &ClientError::Http("http status 502 with a non-envelope body".to_owned())
2325        ));
2326    }
2327
2328    /// A network-level transport failure resends up to the attempt cap when
2329    /// retry is enabled; with it disabled the first failure surfaces.
2330    #[tokio::test]
2331    async fn transport_failures_resend_up_to_the_attempt_cap() {
2332        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2333        let transport = crate::transport::test_transport::failures(MAX_TRANSIENT_ATTEMPTS as usize);
2334        let retrying = Client::new(ClientConfig {
2335            server_url: "http://example.invalid".to_owned(),
2336            auth_token: None,
2337            request_timeout_ms: None,
2338            disable_transient_retry: false,
2339            ca_cert_path: None,
2340        })
2341        .expect("valid client config");
2342        let error = retrying
2343            .namespace_status(&namespace_id)
2344            .await
2345            .expect_err("dropped connections must fail");
2346        assert!(matches!(error, ClientError::Http(_)), "{error:?}");
2347        assert_eq!(transport.attempts(), MAX_TRANSIENT_ATTEMPTS as usize);
2348        drop(transport);
2349
2350        let transport = crate::transport::test_transport::failures(1);
2351        let single_shot = Client::new(ClientConfig {
2352            server_url: "http://example.invalid".to_owned(),
2353            auth_token: None,
2354            request_timeout_ms: None,
2355            disable_transient_retry: true,
2356            ca_cert_path: None,
2357        })
2358        .expect("valid client config");
2359        single_shot
2360            .namespace_status(&namespace_id)
2361            .await
2362            .expect_err("dropped connection must fail without retry");
2363        assert_eq!(transport.attempts(), 1);
2364    }
2365
2366    fn retry_policy_client() -> Client {
2367        Client::new(ClientConfig {
2368            server_url: "http://example.invalid".to_owned(),
2369            auth_token: None,
2370            request_timeout_ms: None,
2371            disable_transient_retry: false,
2372            ca_cert_path: None,
2373        })
2374        .expect("valid client config")
2375    }
2376
2377    /// Installs a transport that fails once then succeeds, so a call that
2378    /// stops after one attempt surfaces the failure and a call that retries
2379    /// would succeed instead.
2380    fn single_attempt_probe() -> (crate::transport::test_transport::Guard, Client) {
2381        (
2382            crate::transport::test_transport::failure_then_success(b"{}".to_vec()),
2383            retry_policy_client(),
2384        )
2385    }
2386
2387    fn assert_single_attempt<T>(
2388        result: Result<T>,
2389        transport: &crate::transport::test_transport::Guard,
2390    ) {
2391        assert!(
2392            matches!(result, Err(ClientError::Http(_))),
2393            "expected the first transport failure to surface"
2394        );
2395        assert_eq!(transport.attempts(), 1);
2396    }
2397
2398    #[tokio::test]
2399    async fn retry_policy_lifecycle_mutations_are_single_attempt() {
2400        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2401        let fork_id = NamespaceId::parse("fork").expect("valid id");
2402
2403        let (transport, client) = single_attempt_probe();
2404        assert_single_attempt(client.create_namespace(&namespace_id).await, &transport);
2405        drop(transport);
2406
2407        let (transport, client) = single_attempt_probe();
2408        assert_single_attempt(
2409            client.fork_namespace(&namespace_id, &fork_id).await,
2410            &transport,
2411        );
2412        drop(transport);
2413
2414        let (transport, client) = single_attempt_probe();
2415        assert_single_attempt(
2416            client
2417                .delete_namespace(&namespace_id, Some(ChangeSeq(7)))
2418                .await,
2419            &transport,
2420        );
2421    }
2422
2423    #[tokio::test]
2424    async fn retry_policy_commit_id_filesystem_mutation_retries() {
2425        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2426        let commit_id =
2427            CommitId::parse("c_00000000000000000000000000000001").expect("valid commit id");
2428        let response = ApiCommitResponse {
2429            namespace_id: namespace_id.clone(),
2430            commit_id: commit_id.clone(),
2431            committed_seq: ChangeSeq(1),
2432        };
2433        let transport = crate::transport::test_transport::failure_then_success(
2434            serde_json::to_vec(&response).expect("serialize response"),
2435        );
2436        let client = retry_policy_client();
2437        let spec = NamespacePath::parse("demo", "/docs").expect("valid namespace path");
2438
2439        let actual = client
2440            .create_directory(
2441                &spec,
2442                &CreateDirectoryOptions {
2443                    commit_id: Some(commit_id),
2444                    message: None,
2445                    ..CreateDirectoryOptions::default()
2446                },
2447            )
2448            .await
2449            .expect("commit-id mutation should retry");
2450        assert_eq!(actual, response);
2451        assert_eq!(transport.attempts(), 2);
2452    }
2453
2454    #[tokio::test]
2455    async fn retry_policy_read_retries() {
2456        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2457        let response = NamespaceStatusResponse {
2458            namespace_id: namespace_id.clone(),
2459            head_seq: ChangeSeq(0),
2460            current_manifest_id: None,
2461            wal_tail_segments: 0,
2462            retention_floor_seq: ChangeSeq(0),
2463        };
2464        let transport = crate::transport::test_transport::failure_then_success(
2465            serde_json::to_vec(&response).expect("serialize response"),
2466        );
2467        let client = retry_policy_client();
2468
2469        let actual = client
2470            .namespace_status(&namespace_id)
2471            .await
2472            .expect("read should retry");
2473        assert_eq!(actual, response);
2474        assert_eq!(transport.attempts(), 2);
2475    }
2476
2477    #[tokio::test]
2478    async fn retry_policy_upload_begins_are_single_attempt() {
2479        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2480
2481        let (transport, client) = single_attempt_probe();
2482        assert_single_attempt(
2483            client
2484                .begin_upload(&namespace_id, &BeginUploadRequest::ServiceProxied {})
2485                .await,
2486            &transport,
2487        );
2488        drop(transport);
2489
2490        let (transport, client) = single_attempt_probe();
2491        assert_single_attempt(
2492            client
2493                .begin_direct_put(&namespace_id, direct_put_claim(b"direct"))
2494                .await,
2495            &transport,
2496        );
2497    }
2498
2499    #[tokio::test]
2500    async fn retry_policy_presigned_upload_is_single_attempt() {
2501        let transport = crate::transport::test_transport::failure_then_success(Vec::new());
2502        let client = retry_policy_client();
2503        let access = ObjectTransferAccess::PresignedUrl {
2504            method: "PUT".to_owned(),
2505            url: "http://example.invalid/upload".to_owned(),
2506            headers: std::collections::BTreeMap::new(),
2507            expires_at_ms: 1,
2508        };
2509
2510        let result = client.upload_via_presigned_url(&access, b"direct").await;
2511
2512        assert!(matches!(result, Err(ClientError::Http(_))), "{result:?}");
2513        assert_eq!(transport.attempts(), 1);
2514    }
2515
2516    #[tokio::test]
2517    async fn retry_policy_proxied_upload_content_retries() {
2518        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2519        let upload_id = loonfs_api::UploadId::parse("upl_00000000000000000000000000000001")
2520            .expect("valid upload id");
2521        let response = UploadContentResponse {
2522            namespace_id: namespace_id.clone(),
2523            upload_id: upload_id.clone(),
2524            content_ref: test_content_ref(b"content"),
2525        };
2526        let transport = crate::transport::test_transport::failure_then_success(
2527            serde_json::to_vec(&response).expect("serialize response"),
2528        );
2529        let client = retry_policy_client();
2530
2531        let actual = client
2532            .upload_content(&namespace_id, &upload_id, b"content")
2533            .await
2534            .expect("identical content staging should retry");
2535        assert_eq!(actual, response);
2536        assert_eq!(transport.attempts(), 2);
2537    }
2538
2539    #[tokio::test]
2540    async fn retry_policy_upload_completion_retries() {
2541        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
2542        let upload_id = loonfs_api::UploadId::parse("upl_00000000000000000000000000000001")
2543            .expect("valid upload id");
2544        let content_ref = test_content_ref(b"content");
2545        let response = CompleteUploadResponse {
2546            namespace_id: namespace_id.clone(),
2547            upload_id: upload_id.clone(),
2548            content_ref: content_ref.clone(),
2549            validated_content_token: None,
2550        };
2551        let transport = crate::transport::test_transport::failure_then_success(
2552            serde_json::to_vec(&response).expect("serialize response"),
2553        );
2554        let client = retry_policy_client();
2555
2556        let actual = client
2557            .complete_upload(
2558                &namespace_id,
2559                &upload_id,
2560                &CompleteUploadRequest::for_content_ref(content_ref),
2561            )
2562            .await
2563            .expect("completed-session replay should retry");
2564        assert_eq!(actual, response);
2565        assert_eq!(transport.attempts(), 2);
2566    }
2567
2568    /// An intermediary answering with a non-envelope body (a load balancer's
2569    /// HTML 502) must keep its status in the surfaced error — the status is
2570    /// the only signal the response carried.
2571    #[test]
2572    fn status_errors_keep_the_status_when_the_body_is_not_the_envelope() {
2573        let error = crate::transport::map_status_error(502, b"<html>upstream error</html>");
2574
2575        let ClientError::Http(message) = error else {
2576            unreachable!("expected Http error, got {error:?}");
2577        };
2578        assert!(message.contains("502"), "{message}");
2579        assert!(message.contains("non-envelope body"), "{message}");
2580    }
2581
2582    fn api_error(status: u16, code: &str) -> ClientError {
2583        ClientError::Api {
2584            status,
2585            code: code.to_owned(),
2586            feature: None,
2587            message: "test".to_owned(),
2588            request_id: None,
2589            details: None,
2590        }
2591    }
2592
2593    #[test]
2594    fn api_errors_with_known_codes_classify_through_the_registry() {
2595        let error = api_error(409, "stale_revision");
2596        assert_eq!(error.code(), Some(ErrorCode::StaleRevision));
2597        assert_eq!(error.kind(), Some(ErrorKind::Conflict));
2598
2599        let error = api_error(409, "content_not_prepared");
2600        assert_eq!(error.code(), Some(ErrorCode::ContentNotPrepared));
2601        assert_eq!(error.kind(), Some(ErrorKind::Conflict));
2602
2603        let error = api_error(410, "namespace_deleted");
2604        assert_eq!(error.code(), Some(ErrorCode::NamespaceDeleted));
2605        assert_eq!(error.kind(), Some(ErrorKind::Gone));
2606
2607        let error = api_error(503, "commit_outcome_unknown");
2608        assert_eq!(error.code(), Some(ErrorCode::CommitOutcomeUnknown));
2609        assert_eq!(error.kind(), Some(ErrorKind::OutcomeUnknown));
2610
2611        let error = api_error(500, "index_corrupt");
2612        assert_eq!(error.code(), Some(ErrorCode::IndexCorrupt));
2613        assert_eq!(error.kind(), Some(ErrorKind::DataCorruption));
2614    }
2615
2616    #[test]
2617    fn api_errors_with_unknown_codes_fall_back_to_the_status_class() {
2618        for (status, kind) in [
2619            (400, ErrorKind::InvalidRequest),
2620            (404, ErrorKind::InvalidRequest),
2621            (500, ErrorKind::Internal),
2622            (503, ErrorKind::Unavailable),
2623        ] {
2624            let error = api_error(status, "code_from_a_newer_server");
2625            assert_eq!(error.code(), None);
2626            assert_eq!(error.kind(), Some(kind), "status {status}");
2627        }
2628    }
2629
2630    #[test]
2631    fn non_api_errors_have_no_code_or_kind() {
2632        let error = ClientError::Http("connection refused".to_owned());
2633        assert_eq!(error.code(), None);
2634        assert_eq!(error.kind(), None);
2635    }
2636
2637    #[test]
2638    fn load_rejects_invalid_server_url() {
2639        let path = write_config(
2640            r#"
2641server_url = "ftp://example.com"
2642auth_token = "dev-token"
2643"#,
2644        );
2645
2646        let error = ClientConfig::load(&path).expect_err("invalid server url");
2647
2648        assert!(
2649            matches!(error, ClientError::ConfigValidation { field, .. } if field == "server_url"),
2650            "expected config validation error, got {error:?}"
2651        );
2652    }
2653
2654    #[test]
2655    fn load_rejects_blank_auth_token() {
2656        let path = write_config(
2657            r#"
2658server_url = "http://127.0.0.1:9400"
2659auth_token = "   "
2660"#,
2661        );
2662
2663        let error = ClientConfig::load(&path).expect_err("blank auth token");
2664
2665        assert!(
2666            matches!(error, ClientError::ConfigValidation { field, .. } if field == "auth_token"),
2667            "expected config validation error, got {error:?}"
2668        );
2669    }
2670
2671    #[test]
2672    fn load_preserves_missing_file_as_config_io() {
2673        let temp_dir = tempdir().expect("tempdir");
2674        let path = temp_dir.path().join("missing.toml");
2675
2676        let error = ClientConfig::load(&path).expect_err("missing config");
2677
2678        assert!(matches!(error, ClientError::ConfigIo(_)));
2679    }
2680
2681    #[test]
2682    fn load_preserves_decode_error() {
2683        let path = write_config("server_url = [");
2684
2685        let error = ClientConfig::load(&path).expect_err("decode error");
2686
2687        assert!(matches!(error, ClientError::ConfigDecode(_)));
2688    }
2689
2690    #[test]
2691    fn namespace_path_parse_rejects_invalid_namespace_id() {
2692        for namespace in ["bad/name", "Demo", "..", "demo?"] {
2693            assert!(
2694                matches!(
2695                    NamespacePath::parse(namespace, "/notes.txt"),
2696                    Err(ClientError::InvalidNamespacePath(_))
2697                ),
2698                "expected invalid namespace path for id {namespace:?}"
2699            );
2700        }
2701    }
2702
2703    /// Construction is the only door: the fields are private, so a bad id
2704    /// or a bad path fails `parse` with the same error the string-shuttling
2705    /// client surfaced before the fields were typed.
2706    #[test]
2707    fn namespace_path_parse_rejects_invalid_paths() {
2708        for path in ["notes.txt", "", "/docs/../a.txt", "/docs/./a.txt"] {
2709            assert!(
2710                matches!(
2711                    NamespacePath::parse("demo", path),
2712                    Err(ClientError::InvalidNamespacePath(_))
2713                ),
2714                "expected invalid namespace path for path {path:?}"
2715            );
2716        }
2717    }
2718
2719    fn write_config(contents: &str) -> std::path::PathBuf {
2720        let temp_dir = tempdir().expect("tempdir");
2721        let path = temp_dir.path().join("client.toml");
2722        fs::write(&path, contents).expect("write config");
2723        let _ = temp_dir.keep();
2724        path
2725    }
2726}