Skip to main content

nap_core/
grpc_client.rs

1//! gRPC client for lore-server's revision service (branch ref sync).
2//!
3//! The lore-server exposes its mutable state — branch pointers, revision
4//! pointers — exclusively over gRPC, whereas content-addressed blob data
5//! is transferred via HTTP / the `lore` CLI.  This module implements the
6//! gRPC half of the push/pull protocol.
7//!
8//! # Architecture
9//!
10//! ```text
11//! LoreBackend::push / pull   (sync, on tokio runtime)
12//!     │
13//!     ▼
14//! block_on_grpc(…)           (spawns dedicated OS thread)
15//!     │
16//!     ▼
17//! LoreGrpcClient             (tonic RevisionServiceClient wrapper)
18//!     │
19//!     ▼
20//! lore-server gRPC endpoint
21//!     ├── RevisionService.BranchGet   → fetch remote tip
22//!     └── RevisionService.BranchPush  → advance remote tip
23//! ```
24//!
25//! # Sync/Async Bridge
26//!
27//! The [`VcsBackend`] trait is synchronous.  gRPC is inherently async.
28//! Rather than changing the trait (which would break every implementation),
29//! we bridge via [`block_on_grpc`]: a dedicated OS thread hosts a shared
30//! single-threaded tokio runtime that executes the async gRPC call.  This
31//! avoids the "Cannot start a runtime from within a runtime" panic that
32//! would occur if we called `Runtime::block_on` directly inside axum
33//! request handlers.
34
35// ---------------------------------------------------------------------------
36// Generated proto modules — must nest exactly as prost expects for
37// cross-package references (lore.revision.v1 → lore.model.v1)
38// ---------------------------------------------------------------------------
39
40/// Generated gRPC service and message types.
41///
42/// Two packages are compiled:
43/// - `lore.model.v1`      — Branch, BranchPoint, etc.
44/// - `lore.revision.v1`   — RevisionService, BranchGetRequest, etc.
45pub 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
76// Re-export the types callers need most frequently.
77pub 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// ===========================================================================
102// Auth interceptor
103// ===========================================================================
104
105/// Injects JWT bearer token and repository-scope metadata into every
106/// outgoing gRPC request.
107///
108/// The token is sent as `Authorization: Bearer <token>` with
109/// `set_sensitive(true)` so proxy logs do not leak it.
110///
111/// The repository ID is sent as binary metadata (keys with `-bin` suffix)
112/// matching the lore-client's `inject_repository()` protocol.
113#[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        // ── Authorization header ──────────────────────────────────────
125        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        // ── Repository-scope binary metadata ──────────────────────────
136        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// ===========================================================================
151// LoreGrpcClient
152// ===========================================================================
153
154/// A gRPC client for lore-server's [`RevisionService`].
155///
156/// This client handles **only** lightweight metadata operations:
157///
158/// | Operation | RPC | Purpose |
159/// |-----------|-----|---------|
160/// | `get_branch_by_name` | `BranchGet` | Fetch remote branch tip before pull |
161/// | `push_branch` | `BranchPush` | Advance remote branch tip after push |
162///
163/// Blob transfer (the heavy payload) remains on the `lore` CLI / HTTP.
164///
165/// [`RevisionService`]: proto_gen::lore::revision::v1::revision_service_client::RevisionServiceClient
166#[derive(Debug, Clone)]
167pub struct LoreGrpcClient {
168    channel: Channel,
169    token: Option<String>,
170    repository_id_bytes: Vec<u8>,
171}
172
173impl LoreGrpcClient {
174    /// Return a builder for fine-grained configuration.
175    pub fn builder() -> Builder {
176        Builder::default()
177    }
178
179    /// Return a clone scoped to a repository returned by `RepositoryGet`.
180    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    // ── Public RPC methods ───────────────────────────────────────────
189
190    /// Look up a branch by its human-readable name.
191    ///
192    /// Returns the [`Branch`] record containing `id` (binary UUID),
193    /// `name`, `latest` (tip signature), and other metadata.
194    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    /// Push a revision as the new tip of a branch.
251    ///
252    /// * `branch_id` — binary branch UUID (obtained from
253    ///   [`get_branch_by_name`]).
254    /// * `revision_signature` — raw content hash of the revision to set as
255    ///   the new tip.
256    /// * `force` — if `true`, bypasses fast-forward checks on the server.
257    ///   When `false`, the server requires the new tip to descend from the
258    ///   current tip (or performs a fast-forward merge).
259    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    // ── Internal helpers ─────────────────────────────────────────────
279
280    /// Convenience constructor that reads all configuration from environment
281    /// variables.  Returns `Ok(None)` when `NAP_LORE_GRPC_ENDPOINT` is not
282    /// set, allowing callers to gracefully skip gRPC integration.
283    ///
284    /// See [`Builder::from_env`] for the list of recognised variables.
285    pub fn builder_from_env() -> Result<Option<Self>, NapError> {
286        Builder::from_env()
287    }
288
289    /// Build a fresh client with the interceptor wired in.
290    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    /// Construct a directory-scoped `RevisionTree` request for one file.
339    ///
340    /// Lore's `RevisionTree` API cannot use a file as `path_prefix`; it walks
341    /// from a directory root. File readers must therefore request the parent
342    /// directory and inspect its direct children for the exact path.
343    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    /// Read a complete Lore storage object, reassembling its fragment tree.
352    ///
353    /// `StorageService.Get` returns the raw payload of an object. Large files
354    /// are represented by a root payload containing 40-byte fragment
355    /// references (32-byte hash plus an LE content offset), so callers must
356    /// recursively fetch those leaves before parsing a manifest.
357    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    /// Look up a repository before constructing a repository-scoped client.
488    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    /// Return names of repositories visible to the current identity.
511    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    /// Read a single file at a revision tree path. The caller must scope this
532    /// client with the repository id returned by `RepositoryGet`.
533    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    /// Read a file selected by its immutable Lore revision signature.
572    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    /// List file paths below a revision tree prefix without downloading them.
611    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            // Lore represents a repository with no committed revisions as a
631            // zero signature. Listing an empty repository is still valid;
632            // surface it as an empty list rather than leaking this server
633            // implementation detail to NAP users.
634            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    /// Look up a file's content address without downloading its payload.
660    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
719/// Return the directory that contains a repository-relative file path.
720///
721/// Lore's `RevisionTree` RPC accepts directory prefixes, including the empty
722/// repository root, but rejects a file path as a prefix.
723fn 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        // The allocated output buffer is exactly `expected_size` bytes and
781        // the input pointer/length come from the gRPC payload slice.
782        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        // `ZSTD_isError` only inspects the return value from Zstd.
791        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// ===========================================================================
850// Builder
851// ===========================================================================
852
853/// Configuration builder for [`LoreGrpcClient`].
854///
855/// # Environment variables
856///
857/// | Variable | Required | Default | Description |
858/// |----------|----------|---------|-------------|
859/// | `NAP_LORE_GRPC_ENDPOINT` | Yes | — | gRPC endpoint URL |
860/// | `NAP_LORE_GRPC_TOKEN` | No | — | JWT bearer token |
861/// | `NAP_LORE_GRPC_RID` | No | — | Repository ID (hex-encoded binary) |
862/// | `NAP_LORE_GRPC_INSECURE` | No | `0` | Skip TLS verification when `1` |
863#[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    /// Set the gRPC endpoint URL.
873    ///
874    /// Format: `https://host:port` (TLS) or `http://host:port` (plain).
875    pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
876        self.endpoint = Some(endpoint.into());
877        self
878    }
879
880    /// Set a JWT bearer token for authenticated requests.
881    pub fn token(mut self, token: impl Into<String>) -> Self {
882        self.token = Some(token.into());
883        self
884    }
885
886    /// Set the repository ID to inject as binary metadata.
887    ///
888    /// This should match the repository / partition UUID the lore-server
889    /// expects.  Pass the raw bytes (not hex-encoded).
890    pub fn repository_id(mut self, id: impl Into<Vec<u8>>) -> Self {
891        self.repository_id_bytes = id.into();
892        self
893    }
894
895    /// When `true`, skip TLS certificate validation.
896    ///
897    /// Use this in development environments where the lore-server uses
898    /// self-signed certificates.
899    pub fn insecure(mut self, insecure: bool) -> Self {
900        self.insecure = insecure;
901        self
902    }
903
904    /// Build the [`LoreGrpcClient`].
905    ///
906    /// Connection is deferred via [`Endpoint::connect_lazy`]; the
907    /// first RPC will establish the TCP + TLS handshake.
908    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        // In insecure mode, downgrade https:// → http:// to skip TLS
917        // verification entirely (self-signed certs in development).
918        // In secure mode, Endpoint::from_shared auto-configures TLS with
919        // native roots for https:// URLs — no explicit tls_config needed.
920        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    /// Build from environment variables.
947    ///
948    /// Returns `Ok(None)` when `NAP_LORE_GRPC_ENDPOINT` is not set
949    /// (allowing the caller to skip gRPC integration gracefully).
950    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
984// ===========================================================================
985// Sync→async bridge
986// ===========================================================================
987
988/// Execute an async gRPC operation from a synchronous context.
989///
990/// # Why a dedicated thread?
991///
992/// The [`VcsBackend`] trait methods (`push`, `pull`) are synchronous.
993/// gRPC client calls are async.  If we called `Runtime::block_on` directly
994/// from within an axum HTTP handler (which already runs on a tokio runtime),
995/// tokio would panic with "Cannot start a runtime from within a runtime".
996///
997/// This function spawns a **dedicated OS thread** that hosts the future
998/// on a shared single-threaded tokio runtime.  The runtime is created once
999/// and reused across all gRPC calls, preserving HTTP/2 keepalive state and
1000/// TLS session tickets.
1001///
1002/// # Type bounds
1003///
1004/// * `F` must be `Send + 'static` because it crosses a thread boundary.
1005/// * `T` must be `Send + 'static` for the same reason.
1006/// * The closure return type is `Result<T, NapError>` so that error
1007///   propagation through the thread join is straightforward.
1008///
1009/// [`VcsBackend`]: crate::vcs::VcsBackend
1010pub 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    // `&'static Runtime` is both `Send` and `Sync` because the static
1023    // reference lives forever.  It is safe to pass to a spawned thread.
1024    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
1036// ===========================================================================
1037// Error mapping
1038// ===========================================================================
1039
1040/// Map a [`tonic::Status`] to a structured [`NapError`].
1041fn 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}