Skip to main content

paykit_lib/
pubky_routing.rs

1//! Concrete Pubky routing/storage helpers used by Paykit.
2//!
3//! Paykit supports Pubky as its storage and encrypted-message transport. This
4//! module centralizes public payment endpoint path construction and public
5//! storage access so call sites do not hard-code Pubky paths.
6
7use std::collections::HashMap;
8
9use pubky::{
10    errors::RequestError, Error as PubkyError, PubkyResource, PubkySession, PublicKey,
11    PublicStorage, StatusCode,
12};
13use tracing::{debug, error, instrument, trace};
14
15use crate::{
16    parse_paykit_app_registry_json, serialize_paykit_app_registry, validation::invalid_data,
17    PaykitAppId, PaykitAppRegistry, PaykitError, PaymentEndpointIdentifier, PaymentEndpointPayload,
18    PaymentList, Result, PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES, PAYMENT_LIST_MAX_ENDPOINTS,
19};
20
21mod locks;
22#[cfg(test)]
23pub(crate) use locks::with_write_lock_timeout;
24pub use locks::{
25    delete_resource_if_revision, is_write_conflict, put_resource_if_revision, with_write_lock,
26};
27
28/// Content fingerprint used to detect an application-level stale edit.
29pub fn content_revision(bytes: &[u8]) -> String {
30    format!("blake3:{}", blake3::hash(bytes).to_hex())
31}
32
33/// Conventional prefix for public Paykit data hosted on Pubky storage.
34///
35pub const PAYKIT_PATH_PREFIX: &str = "/pub/paykit/v0/";
36
37/// Public path for the identity-wide Paykit App Registry.
38pub const PAYKIT_APP_REGISTRY_PATH: &str = "/pub/paykit/v0/app-registry.json";
39/// Identity-authorized current Noise key, outside delegated Paykit write access.
40pub const PAYKIT_NOISE_KEY_AUTHORIZATION_PATH: &str = "/pub/paykit-authority/v0/current-key.json";
41
42/// Pubky path for the encrypted identity-wide SDK state.
43pub const PAYKIT_SHARED_STATE_PATH: &str = "/pub/paykit/v0/shared-state.bin";
44
45/// Prefix for empty markers identifying unconfirmed shared-state writes.
46pub const PAYKIT_SHARED_STATE_WRITE_PATH_PREFIX: &str = "/pub/paykit/v0/shared-state-writes/";
47
48/// Conventional prefix for private (encrypted) Paykit data.
49///
50/// This prefix is used as the base path for pubky-noise's encrypted messaging.
51/// The actual write and read paths are derived per-counterparty pair using
52/// [`pubky_noise::path_derivation::derive_asymmetric_paths`]. Pubky-noise manages
53/// individual file slots within the derived folders using a counter-based scheme.
54pub const PAYKIT_PRIVATE_PATH_PREFIX: &str = "/pub/paykit/v0/private";
55
56/// Conventional prefix for Encrypted Link recovery markers.
57///
58/// Marker paths are derived per-counterparty pair before being appended below
59/// this prefix, so the prefix itself does not identify the counterparty pair.
60pub const PAYKIT_ENCRYPTED_LINK_RECOVERY_PATH_PREFIX: &str =
61    "/pub/paykit/v0/encrypted-link-recovery";
62
63pub(crate) fn identity_pair_path_domain(
64    domain: &[u8],
65    local_identity_public_key: &PublicKey,
66    remote_identity_public_key: &PublicKey,
67) -> Vec<u8> {
68    let mut identities = [
69        local_identity_public_key.to_bytes(),
70        remote_identity_public_key.to_bytes(),
71    ];
72    identities.sort_unstable();
73    // Fixed-width keys make the pair unambiguous; sorting preserves peer parity.
74    // The caller still derives paths with a Noise DH secret to keep them private.
75    let mut path_domain = Vec::with_capacity(domain.len() + 64);
76    path_domain.extend_from_slice(domain);
77    path_domain.extend_from_slice(&identities[0]);
78    path_domain.extend_from_slice(&identities[1]);
79    path_domain
80}
81
82const LIST_PAGE_LIMIT: u16 = 100;
83const LIST_MAX_PAGES: usize = 100;
84
85/// Maximum accepted size for a public Paykit document (Payment Endpoint
86/// payloads, the Paykit App Registry, and recovery markers).
87///
88/// Public objects are served by other users' homeservers and must not be
89/// trusted to fit in memory; an unbounded read lets a hostile publisher
90/// exhaust the client. 64 KiB is far above any legitimate descriptor
91/// (LNURL, BOLT11 invoice, JSON endpoint payload).
92pub(crate) const MAX_PUBLIC_RESOURCE_BYTES: usize = 64 * 1024;
93
94#[derive(Debug, thiserror::Error)]
95#[error("Payment List exceeds caller-supplied limits")]
96struct PaymentListLimitExceeded;
97
98#[derive(Debug, thiserror::Error)]
99#[error("response exceeds caller-supplied byte limit")]
100struct ResponseSizeLimitExceeded;
101
102pub(crate) fn is_payment_list_limit_exceeded(error: &PaykitError) -> bool {
103    matches!(
104        error,
105        PaykitError::InvalidData {
106            source: Some(source),
107            ..
108        } if source.downcast_ref::<PaymentListLimitExceeded>().is_some()
109    )
110}
111
112fn is_response_size_limit_exceeded(error: &PaykitError) -> bool {
113    matches!(
114        error,
115        PaykitError::InvalidData {
116            source: Some(source),
117            ..
118        } if source.downcast_ref::<ResponseSizeLimitExceeded>().is_some()
119    )
120}
121
122fn payment_list_limit_exceeded(context: String) -> PaykitError {
123    PaykitError::InvalidData {
124        context,
125        source: Some(PaymentListLimitExceeded.into()),
126    }
127}
128
129pub(crate) fn log_payment_endpoint_storage_failure(
130    operation: &'static str,
131    _error: &impl std::fmt::Display,
132) {
133    error!(operation, "payment endpoint storage request failed");
134}
135
136/// Writes or updates a payment endpoint document in the authenticated Pubky session.
137#[instrument(skip(session, payload), fields(identifier = %identifier))]
138pub async fn upsert_payment_endpoint(
139    session: &PubkySession,
140    app_id: &PaykitAppId,
141    identifier: &PaymentEndpointIdentifier,
142    payload: &PaymentEndpointPayload,
143) -> Result<()> {
144    validate_payment_endpoint_payload(payload)?;
145    let path = payment_endpoint_path(app_id, identifier);
146    debug!(path = %path, "writing payment endpoint to Pubky storage");
147    with_write_lock(session, &path, |lock| async move {
148        session
149            .storage()
150            .put_locked(&lock, payload.as_str().to_string())
151            .await
152            .map_err(|err| {
153                log_payment_endpoint_storage_failure("put", &err);
154                PaykitError::Transport {
155                    context: "put endpoint".into(),
156                    source: err.into(),
157                }
158            })
159            .map(|_| ())
160    })
161    .await?;
162    debug!("payment endpoint stored successfully");
163    Ok(())
164}
165
166pub async fn create_payment_endpoint(
167    session: &PubkySession,
168    app_id: &PaykitAppId,
169    identifier: &PaymentEndpointIdentifier,
170    payload: &PaymentEndpointPayload,
171) -> Result<()> {
172    validate_payment_endpoint_payload(payload)?;
173    let path = payment_endpoint_path(app_id, identifier);
174    put_resource_if_revision(
175        session,
176        &path,
177        payload.as_str().as_bytes().to_vec(),
178        None,
179        PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES,
180    )
181    .await?;
182    Ok(())
183}
184
185pub async fn update_payment_endpoint(
186    session: &PubkySession,
187    app_id: &PaykitAppId,
188    identifier: &PaymentEndpointIdentifier,
189    payload: &PaymentEndpointPayload,
190    revision: &str,
191) -> Result<()> {
192    validate_payment_endpoint_payload(payload)?;
193    let path = payment_endpoint_path(app_id, identifier);
194    put_resource_if_revision(
195        session,
196        &path,
197        payload.as_str().as_bytes().to_vec(),
198        Some(revision),
199        usize::MAX,
200    )
201    .await?;
202    Ok(())
203}
204
205/// Removes an existing payment endpoint from the authenticated Pubky session.
206#[instrument(skip(session), fields(identifier = %identifier))]
207pub async fn delete_payment_endpoint(
208    session: &PubkySession,
209    app_id: &PaykitAppId,
210    identifier: &PaymentEndpointIdentifier,
211) -> Result<()> {
212    let path = payment_endpoint_path(app_id, identifier);
213    debug!(path = %path, "deleting payment endpoint from Pubky storage");
214    with_write_lock(session, &path, |lock| async move {
215        match session.storage().delete_locked(&lock).await {
216            Ok(_) => {}
217            Err(err) if is_not_found(&err) => {
218                debug!("payment endpoint already absent");
219            }
220            Err(err) => {
221                log_payment_endpoint_storage_failure("delete", &err);
222                return Err(PaykitError::Transport {
223                    context: "delete endpoint".into(),
224                    source: err.into(),
225                });
226            }
227        }
228        Ok::<_, PaykitError>(())
229    })
230    .await?;
231    debug!("payment endpoint removed successfully");
232    Ok(())
233}
234
235pub async fn delete_payment_endpoint_if_revision(
236    session: &PubkySession,
237    app_id: &PaykitAppId,
238    identifier: &PaymentEndpointIdentifier,
239    revision: &str,
240) -> Result<()> {
241    let path = payment_endpoint_path(app_id, identifier);
242    delete_resource_if_revision(session, &path, revision, usize::MAX).await?;
243    Ok(())
244}
245
246fn validate_payment_endpoint_payload(payload: &PaymentEndpointPayload) -> Result<()> {
247    if payload.as_str().len() > PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES {
248        return Err(PaykitError::Validation(format!(
249            "Payment Endpoint payload must not exceed {PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES} bytes"
250        )));
251    }
252    Ok(())
253}
254
255/// Fetches all public payment endpoints for the provided payee from Pubky storage.
256///
257/// Directory listing and per-resource fetches are not atomic; the returned list is a
258/// best-effort snapshot of the payee's homeserver state.
259#[instrument(skip(storage, remaining_endpoints, remaining_requests, remaining_payload_bytes), fields(payee = %payee))]
260pub async fn fetch_payment_list_with_budget(
261    storage: &PublicStorage,
262    payee: &PublicKey,
263    app_id: &PaykitAppId,
264    remaining_endpoints: &mut usize,
265    remaining_requests: &mut usize,
266    remaining_payload_bytes: &mut usize,
267) -> Result<PaymentList> {
268    let max_endpoints = (*remaining_endpoints).min(PAYMENT_LIST_MAX_ENDPOINTS);
269    let addr = format!("{payee}{}", payment_endpoint_path_prefix(app_id));
270    debug!("listing payment endpoints");
271    let resources = list_resources(
272        storage,
273        addr,
274        "list payment endpoints",
275        remaining_requests,
276        PAYMENT_LIST_MAX_ENDPOINTS,
277    )
278    .await?;
279
280    let resources = resources
281        .into_iter()
282        .filter(|resource| !resource.path.as_str().ends_with('/'))
283        .collect::<Vec<_>>();
284    if resources.len() > max_endpoints {
285        let context =
286            format!("Payment List contains more than the allowed {max_endpoints} endpoints");
287        return if max_endpoints < PAYMENT_LIST_MAX_ENDPOINTS {
288            Err(payment_list_limit_exceeded(context))
289        } else {
290            Err(PaykitError::InvalidData {
291                context,
292                source: None,
293            })
294        };
295    }
296
297    let mut map = HashMap::new();
298    for resource in resources {
299        let identifier_text = resource
300            .path
301            .as_str()
302            .rsplit('/')
303            .next()
304            .filter(|segment| !segment.is_empty())
305            .ok_or_else(|| {
306                error!("invalid resource path for Payment Endpoint");
307                PaykitError::InvalidData {
308                    context: format!(
309                        "cannot extract Payment Endpoint Identifier from resource path '{}'",
310                        resource.path
311                    ),
312                    source: None,
313                }
314            })?
315            .to_string();
316
317        let label = format!("fetch payment endpoint {identifier_text}");
318        let payload_limit = (*remaining_payload_bytes).min(PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES);
319        consume_payment_list_request(remaining_requests)?;
320        *remaining_endpoints -= 1;
321        let payload = match fetch_text_with_budget(
322            storage,
323            resource.to_string(),
324            &label,
325            Some(payload_limit),
326            remaining_payload_bytes,
327        )
328        .await
329        {
330            Ok(payload) => payload,
331            Err(error)
332                if payload_limit < PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES
333                    && is_response_size_limit_exceeded(&error) =>
334            {
335                return Err(payment_list_limit_exceeded(
336                    "Payment List payload budget exhausted".into(),
337                ));
338            }
339            Err(error) => return Err(error),
340        };
341        if let Some(payload) = payload {
342            debug!(identifier = %identifier_text, "fetched Payment Endpoint Payload");
343            let payment_endpoint_identifier = PaymentEndpointIdentifier::new(&identifier_text)
344                .map_err(|err| PaykitError::InvalidData {
345                    context: format!(
346                        "storage returned invalid Payment Endpoint Identifier '{identifier_text}'"
347                    ),
348                    source: Some(err.into()),
349                })?;
350            map.insert(
351                payment_endpoint_identifier,
352                PaymentEndpointPayload::new(payload),
353            );
354        }
355    }
356
357    debug!(count = map.len(), "Payment List collected");
358    Ok(PaymentList {
359        payment_endpoints: map,
360    })
361}
362
363fn consume_payment_list_request(remaining_requests: &mut usize) -> Result<()> {
364    *remaining_requests = remaining_requests.checked_sub(1).ok_or_else(|| {
365        payment_list_limit_exceeded("Payment List request budget exhausted".into())
366    })?;
367    Ok(())
368}
369
370/// Fetches an individual public payment endpoint from Pubky storage.
371#[instrument(skip(storage), fields(payee = %payee, identifier = %identifier))]
372pub async fn fetch_payment_endpoint(
373    storage: &PublicStorage,
374    payee: &PublicKey,
375    app_id: &PaykitAppId,
376    identifier: &PaymentEndpointIdentifier,
377) -> Result<Option<PaymentEndpointPayload>> {
378    let addr = format!("{payee}{}", payment_endpoint_path(app_id, identifier));
379    debug!("fetching individual payment endpoint");
380    match fetch_text(
381        storage,
382        addr,
383        "fetch endpoint",
384        Some(PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES),
385    )
386    .await?
387    {
388        Some(payload) => {
389            debug!("payment endpoint found");
390            Ok(Some(PaymentEndpointPayload::new(payload)))
391        }
392        None => {
393            debug!("payment endpoint not found");
394            Ok(None)
395        }
396    }
397}
398
399pub async fn fetch_payment_endpoint_with_revision(
400    storage: &PublicStorage,
401    payee: &PublicKey,
402    app_id: &PaykitAppId,
403    identifier: &PaymentEndpointIdentifier,
404) -> Result<Option<(Option<PaymentEndpointPayload>, String)>> {
405    let addr = format!("{payee}{}", payment_endpoint_path(app_id, identifier));
406    let response = match storage.get(&addr).await {
407        Ok(response) => response,
408        Err(err) if is_not_found(&err) => return Ok(None),
409        Err(err) => {
410            return Err(PaykitError::Transport {
411                context: "fetch endpoint".into(),
412                source: err.into(),
413            });
414        }
415    };
416    let bytes = read_bounded_body(
417        response,
418        PAYMENT_ENDPOINT_PAYLOAD_MAX_BYTES,
419        "fetch endpoint",
420    )
421    .await?;
422    let revision = content_revision(&bytes);
423    let payload = String::from_utf8(bytes)
424        .map_err(|err| invalid_data("Payment Endpoint is not UTF-8", Some(err.into())))?;
425    let payload = if payload.is_empty() {
426        None
427    } else {
428        Some(PaymentEndpointPayload::new(payload))
429    };
430    Ok(Some((payload, revision)))
431}
432
433/// Lists one App's Payment Endpoint Identifiers without loading their payloads.
434///
435/// Cleanup can enumerate more than a valid Payment List's endpoint limit. Listing
436/// remains bounded to 100 pages; invalid identifiers or paths are rejected.
437/// Invalid names require explicit Pubky cleanup rather than bypassing identifier validation.
438/// Session creation, capability scope, and key rotation remain the caller's responsibility.
439pub async fn list_payment_endpoint_identifiers(
440    storage: &PublicStorage,
441    owner: &PublicKey,
442    app_id: &PaykitAppId,
443) -> Result<Vec<PaymentEndpointIdentifier>> {
444    let prefix = payment_endpoint_path_prefix(app_id);
445    let mut requests = LIST_MAX_PAGES + 1;
446    let resources = list_resources(
447        storage,
448        format!("{owner}{prefix}"),
449        "list Payment Endpoint Identifiers",
450        &mut requests,
451        LIST_MAX_PAGES * usize::from(LIST_PAGE_LIMIT),
452    )
453    .await?;
454    resources
455        .into_iter()
456        .filter(|resource| !resource.path.as_str().ends_with('/'))
457        .map(|resource| payment_endpoint_identifier(&resource, owner, &prefix))
458        .collect()
459}
460
461fn payment_endpoint_identifier(
462    resource: &PubkyResource,
463    owner: &PublicKey,
464    prefix: &str,
465) -> Result<PaymentEndpointIdentifier> {
466    let identifier = resource
467        .path
468        .as_str()
469        .strip_prefix(prefix)
470        .filter(|_| resource.owner == *owner)
471        .ok_or_else(|| {
472            invalid_data(
473                "Payment Endpoint listing returned an unrelated resource",
474                None,
475            )
476        })?;
477    PaymentEndpointIdentifier::new(identifier).map_err(|error| {
478        invalid_data(
479            "Payment Endpoint listing returned an invalid identifier",
480            Some(error.into()),
481        )
482    })
483}
484
485/// Reads a resource's content fingerprint without decoding its bytes.
486///
487/// This supports conditional repair of malformed documents. Reads are streamed
488/// and bounded by `max_bytes`; missing resources return `None`. Session creation,
489/// capability scope, key rotation, and request timeouts remain the caller's responsibility.
490pub async fn fetch_resource_revision(
491    storage: &PublicStorage,
492    resource: &PubkyResource,
493    max_bytes: usize,
494) -> Result<Option<String>> {
495    let response = match storage.get(resource.clone()).await {
496        Ok(response) => response,
497        Err(error) if is_not_found(&error) => return Ok(None),
498        Err(error) => {
499            return Err(PaykitError::Transport {
500                context: "fetch resource revision".into(),
501                source: error.into(),
502            })
503        }
504    };
505    read_response_revision(response, max_bytes, "fetch resource revision")
506        .await
507        .map(Some)
508}
509
510/// Reads an owned Payment Endpoint's fingerprint for conditional repair or removal.
511///
512/// Unlike payment discovery, maintenance must also handle oversized or non-UTF-8
513/// payloads. The complete body is hashed incrementally without retaining it in
514/// memory. Use this on the caller's own endpoints; request timeouts, session
515/// creation, capability scope, and key rotation remain the caller's responsibility.
516pub async fn fetch_payment_endpoint_revision(
517    session: &PubkySession,
518    app_id: &PaykitAppId,
519    identifier: &PaymentEndpointIdentifier,
520) -> Result<Option<String>> {
521    locks::resource_revision(
522        session,
523        &payment_endpoint_path(app_id, identifier),
524        usize::MAX,
525    )
526    .await
527}
528
529pub(crate) fn payment_endpoint_path_prefix(app_id: &PaykitAppId) -> String {
530    format!("{PAYKIT_PATH_PREFIX}apps/{app_id}/endpoints/")
531}
532
533pub(crate) fn payment_endpoint_path(
534    app_id: &PaykitAppId,
535    identifier: &PaymentEndpointIdentifier,
536) -> String {
537    format!(
538        "{}{}",
539        payment_endpoint_path_prefix(app_id),
540        identifier.as_str()
541    )
542}
543
544/// Creates the identity-wide Paykit App Registry if it does not exist.
545pub async fn create_paykit_app_registry(
546    session: &PubkySession,
547    registry: &PaykitAppRegistry,
548) -> Result<()> {
549    let body = serialize_paykit_app_registry(registry)?;
550    put_resource_if_revision(
551        session,
552        PAYKIT_APP_REGISTRY_PATH,
553        body.into_bytes(),
554        None,
555        crate::PAYKIT_APP_REGISTRY_MAX_BYTES,
556    )
557    .await?;
558    Ok(())
559}
560
561/// Replaces the identity-wide Paykit App Registry at one exact revision.
562pub async fn update_paykit_app_registry(
563    session: &PubkySession,
564    registry: &PaykitAppRegistry,
565    revision: &str,
566) -> Result<()> {
567    let body = serialize_paykit_app_registry(registry)?;
568    put_resource_if_revision(
569        session,
570        PAYKIT_APP_REGISTRY_PATH,
571        body.into_bytes(),
572        Some(revision),
573        crate::PAYKIT_APP_REGISTRY_MAX_BYTES,
574    )
575    .await?;
576    Ok(())
577}
578
579/// Fetches and parses the identity-wide Paykit App Registry.
580pub async fn fetch_paykit_app_registry(
581    storage: &PublicStorage,
582    owner: &PublicKey,
583) -> Result<Option<PaykitAppRegistry>> {
584    Ok(fetch_paykit_app_registry_with_revision(storage, owner)
585        .await?
586        .map(|(registry, _)| registry))
587}
588
589/// Fetches and parses the identity-wide Paykit App Registry with its content revision.
590pub async fn fetch_paykit_app_registry_with_revision(
591    storage: &PublicStorage,
592    owner: &PublicKey,
593) -> Result<Option<(PaykitAppRegistry, String)>> {
594    let addr = format!("{owner}{PAYKIT_APP_REGISTRY_PATH}");
595    let response = match storage.get(&addr).await {
596        Ok(response) => response,
597        Err(err) if is_not_found(&err) => return Ok(None),
598        Err(err) => {
599            return Err(PaykitError::Transport {
600                context: "fetch Paykit App Registry".into(),
601                source: err.into(),
602            });
603        }
604    };
605    let bytes = read_bounded_body(
606        response,
607        crate::PAYKIT_APP_REGISTRY_MAX_BYTES,
608        "fetch Paykit App Registry",
609    )
610    .await?;
611    let revision = content_revision(&bytes);
612    let body = String::from_utf8(bytes)
613        .map_err(|err| invalid_data("Paykit App Registry is not UTF-8", Some(err.into())))?;
614    Ok(Some((parse_paykit_app_registry_json(&body)?, revision)))
615}
616
617#[instrument(skip(storage, addr, label), fields(operation = %label))]
618pub(crate) async fn fetch_text(
619    storage: &PublicStorage,
620    addr: String,
621    label: &str,
622    max_bytes: Option<usize>,
623) -> Result<Option<String>> {
624    let mut remaining_payload_bytes = usize::MAX;
625    fetch_text_with_budget(
626        storage,
627        addr,
628        label,
629        max_bytes,
630        &mut remaining_payload_bytes,
631    )
632    .await
633}
634
635async fn fetch_text_with_budget(
636    storage: &PublicStorage,
637    addr: String,
638    label: &str,
639    max_bytes: Option<usize>,
640    remaining_payload_bytes: &mut usize,
641) -> Result<Option<String>> {
642    trace!("fetching text resource");
643    match storage.get(&addr).await {
644        Ok(mut resp) => {
645            read_text_response(&mut resp, label, max_bytes, remaining_payload_bytes).await
646        }
647        Err(err) if is_not_found(&err) => {
648            debug!("resource not found (404/GONE)");
649            Ok(None)
650        }
651        Err(err) => {
652            error!("transport error during fetch");
653            Err(PaykitError::Transport {
654                context: label.to_string(),
655                source: err.into(),
656            })
657        }
658    }
659}
660
661/// Read a response with a hard streaming bound, including when Content-Length
662/// is absent or inaccurate. Outbox bodies are also untrusted homeserver data.
663pub(crate) async fn read_bounded_body(
664    mut response: reqwest::Response,
665    max_bytes: usize,
666    label: &str,
667) -> Result<Vec<u8>> {
668    read_response_body(&mut response, label, max_bytes).await
669}
670
671async fn read_response_body(
672    response: &mut reqwest::Response,
673    label: &str,
674    max_bytes: usize,
675) -> Result<Vec<u8>> {
676    let mut remaining_payload_bytes = usize::MAX;
677    read_response_body_with_budget(response, label, max_bytes, &mut remaining_payload_bytes).await
678}
679
680async fn read_response_revision(
681    mut response: reqwest::Response,
682    max_bytes: usize,
683    label: &str,
684) -> Result<String> {
685    let mut hasher = blake3::Hasher::new();
686    let mut remaining = max_bytes;
687    if response
688        .content_length()
689        .is_some_and(|length| length > max_bytes as u64)
690    {
691        return Err(invalid_data(
692            format!("{label}: response exceeds the {max_bytes}-byte limit"),
693            None,
694        ));
695    }
696    while let Some(chunk) = response
697        .chunk()
698        .await
699        .map_err(|error| PaykitError::Transport {
700            context: label.into(),
701            source: error.into(),
702        })?
703    {
704        remaining = remaining.checked_sub(chunk.len()).ok_or_else(|| {
705            invalid_data(
706                format!("{label}: response exceeds the {max_bytes}-byte limit"),
707                None,
708            )
709        })?;
710        hasher.update(&chunk);
711    }
712    Ok(format!("blake3:{}", hasher.finalize().to_hex()))
713}
714
715async fn read_response_body_with_budget(
716    response: &mut reqwest::Response,
717    label: &str,
718    max_bytes: usize,
719    remaining_payload_bytes: &mut usize,
720) -> Result<Vec<u8>> {
721    if let Some(content_length) = response.content_length() {
722        if content_length > max_bytes as u64 {
723            return Err(PaykitError::InvalidData {
724                context: format!("{label}: response exceeds the {max_bytes}-byte limit"),
725                source: Some(ResponseSizeLimitExceeded.into()),
726            });
727        }
728    }
729    let mut bytes = Vec::new();
730    while let Some(chunk) = response.chunk().await.map_err(|err| {
731        error!("failed to read response bytes");
732        PaykitError::Transport {
733            context: label.to_string(),
734            source: err.into(),
735        }
736    })? {
737        *remaining_payload_bytes = remaining_payload_bytes.saturating_sub(chunk.len());
738        if bytes.len().saturating_add(chunk.len()) > max_bytes {
739            return Err(PaykitError::InvalidData {
740                context: format!("{label}: response exceeds the {max_bytes}-byte limit"),
741                source: Some(ResponseSizeLimitExceeded.into()),
742            });
743        }
744        bytes.extend_from_slice(&chunk);
745    }
746    Ok(bytes)
747}
748
749async fn read_text_response(
750    response: &mut reqwest::Response,
751    label: &str,
752    max_bytes: Option<usize>,
753    remaining_payload_bytes: &mut usize,
754) -> Result<Option<String>> {
755    let bytes = read_response_body_with_budget(
756        response,
757        label,
758        max_bytes.unwrap_or(MAX_PUBLIC_RESOURCE_BYTES),
759        remaining_payload_bytes,
760    )
761    .await?;
762    if bytes.is_empty() {
763        debug!("resource is empty, returning None");
764        return Ok(None);
765    }
766    let data = String::from_utf8(bytes).map_err(|err| {
767        let pos = err.utf8_error().valid_up_to();
768        error!(
769            valid_up_to = pos,
770            "response contains invalid UTF-8 — data may be corrupt"
771        );
772        PaykitError::InvalidData {
773            context: format!("{label}: invalid UTF-8 at byte {pos}"),
774            source: Some(err.into()),
775        }
776    })?;
777    trace!(len = data.len(), "text resource fetched");
778    Ok(Some(data))
779}
780
781#[instrument(skip(storage, addr, label), fields(operation = %label))]
782async fn list_resources(
783    storage: &PublicStorage,
784    addr: String,
785    label: &str,
786    remaining_requests: &mut usize,
787    max_resources: usize,
788) -> Result<Vec<PubkyResource>> {
789    trace!("listing directory resources");
790    let mut resources = Vec::new();
791    let mut cursor = None::<String>;
792    let mut pages = 0usize;
793
794    loop {
795        let mut builder = match storage.list(&addr) {
796            Ok(builder) => builder.shallow(true).limit(LIST_PAGE_LIMIT),
797            Err(err) if is_not_found(&err) => {
798                debug!("directory not found, returning listed resources");
799                return Ok(resources);
800            }
801            Err(err) => {
802                error!("failed to create list builder");
803                return Err(PaykitError::Transport {
804                    context: label.to_string(),
805                    source: err.into(),
806                });
807            }
808        };
809
810        if let Some(cursor) = cursor.as_deref() {
811            builder = builder.cursor(cursor);
812        }
813
814        consume_payment_list_request(remaining_requests)?;
815        let page = match builder.send().await {
816            Ok(page) => page,
817            Err(err) if is_not_found(&err) => {
818                debug!("directory not found during send, returning listed resources");
819                return Ok(resources);
820            }
821            Err(err) => {
822                error!("list send failed");
823                return Err(PaykitError::Transport {
824                    context: format!("{label} send failed"),
825                    source: err.into(),
826                });
827            }
828        };
829
830        if page.is_empty() {
831            break;
832        }
833
834        pages += 1;
835        if pages > LIST_MAX_PAGES {
836            return Err(invalid_data(
837                format!("{label}: listing exceeded {LIST_MAX_PAGES} pages"),
838                None,
839            ));
840        }
841
842        let page_len = page.len();
843        if resources.len().saturating_add(page_len) > max_resources {
844            return Err(PaykitError::InvalidData {
845                context: format!("{label}: directory contains more than {max_resources} resources"),
846                source: None,
847            });
848        }
849        let next_cursor = page
850            .last()
851            .map(|resource| format!("{}{}", resource.owner.z32(), resource.path.as_str()))
852            .ok_or_else(|| PaykitError::InvalidData {
853                context: format!("{label}: non-empty page has no cursor resource"),
854                source: None,
855            })?;
856        if cursor
857            .as_ref()
858            .is_some_and(|previous| next_cursor.as_str() <= previous.as_str())
859        {
860            return Err(PaykitError::InvalidData {
861                context: format!("{label}: directory cursor did not advance"),
862                source: None,
863            });
864        }
865        cursor = Some(next_cursor);
866        resources.extend(page);
867
868        if page_len < LIST_PAGE_LIMIT as usize {
869            break;
870        }
871    }
872
873    debug!(count = resources.len(), "directory resources listed");
874    Ok(resources)
875}
876
877pub(crate) fn is_not_found(err: &PubkyError) -> bool {
878    matches!(
879        err,
880        PubkyError::Request(RequestError::Server { status, .. })
881            if *status == StatusCode::NOT_FOUND || *status == StatusCode::GONE
882    )
883}
884
885#[cfg(test)]
886mod tests;