1use 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
28pub fn content_revision(bytes: &[u8]) -> String {
30 format!("blake3:{}", blake3::hash(bytes).to_hex())
31}
32
33pub const PAYKIT_PATH_PREFIX: &str = "/pub/paykit/v0/";
36
37pub const PAYKIT_APP_REGISTRY_PATH: &str = "/pub/paykit/v0/app-registry.json";
39pub const PAYKIT_NOISE_KEY_AUTHORIZATION_PATH: &str = "/pub/paykit-authority/v0/current-key.json";
41
42pub const PAYKIT_SHARED_STATE_PATH: &str = "/pub/paykit/v0/shared-state.bin";
44
45pub const PAYKIT_SHARED_STATE_WRITE_PATH_PREFIX: &str = "/pub/paykit/v0/shared-state-writes/";
47
48pub const PAYKIT_PRIVATE_PATH_PREFIX: &str = "/pub/paykit/v0/private";
55
56pub 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 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
85pub(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#[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#[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#[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#[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
433pub 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
485pub 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
510pub 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
544pub 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
561pub 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
579pub 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
589pub 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
661pub(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;