1pub mod proto_gen {
46 #![allow(unreachable_pub)]
47 pub mod lore {
48 pub mod model {
49 pub mod v1 {
50 tonic::include_proto!("lore.model.v1");
51 }
52 }
53 pub mod revision {
54 pub mod v1 {
55 tonic::include_proto!("lore.revision.v1");
56 }
57 }
58 pub mod repository {
59 pub mod v1 {
60 tonic::include_proto!("lore.repository.v1");
61 }
62 }
63 pub mod storage {
64 pub mod v1 {
65 tonic::include_proto!("lore.storage.v1");
66 }
67 }
68 pub mod thin_client {
69 pub mod v1 {
70 tonic::include_proto!("lore.thin_client.v1");
71 }
72 }
73 }
74}
75
76pub use proto_gen::lore::model::v1::Branch;
78pub use proto_gen::lore::repository::v1::repository_service_client::RepositoryServiceClient;
79pub use proto_gen::lore::repository::v1::{RepositoryGetRequest, RepositoryListRequest};
80pub use proto_gen::lore::revision::v1::branch_get_request;
81pub use proto_gen::lore::revision::v1::revision_service_client::RevisionServiceClient;
82pub use proto_gen::lore::revision::v1::{BranchGetRequest, BranchPushRequest};
83pub use proto_gen::lore::revision::v1::{BranchListRequest, RevisionListRequest};
84pub use proto_gen::lore::storage::v1::storage_service_client::StorageServiceClient;
85use proto_gen::lore::thin_client::v1::revision_tree_request::Query as RevisionTreeQuery;
86pub use proto_gen::lore::thin_client::v1::thin_client_service_client::ThinClientServiceClient;
87pub use proto_gen::lore::thin_client::v1::{RevisionInfoRequest, RevisionTreeRequest};
88
89use std::future::Future;
90use std::sync::LazyLock;
91use std::thread;
92use std::time::Duration;
93
94use tonic::codegen::InterceptedService;
95use tonic::metadata::{BinaryMetadataValue, MetadataValue};
96use tonic::service::Interceptor;
97use tonic::transport::{Channel, Endpoint};
98
99use crate::error::NapError;
100
101#[derive(Clone)]
114struct GrpcAuthInterceptor {
115 token: Option<String>,
116 repository_id_bytes: Vec<u8>,
117}
118
119impl Interceptor for GrpcAuthInterceptor {
120 fn call(
121 &mut self,
122 mut request: tonic::Request<()>,
123 ) -> Result<tonic::Request<()>, tonic::Status> {
124 if let Some(ref token) = self.token
126 && !token.is_empty()
127 {
128 let mut value: MetadataValue<_> = format!("Bearer {token}")
129 .parse()
130 .map_err(|e| tonic::Status::invalid_argument(format!("bad token metadata: {e}")))?;
131 value.set_sensitive(true);
132 request.metadata_mut().insert("authorization", value);
133 }
134
135 if !self.repository_id_bytes.is_empty() {
137 let bin_val = BinaryMetadataValue::from_bytes(&self.repository_id_bytes);
138 request
139 .metadata_mut()
140 .insert_bin("lore-partition-bin", bin_val.clone());
141 request
142 .metadata_mut()
143 .insert_bin("urc-repository-id-bin", bin_val);
144 }
145
146 Ok(request)
147 }
148}
149
150#[derive(Debug, Clone)]
167pub struct LoreGrpcClient {
168 channel: Channel,
169 token: Option<String>,
170 repository_id_bytes: Vec<u8>,
171}
172
173impl LoreGrpcClient {
174 pub fn builder() -> Builder {
176 Builder::default()
177 }
178
179 pub fn for_repository_id(&self, id: impl Into<Vec<u8>>) -> Self {
181 Self {
182 channel: self.channel.clone(),
183 token: self.token.clone(),
184 repository_id_bytes: id.into(),
185 }
186 }
187
188 pub async fn get_branch_by_name(&self, name: &str) -> Result<Branch, NapError> {
195 let mut client = self.make_client();
196 let response = client
197 .branch_get(BranchGetRequest {
198 query: Some(branch_get_request::Query::Name(name.to_string())),
199 })
200 .await
201 .map_err(|status| map_grpc_status("BranchGet", status))?;
202
203 response.into_inner().branch.ok_or_else(|| {
204 NapError::GrpcError(format!("BranchGet({name}) returned empty branch record"))
205 })
206 }
207
208 pub async fn list_branches(&self) -> Result<Vec<Branch>, NapError> {
209 let mut client = self.make_client();
210 let mut stream = client
211 .branch_list(BranchListRequest {
212 creator: None,
213 include_deleted: false,
214 })
215 .await
216 .map_err(|status| map_grpc_status("BranchList", status))?
217 .into_inner();
218 let mut branches = Vec::new();
219 while let Some(item) = stream
220 .message()
221 .await
222 .map_err(|status| map_grpc_status("BranchList", status))?
223 {
224 if let Some(branch) = item.branch {
225 branches.push(branch);
226 }
227 }
228 Ok(branches)
229 }
230
231 pub async fn list_revisions(
232 &self,
233 identifier: proto_gen::lore::model::v1::RevisionIdentifier,
234 ) -> Result<Vec<proto_gen::lore::model::v1::RevisionItem>, NapError> {
235 let mut client = self.make_client();
236 Ok(client
237 .revision_list(RevisionListRequest {
238 start: Some(
239 proto_gen::lore::revision::v1::revision_list_request::Start::Identifier(
240 identifier,
241 ),
242 ),
243 })
244 .await
245 .map_err(|status| map_grpc_status("RevisionList", status))?
246 .into_inner()
247 .items)
248 }
249
250 pub async fn push_branch(
260 &self,
261 branch_id: bytes::Bytes,
262 revision_signature: bytes::Bytes,
263 force: bool,
264 ) -> Result<(), NapError> {
265 let mut client = self.make_client();
266 client
267 .branch_push(BranchPushRequest {
268 id: branch_id,
269 revision_signature,
270 force,
271 fast_forward_merge: !force,
272 })
273 .await
274 .map_err(|status| map_grpc_status("BranchPush", status))?;
275 Ok(())
276 }
277
278 pub fn builder_from_env() -> Result<Option<Self>, NapError> {
286 Builder::from_env()
287 }
288
289 fn make_client(
291 &self,
292 ) -> RevisionServiceClient<InterceptedService<Channel, GrpcAuthInterceptor>> {
293 RevisionServiceClient::with_interceptor(
294 self.channel.clone(),
295 GrpcAuthInterceptor {
296 token: self.token.clone(),
297 repository_id_bytes: self.repository_id_bytes.clone(),
298 },
299 )
300 }
301
302 fn make_repository_client(
303 &self,
304 ) -> RepositoryServiceClient<InterceptedService<Channel, GrpcAuthInterceptor>> {
305 RepositoryServiceClient::with_interceptor(
306 self.channel.clone(),
307 GrpcAuthInterceptor {
308 token: self.token.clone(),
309 repository_id_bytes: Vec::new(),
310 },
311 )
312 }
313
314 fn make_storage_client(
315 &self,
316 ) -> StorageServiceClient<InterceptedService<Channel, GrpcAuthInterceptor>> {
317 StorageServiceClient::with_interceptor(
318 self.channel.clone(),
319 GrpcAuthInterceptor {
320 token: self.token.clone(),
321 repository_id_bytes: self.repository_id_bytes.clone(),
322 },
323 )
324 }
325
326 fn make_thin_client(
327 &self,
328 ) -> ThinClientServiceClient<InterceptedService<Channel, GrpcAuthInterceptor>> {
329 ThinClientServiceClient::with_interceptor(
330 self.channel.clone(),
331 GrpcAuthInterceptor {
332 token: self.token.clone(),
333 repository_id_bytes: self.repository_id_bytes.clone(),
334 },
335 )
336 }
337
338 fn revision_tree_for_file(query: RevisionTreeQuery, path: &str) -> RevisionTreeRequest {
344 RevisionTreeRequest {
345 query: Some(query),
346 path_prefix: Some(parent_tree_path(path)),
347 max_depth: Some(1),
348 }
349 }
350
351 async fn read_storage_content(
358 &self,
359 address: proto_gen::lore::model::v1::Address,
360 ) -> Result<Vec<u8>, NapError> {
361 let mut pending = vec![(address, 0_u64)];
362 let mut content = None;
363 let mut ranges = Vec::<std::ops::Range<usize>>::new();
364 let mut fragments_seen = 0_usize;
365
366 while let Some((address, base_offset)) = pending.pop() {
367 fragments_seen += 1;
368 if fragments_seen > 100_000 {
369 return Err(storage_protocol_error(
370 "fragment tree exceeds 100,000 nodes",
371 ));
372 }
373
374 let (fragment, payload) = self.read_storage_fragment(address.clone()).await?;
375 let size_content = usize::try_from(fragment.size_content).map_err(|_| {
376 storage_protocol_error("fragment content size exceeds platform limits")
377 })?;
378
379 if content.is_none() {
380 content = Some(vec![0; size_content]);
381 }
382 let root_size = content.as_ref().expect("content initialized").len();
383 let fragment_end = base_offset
384 .checked_add(fragment.size_content)
385 .and_then(|end| usize::try_from(end).ok())
386 .ok_or_else(|| storage_protocol_error("fragment offset overflows"))?;
387 if fragment_end > root_size {
388 return Err(storage_protocol_error(
389 "fragment extends beyond root content",
390 ));
391 }
392
393 if fragment.flags & FRAGMENT_PAYLOAD_FRAGMENTED != 0 {
394 for reference in decode_fragment_references(&payload)? {
395 let child_offset =
396 base_offset.checked_add(reference.offset).ok_or_else(|| {
397 storage_protocol_error("fragment reference offset overflows")
398 })?;
399 pending.push((
400 proto_gen::lore::model::v1::Address {
401 hash: reference.hash.into(),
402 context: address.context.clone(),
403 },
404 child_offset,
405 ));
406 }
407 } else {
408 let decoded = decode_fragment_payload(&fragment, &payload)?;
409 let end = base_offset
410 .checked_add(decoded.len() as u64)
411 .and_then(|end| usize::try_from(end).ok())
412 .ok_or_else(|| storage_protocol_error("fragment payload offset overflows"))?;
413 if end > root_size || decoded.len() != size_content {
414 return Err(storage_protocol_error(
415 "leaf payload has an invalid content size",
416 ));
417 }
418 let start = base_offset as usize;
419 if ranges
420 .iter()
421 .any(|range| start < range.end && end > range.start)
422 {
423 return Err(storage_protocol_error("fragment leaves overlap"));
424 }
425 content.as_mut().expect("content initialized")[start..end]
426 .copy_from_slice(&decoded);
427 ranges.push(start..end);
428 }
429 }
430
431 let content =
432 content.ok_or_else(|| storage_protocol_error("storage returned no fragments"))?;
433 ranges.sort_unstable_by_key(|range| range.start);
434 let mut cursor = 0;
435 for range in ranges {
436 if range.start != cursor {
437 return Err(storage_protocol_error(
438 "fragment leaves do not cover root content",
439 ));
440 }
441 cursor = range.end;
442 }
443 if cursor != content.len() {
444 return Err(storage_protocol_error(
445 "fragment leaves do not cover root content",
446 ));
447 }
448 Ok(content)
449 }
450
451 async fn read_storage_fragment(
452 &self,
453 address: proto_gen::lore::model::v1::Address,
454 ) -> Result<(proto_gen::lore::model::v1::Fragment, Vec<u8>), NapError> {
455 let mut storage = self.make_storage_client();
456 let mut stream = storage
457 .get(tokio_stream::iter([address]))
458 .await
459 .map_err(|status| map_grpc_status("StorageGet", status))?
460 .into_inner();
461 let response = stream
462 .message()
463 .await
464 .map_err(|status| map_grpc_status("StorageGet", status))?
465 .ok_or_else(|| storage_protocol_error("storage returned no response"))?;
466 if stream
467 .message()
468 .await
469 .map_err(|status| map_grpc_status("StorageGet", status))?
470 .is_some()
471 {
472 return Err(storage_protocol_error(
473 "storage returned multiple responses for one address",
474 ));
475 }
476 let fragment = response.fragment.ok_or_else(|| {
477 storage_protocol_error("storage response is missing fragment metadata")
478 })?;
479 if response.payload.len() != fragment.size_payload as usize {
480 return Err(storage_protocol_error(
481 "storage payload length does not match fragment metadata",
482 ));
483 }
484 Ok((fragment, response.payload.to_vec()))
485 }
486
487 pub async fn get_repository_by_name(
489 &self,
490 name: &str,
491 ) -> Result<proto_gen::lore::model::v1::Repository, NapError> {
492 let mut client = self.make_repository_client();
493 client
494 .repository_get(RepositoryGetRequest {
495 query: Some(
496 proto_gen::lore::repository::v1::repository_get_request::Query::Name(
497 name.to_string(),
498 ),
499 ),
500 })
501 .await
502 .map_err(|status| map_grpc_status("RepositoryGet", status))?
503 .into_inner()
504 .repository
505 .ok_or_else(|| {
506 NapError::GrpcError(format!("RepositoryGet({name}) returned no repository"))
507 })
508 }
509
510 pub async fn list_repositories(&self) -> Result<Vec<String>, NapError> {
512 let mut client = self.make_repository_client();
513 let mut stream = client
514 .repository_list(RepositoryListRequest { creator: None })
515 .await
516 .map_err(|status| map_grpc_status("RepositoryList", status))?
517 .into_inner();
518 let mut names = Vec::new();
519 while let Some(item) = stream
520 .message()
521 .await
522 .map_err(|status| map_grpc_status("RepositoryList", status))?
523 {
524 if let Some(repository) = item.repository {
525 names.push(repository.name);
526 }
527 }
528 Ok(names)
529 }
530
531 pub async fn read_file_at_revision(
534 &self,
535 identifier: proto_gen::lore::model::v1::RevisionIdentifier,
536 path: String,
537 ) -> Result<(Vec<u8>, Vec<u8>), NapError> {
538 let mut tree = self.make_thin_client();
539 let mut stream = tree
540 .revision_tree(Self::revision_tree_for_file(
541 RevisionTreeQuery::Identifier(identifier),
542 &path,
543 ))
544 .await
545 .map_err(|status| map_grpc_status("RevisionTree", status))?
546 .into_inner();
547 let mut signature = Vec::new();
548 let mut address = None;
549 while let Some(item) = stream
550 .message()
551 .await
552 .map_err(|status| map_grpc_status("RevisionTree", status))?
553 {
554 match item.payload {
555 Some(
556 proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Header(
557 header,
558 ),
559 ) => signature = header.signature.to_vec(),
560 Some(proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Node(
561 node,
562 )) if node.path == path => address = node.address,
563 _ => {}
564 }
565 }
566 let address = address.ok_or_else(|| NapError::ManifestNotFound(path.clone()))?;
567 let bytes = self.read_storage_content(address).await?;
568 Ok((bytes, signature))
569 }
570
571 pub async fn read_file_at_signature(
573 &self,
574 signature: Vec<u8>,
575 path: String,
576 ) -> Result<(Vec<u8>, Vec<u8>), NapError> {
577 let mut tree = self.make_thin_client();
578 let mut stream = tree
579 .revision_tree(Self::revision_tree_for_file(
580 RevisionTreeQuery::Signature(signature.into()),
581 &path,
582 ))
583 .await
584 .map_err(|status| map_grpc_status("RevisionTree", status))?
585 .into_inner();
586 let mut resolved_signature = Vec::new();
587 let mut address = None;
588 while let Some(item) = stream
589 .message()
590 .await
591 .map_err(|status| map_grpc_status("RevisionTree", status))?
592 {
593 match item.payload {
594 Some(
595 proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Header(
596 header,
597 ),
598 ) => resolved_signature = header.signature.to_vec(),
599 Some(proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Node(
600 node,
601 )) if node.path == path => address = node.address,
602 _ => {}
603 }
604 }
605 let address = address.ok_or(NapError::ManifestNotFound(path))?;
606 let bytes = self.read_storage_content(address).await?;
607 Ok((bytes, resolved_signature))
608 }
609
610 pub async fn list_paths_at_revision(
612 &self,
613 identifier: proto_gen::lore::model::v1::RevisionIdentifier,
614 prefix: String,
615 ) -> Result<Vec<String>, NapError> {
616 let mut tree = self.make_thin_client();
617 let response = tree
618 .revision_tree(RevisionTreeRequest {
619 query: Some(
620 proto_gen::lore::thin_client::v1::revision_tree_request::Query::Identifier(
621 identifier,
622 ),
623 ),
624 path_prefix: Some(prefix),
625 max_depth: None,
626 })
627 .await;
628 let mut stream = match response {
629 Ok(response) => response.into_inner(),
630 Err(status)
635 if status.code() == tonic::Code::InvalidArgument
636 && status.message().contains("zeroed revision") =>
637 {
638 return Ok(Vec::new());
639 }
640 Err(status) => return Err(map_grpc_status("RevisionTree", status)),
641 };
642 let mut paths = Vec::new();
643 while let Some(item) = stream
644 .message()
645 .await
646 .map_err(|status| map_grpc_status("RevisionTree", status))?
647 {
648 if let Some(proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Node(
649 node,
650 )) = item.payload
651 && node.node_type == proto_gen::lore::thin_client::v1::NodeType::File as i32
652 {
653 paths.push(node.path);
654 }
655 }
656 Ok(paths)
657 }
658
659 pub async fn file_address_at_revision(
661 &self,
662 identifier: proto_gen::lore::model::v1::RevisionIdentifier,
663 path: String,
664 ) -> Result<(proto_gen::lore::model::v1::Address, Vec<u8>), NapError> {
665 let mut tree = self.make_thin_client();
666 let mut stream = tree
667 .revision_tree(Self::revision_tree_for_file(
668 RevisionTreeQuery::Identifier(identifier),
669 &path,
670 ))
671 .await
672 .map_err(|status| map_grpc_status("RevisionTree", status))?
673 .into_inner();
674 let mut signature = Vec::new();
675 let mut address = None;
676 while let Some(item) = stream
677 .message()
678 .await
679 .map_err(|status| map_grpc_status("RevisionTree", status))?
680 {
681 match item.payload {
682 Some(
683 proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Header(
684 header,
685 ),
686 ) => signature = header.signature.to_vec(),
687 Some(proto_gen::lore::thin_client::v1::revision_tree_response::Payload::Node(
688 node,
689 )) if node.path == path => address = node.address,
690 _ => {}
691 }
692 }
693 address
694 .map(|address| (address, signature))
695 .ok_or(NapError::ManifestNotFound(path))
696 }
697
698 pub async fn revision_info_at_signature(
699 &self,
700 signature: Vec<u8>,
701 ) -> Result<proto_gen::lore::thin_client::v1::Revision, NapError> {
702 let mut client = self.make_thin_client();
703 client
704 .revision_info(RevisionInfoRequest {
705 query: Some(
706 proto_gen::lore::thin_client::v1::revision_info_request::Query::Signature(
707 signature.into(),
708 ),
709 ),
710 })
711 .await
712 .map_err(|status| map_grpc_status("RevisionInfo", status))?
713 .into_inner()
714 .revision
715 .ok_or_else(|| NapError::GrpcError("RevisionInfo returned no revision".to_string()))
716 }
717}
718
719fn parent_tree_path(path: &str) -> String {
724 path.rsplit_once('/')
725 .map_or_else(String::new, |(parent, _)| parent.to_string())
726}
727
728const FRAGMENT_PAYLOAD_FRAGMENTED: u32 = 1;
729const FRAGMENT_PAYLOAD_COMPRESSED_LZ4: u32 = 1 << 1;
730const FRAGMENT_PAYLOAD_COMPRESSED_OODLE: u32 = 1 << 2;
731const FRAGMENT_PAYLOAD_COMPRESSED_ZSTD: u32 = 1 << 3;
732const FRAGMENT_PAYLOAD_COMPRESSED: u32 = 0b1111_1110;
733const FRAGMENT_REFERENCE_SIZE: usize = 40;
734
735#[derive(Debug, PartialEq, Eq)]
736struct FragmentReference {
737 hash: Vec<u8>,
738 offset: u64,
739}
740
741fn storage_protocol_error(message: impl Into<String>) -> NapError {
742 NapError::GrpcError(format!(
743 "StorageGet (invalid Lore fragment): {}",
744 message.into()
745 ))
746}
747
748fn decode_fragment_references(payload: &[u8]) -> Result<Vec<FragmentReference>, NapError> {
749 if payload.is_empty() || !payload.len().is_multiple_of(FRAGMENT_REFERENCE_SIZE) {
750 return Err(storage_protocol_error(
751 "fragmented payload is not a list of references",
752 ));
753 }
754 Ok(payload
755 .chunks_exact(FRAGMENT_REFERENCE_SIZE)
756 .map(|chunk| FragmentReference {
757 hash: chunk[..32].to_vec(),
758 offset: u64::from_le_bytes(chunk[32..].try_into().expect("fixed-size offset")),
759 })
760 .collect())
761}
762
763fn decode_fragment_payload(
764 fragment: &proto_gen::lore::model::v1::Fragment,
765 payload: &[u8],
766) -> Result<Vec<u8>, NapError> {
767 let expected_size = usize::try_from(fragment.size_content)
768 .map_err(|_| storage_protocol_error("fragment content size exceeds platform limits"))?;
769 let compression = fragment.flags & FRAGMENT_PAYLOAD_COMPRESSED;
770 if compression == 0 {
771 return Ok(payload.to_vec());
772 }
773 if compression.count_ones() != 1 {
774 return Err(storage_protocol_error(
775 "fragment has incompatible compression flags",
776 ));
777 }
778 if compression == FRAGMENT_PAYLOAD_COMPRESSED_ZSTD {
779 let mut decoded = vec![0; expected_size];
780 let decoded_size = unsafe {
783 zstd_sys::ZSTD_decompress(
784 decoded.as_mut_ptr().cast(),
785 decoded.len(),
786 payload.as_ptr().cast(),
787 payload.len(),
788 )
789 };
790 if unsafe { zstd_sys::ZSTD_isError(decoded_size) } != 0 || decoded_size != expected_size {
792 return Err(storage_protocol_error("Zstd decompression failed"));
793 }
794 return Ok(decoded);
795 }
796 let codec = match compression {
797 FRAGMENT_PAYLOAD_COMPRESSED_LZ4 => "LZ4",
798 FRAGMENT_PAYLOAD_COMPRESSED_OODLE => "Oodle",
799 _ => "an unknown",
800 };
801 Err(storage_protocol_error(format!(
802 "encountered {codec}-compressed content, which this client cannot decode"
803 )))
804}
805
806#[cfg(test)]
807mod tests {
808 use super::{
809 FRAGMENT_PAYLOAD_FRAGMENTED, FragmentReference, LoreGrpcClient, RevisionTreeQuery,
810 decode_fragment_references, parent_tree_path,
811 };
812 use crate::grpc_client::proto_gen::lore::model::v1::RevisionIdentifier;
813
814 #[test]
815 fn revision_tree_for_file_uses_a_parent_directory_and_one_level_walk() {
816 let request = LoreGrpcClient::revision_tree_for_file(
817 RevisionTreeQuery::Identifier(RevisionIdentifier {
818 branch_id: vec![1, 2, 3].into(),
819 number: 0,
820 }),
821 "character/nathan-gunn.yaml",
822 );
823 assert_eq!(request.path_prefix.as_deref(), Some("character"));
824 assert_eq!(request.max_depth, Some(1));
825 assert_eq!(parent_tree_path("character/nathan-gunn.yaml"), "character");
826 assert_eq!(
827 parent_tree_path("assets/portraits/nathan.png"),
828 "assets/portraits"
829 );
830 assert_eq!(parent_tree_path("repository.yaml"), "");
831 }
832
833 #[test]
834 fn fragmented_storage_payload_decodes_lore_reference_layout() {
835 let mut payload = vec![7; 32];
836 payload.extend_from_slice(&512_u64.to_le_bytes());
837 let references = decode_fragment_references(&payload).expect("valid reference payload");
838 assert_eq!(
839 references,
840 vec![FragmentReference {
841 hash: vec![7; 32],
842 offset: 512
843 }]
844 );
845 assert_ne!(FRAGMENT_PAYLOAD_FRAGMENTED, 0);
846 }
847}
848
849#[derive(Default)]
864pub struct Builder {
865 endpoint: Option<String>,
866 token: Option<String>,
867 repository_id_bytes: Vec<u8>,
868 insecure: bool,
869}
870
871impl Builder {
872 pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
876 self.endpoint = Some(endpoint.into());
877 self
878 }
879
880 pub fn token(mut self, token: impl Into<String>) -> Self {
882 self.token = Some(token.into());
883 self
884 }
885
886 pub fn repository_id(mut self, id: impl Into<Vec<u8>>) -> Self {
891 self.repository_id_bytes = id.into();
892 self
893 }
894
895 pub fn insecure(mut self, insecure: bool) -> Self {
900 self.insecure = insecure;
901 self
902 }
903
904 pub fn build(self) -> Result<LoreGrpcClient, NapError> {
909 let endpoint_str = self.endpoint.ok_or_else(|| {
910 NapError::GrpcError(
911 "gRPC endpoint is required — set via .endpoint() or NAP_LORE_GRPC_ENDPOINT"
912 .to_string(),
913 )
914 })?;
915
916 let effective_url = if self.insecure {
921 endpoint_str
922 .strip_prefix("https://")
923 .map(|rest| format!("http://{rest}"))
924 .unwrap_or_else(|| endpoint_str.clone())
925 } else {
926 endpoint_str.clone()
927 };
928
929 let channel = Endpoint::from_shared(effective_url)
930 .map_err(|e| {
931 NapError::GrpcError(format!("invalid gRPC endpoint '{endpoint_str}': {e}"))
932 })?
933 .http2_keep_alive_interval(Duration::from_secs(30))
934 .keep_alive_timeout(Duration::from_secs(20))
935 .user_agent(concat!("nap-core/", env!("CARGO_PKG_VERSION")))
936 .map_err(|e| NapError::GrpcError(format!("user-agent configuration error: {e}")))?
937 .connect_lazy();
938
939 Ok(LoreGrpcClient {
940 channel,
941 token: self.token,
942 repository_id_bytes: self.repository_id_bytes,
943 })
944 }
945
946 pub fn from_env() -> Result<Option<LoreGrpcClient>, NapError> {
951 let endpoint = match std::env::var("NAP_LORE_GRPC_ENDPOINT") {
952 Ok(v) => v,
953 Err(_) => return Ok(None),
954 };
955
956 let token = std::env::var("NAP_LORE_GRPC_TOKEN").ok();
957 let insecure = std::env::var("NAP_LORE_GRPC_INSECURE")
958 .ok()
959 .is_some_and(|v| v == "1" || v == "true" || v == "yes");
960
961 let repository_id_bytes = std::env::var("NAP_LORE_GRPC_RID")
962 .ok()
963 .map(|hex| {
964 hex::decode(&hex).map_err(|e| {
965 NapError::GrpcError(format!("invalid NAP_LORE_GRPC_RID hex '{hex}': {e}"))
966 })
967 })
968 .transpose()?
969 .unwrap_or_default();
970
971 let mut builder = Builder::default().endpoint(endpoint).insecure(insecure);
972
973 if let Some(t) = token {
974 builder = builder.token(t);
975 }
976 if !repository_id_bytes.is_empty() {
977 builder = builder.repository_id(repository_id_bytes);
978 }
979
980 builder.build().map(Some)
981 }
982}
983
984pub fn block_on_grpc<F, T>(f: F) -> Result<T, NapError>
1011where
1012 F: Future<Output = Result<T, NapError>> + Send + 'static,
1013 T: Send + 'static,
1014{
1015 static RUNTIME: LazyLock<tokio::runtime::Runtime> = LazyLock::new(|| {
1016 tokio::runtime::Builder::new_current_thread()
1017 .enable_all()
1018 .build()
1019 .expect("failed to build gRPC tokio runtime")
1020 });
1021
1022 let rt: &'static tokio::runtime::Runtime = &RUNTIME;
1025
1026 thread::Builder::new()
1027 .name("nap-grpc".into())
1028 .spawn(move || rt.block_on(f))
1029 .expect("failed to spawn gRPC worker thread")
1030 .join()
1031 .map_err(|panic_payload| {
1032 NapError::GrpcError(format!("gRPC thread panicked: {panic_payload:?}"))
1033 })?
1034}
1035
1036fn map_grpc_status(context: &str, status: tonic::Status) -> NapError {
1042 let code = status.code();
1043 let message = status.message();
1044 match code {
1045 tonic::Code::NotFound => NapError::RefNotFound(format!("{context}: {message}")),
1046 tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => {
1047 NapError::PermissionDenied(format!("{context}: {message}"))
1048 }
1049 _ => NapError::GrpcError(format!("{context} ({code}): {message}")),
1050 }
1051}