Skip to main content

nap_core/
resolver.rs

1//! NAP Resolver — URI → Manifest, with query and version selectors.
2//!
3//! The resolver is the primary interface for reading NAP resources.
4//! It handles:
5//! - Full manifest resolution: `nap://toystory/character/woody`
6//! - Fragment queries: `nap://toystory/character/woody#references.appears_in`
7//! - Version selectors: branch, commit
8//! - Subtree extraction for efficient AI/application access
9//!
10//! Version and branch are NEVER in the URI. They are orthogonal selectors:
11//! ```text
12//! URI + Reference + Revision Selector
13//! ```
14
15use std::collections::BTreeMap;
16use std::fmt;
17use std::path::{Component, Path, PathBuf};
18use std::sync::OnceLock;
19use std::time::Duration;
20
21use reqwest::header::AUTHORIZATION;
22use serde::{Deserialize, Serialize};
23use tracing::{debug, info};
24
25use crate::error::NapError;
26use crate::grpc_client::{LoreGrpcClient, block_on_grpc};
27use crate::manifest::Manifest;
28use crate::query::ManifestQuery;
29use crate::repository::Repository;
30use crate::uri::NapUri;
31use crate::vcs::VcsBackend;
32use crate::vcs_lore::LoreBackend;
33
34/// Where repository-backed reads are performed.
35#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ResolveSource {
38    /// Use the configured Lore server, falling back to local Lore when no
39    /// provider configuration exists.
40    #[default]
41    Auto,
42    /// Require the configured (or default local) Lore server.
43    Remote,
44    /// Read a checked-out NAP working tree under `base_path`.
45    Local,
46}
47
48/// Resolver configuration — set at construction time.
49///
50/// Controls how the resolver resolves URIs when no explicit branch or
51/// commit is provided by the caller.
52#[derive(Debug, Clone, Default)]
53pub struct ResolveConfig {
54    /// Branch to resolve when neither `branch` nor `commit` is specified
55    /// in [`ResolveOptions`].  If `None`, resolves without a branch or
56    /// commit — this will trigger a [`NapError::NoDefaultBranch`] error
57    /// for any resolve call that omits both `branch` and `commit`.
58    pub default_branch: Option<String>,
59}
60
61/// Options for resolving a NAP URI. All are optional — omitting all
62/// causes the resolver to use its [`ResolveConfig::default_branch`] (if
63/// configured) or fail with [`NapError::NoDefaultBranch`].
64#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct ResolveOptions {
66    /// Select server-backed or explicit working-tree resolution.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub source: Option<ResolveSource>,
69    /// Resolve at a specific branch. e.g., `"canon"`.
70    /// Takes precedence over [`ResolveConfig::default_branch`].
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub branch: Option<String>,
73
74    /// Resolve at a specific commit hash (BLAKE3). e.g.,
75    /// `"af1349b9f5f9a1a6a0404deb36d020949b834f2a42e37e5f8d2e4ba2765f1a2f"`.
76    /// Takes precedence over `branch` and [`ResolveConfig::default_branch`].
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub commit: Option<String>,
79
80    /// Subtree query path (overrides URI fragment). e.g., `"appearances.audienceVotes"`.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub path: Option<String>,
83
84    /// Recursively resolve nested URIs. When true, the resolver will follow
85    /// all nap:// URIs found in the resolved manifest and resolve them as well.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub recursive: Option<bool>,
88
89    /// Maximum recursion depth for recursive resolution. Defaults to 10 to prevent
90    /// infinite loops. Set to None for unlimited depth (not recommended).
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub max_depth: Option<usize>,
93
94    /// Include per-file provenance metadata for the manifest and direct representations.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub provenance: Option<bool>,
97
98    /// Hydrate known readable provenance artifacts such as prompts and run records.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub include_blobs: Option<bool>,
101}
102
103/// Options for creating a bearer URL for a committed representation.
104#[derive(Clone, Default)]
105pub struct PresignOptions {
106    pub branch: Option<String>,
107    pub commit: Option<String>,
108    pub ttl_seconds: Option<u64>,
109    pub lore_http_url: Option<String>,
110    pub bearer_token: Option<String>,
111}
112
113impl fmt::Debug for PresignOptions {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("PresignOptions")
116            .field("branch", &self.branch)
117            .field("commit", &self.commit)
118            .field("ttl_seconds", &self.ttl_seconds)
119            .field("lore_http_url", &self.lore_http_url)
120            .field(
121                "bearer_token",
122                &self.bearer_token.as_ref().map(|_| "<redacted>"),
123            )
124            .finish()
125    }
126}
127
128/// A time-limited public URL for one immutable representation.
129#[derive(Clone, Serialize, Deserialize)]
130pub struct PresignedRepresentation {
131    pub url: String,
132    pub expires_at: u64,
133    pub revision: String,
134    pub repository_id: String,
135    pub address: String,
136    pub representation: String,
137    pub format: String,
138}
139
140impl fmt::Debug for PresignedRepresentation {
141    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142        f.debug_struct("PresignedRepresentation")
143            .field("url", &"<redacted>")
144            .field("expires_at", &self.expires_at)
145            .field("revision", &self.revision)
146            .field("repository_id", &self.repository_id)
147            .field("address", &self.address)
148            .field("representation", &self.representation)
149            .field("format", &self.format)
150            .finish()
151    }
152}
153
154#[derive(Serialize)]
155struct LorePresignRequest {
156    #[serde(skip_serializing_if = "Option::is_none")]
157    ttl_seconds: Option<u64>,
158}
159
160#[derive(Deserialize)]
161struct LorePresignResponse {
162    url_suffix: String,
163    expires_at: u64,
164}
165
166fn presign_http_client() -> Result<&'static reqwest::Client, NapError> {
167    static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
168    CLIENT
169        .get_or_init(|| {
170            reqwest::Client::builder()
171                .connect_timeout(Duration::from_secs(5))
172                .timeout(Duration::from_secs(30))
173                .redirect(reqwest::redirect::Policy::none())
174                .build()
175                .map_err(|e| e.to_string())
176        })
177        .as_ref()
178        .map_err(|e| NapError::Other(format!("failed to initialize presign HTTP client: {e}")))
179}
180
181async fn read_bounded_response(
182    mut response: reqwest::Response,
183    limit: usize,
184) -> Result<(reqwest::StatusCode, Vec<u8>), NapError> {
185    let status = response.status();
186    if response
187        .content_length()
188        .is_some_and(|length| length > limit as u64)
189    {
190        return Err(NapError::Other(format!(
191            "Lore presign response exceeded {limit} bytes"
192        )));
193    }
194    let mut body = Vec::new();
195    while let Some(chunk) = response
196        .chunk()
197        .await
198        .map_err(|e| NapError::Other(format!("failed to read Lore presign response: {e}")))?
199    {
200        if body.len().saturating_add(chunk.len()) > limit {
201            return Err(NapError::Other(format!(
202                "Lore presign response exceeded {limit} bytes"
203            )));
204        }
205        body.extend_from_slice(&chunk);
206    }
207    Ok((status, body))
208}
209
210fn validate_presigned_url(
211    base_url: &reqwest::Url,
212    suffix: &str,
213    expected_path: &str,
214) -> Result<reqwest::Url, NapError> {
215    if suffix.starts_with("//") || !suffix.starts_with('/') {
216        return Err(NapError::Other(
217            "Lore returned an unexpected presigned URL path".to_string(),
218        ));
219    }
220    let url = base_url
221        .join(suffix)
222        .map_err(|e| NapError::Other(format!("invalid Lore presigned URL: {e}")))?;
223    let query: Vec<_> = url.query_pairs().collect();
224    if url.origin() != base_url.origin()
225        || url.path() != expected_path
226        || query.len() != 1
227        || query[0].0 != "token"
228        || query[0].1.is_empty()
229    {
230        return Err(NapError::Other(
231            "Lore returned a cross-origin or malformed presigned URL".to_string(),
232        ));
233    }
234    Ok(url)
235}
236
237fn format_lore_repository_id(bytes: &[u8]) -> String {
238    if bytes.len() == 16 {
239        let value = hex::encode(bytes);
240        format!(
241            "{}-{}-{}-{}-{}",
242            &value[..8],
243            &value[8..12],
244            &value[12..16],
245            &value[16..20],
246            &value[20..]
247        )
248    } else {
249        hex::encode(bytes)
250    }
251}
252
253impl ResolveOptions {
254    /// Returns the query path (from options or URI fragment).
255    fn query_path(&self, uri: &NapUri) -> Option<String> {
256        self.path.clone().or_else(|| uri.fragment.clone())
257    }
258}
259
260/// The result of resolving a NAP URI.
261#[derive(Debug, Clone, Serialize, Deserialize)]
262#[serde(untagged)]
263pub enum ResolveResult {
264    /// Full manifest (no query applied).
265    Full(Box<Manifest>),
266    /// Full manifest with Lore-backed per-file provenance envelope.
267    Provenance(Box<ResolveEnvelope>),
268    /// Subtree result from a query.
269    Subtree(serde_json::Value),
270}
271
272/// Envelope returned when `ResolveOptions::provenance` is enabled.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct ResolveEnvelope {
275    pub manifest: Box<Manifest>,
276    pub provenance: ResolveProvenanceEnvelope,
277}
278
279/// Per-resolution provenance metadata.
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ResolveProvenanceEnvelope {
282    pub revision: String,
283    pub files: Vec<ResolveProvenanceFile>,
284}
285
286/// Provenance for one file participating in an entity resolution.
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct ResolveProvenanceFile {
289    pub role: String,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub name: Option<String>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub path: Option<String>,
294    #[serde(default, skip_serializing_if = "Option::is_none")]
295    pub uri: Option<String>,
296    #[serde(default, skip_serializing_if = "Option::is_none")]
297    pub hash: Option<String>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub format: Option<String>,
300    pub provenance: serde_json::Value,
301    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
302    pub blobs: BTreeMap<String, HydratedProvenanceBlob>,
303}
304
305/// Hydrated readable provenance artifact.
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct HydratedProvenanceBlob {
308    pub address: String,
309    pub content: String,
310    pub truncated: bool,
311    pub original_bytes: usize,
312    pub included_bytes: usize,
313}
314
315const MAX_CONDENSED_METADATA_VALUE_BYTES: usize = 256;
316const MAX_HYDRATED_BLOB_BYTES: usize = 12_000;
317
318/// The NAP resolver — resolves URIs to manifests or subtrees.
319pub struct Resolver {
320    /// Base directory containing repository repositories.
321    base_path: PathBuf,
322    /// VCS backend factory (creates backend per-repo).
323    vcs_factory: fn() -> Box<dyn VcsBackend>,
324    /// Whether a version-control backend is configured. When `false`,
325    /// repositories are opened in unversioned mode and resolution reads the
326    /// current filesystem state (no branch/commit selectors available).
327    use_vcs: bool,
328    /// Resolution configuration (default branch, etc.).
329    config: ResolveConfig,
330    source: ResolveSource,
331}
332
333impl Resolver {
334    /// Create a resolver that looks for repository repos under `base_path`.
335    ///
336    /// Uses [`LoreBackend::from_env()`] by default **when a version-control
337    /// backend is configured** for `base_path` (i.e. a valid `provider.toml`
338    /// exists). Otherwise repositories are opened in unversioned mode. For
339    /// testing, use [`Resolver::with_vcs_factory()`] with a mock backend.
340    ///
341    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
342    /// `None`. In versioned mode any resolve that omits both `branch` and
343    /// `commit` will fail with [`NapError::NoDefaultBranch`]; in unversioned
344    /// mode such a resolve reads the current filesystem state.
345    ///
346    /// # Example layout
347    /// ```text
348    /// base_path/
349    /// ├── toystory/    ← repository repo
350    /// ├── toystory/    ← repository repo
351    /// └── marvel/      ← repository repo
352    /// ```
353    pub fn new(base_path: &Path) -> Self {
354        let source = match std::env::var("NAP_RESOLVE_SOURCE").ok().as_deref() {
355            Some("local") => ResolveSource::Local,
356            Some("remote") => ResolveSource::Remote,
357            _ => ResolveSource::Auto,
358        };
359        Self {
360            base_path: base_path.to_path_buf(),
361            vcs_factory: || Box::new(LoreBackend::from_env()),
362            // Even without provider.toml, NAP's established local Lore server
363            // is the default server for explicit working-tree reads.
364            use_vcs: true,
365            config: ResolveConfig::default(),
366            source,
367        }
368    }
369
370    /// Create a resolver with a custom VCS backend factory and config.
371    ///
372    /// Repositories are always opened in versioned mode.
373    pub fn with_vcs_factory(
374        base_path: &Path,
375        factory: fn() -> Box<dyn VcsBackend>,
376        config: ResolveConfig,
377    ) -> Self {
378        Self {
379            base_path: base_path.to_path_buf(),
380            vcs_factory: factory,
381            use_vcs: true,
382            config,
383            source: ResolveSource::Local,
384        }
385    }
386
387    /// Override the resolver's default source. Primarily useful to callers
388    /// that need an explicit local working-tree read.
389    pub fn with_source(mut self, source: ResolveSource) -> Self {
390        self.source = source;
391        self
392    }
393
394    fn effective_source(&self, options: &ResolveOptions) -> ResolveSource {
395        options.source.unwrap_or(self.source)
396    }
397
398    fn remote_client(&self) -> Result<LoreGrpcClient, NapError> {
399        let server = LoreBackend::configured_server_url(&self.base_path);
400        let endpoint = server
401            .strip_prefix("lore://")
402            .map(|rest| format!("http://{rest}"))
403            .or_else(|| {
404                server
405                    .strip_prefix("grpc://")
406                    .map(|rest| format!("http://{rest}"))
407            })
408            .or_else(|| {
409                server
410                    .strip_prefix("lores://")
411                    .map(|rest| format!("https://{rest}"))
412            })
413            .or_else(|| {
414                server
415                    .strip_prefix("grpcs://")
416                    .map(|rest| format!("https://{rest}"))
417            })
418            .unwrap_or(server);
419        let token = std::env::var("NAP_LORE_GRPC_TOKEN")
420            .ok()
421            .or_else(|| std::env::var("NAP_REMOTE_AUTH_TOKEN").ok());
422
423        // `Endpoint::connect_lazy()` creates Tokio I/O state. Construct it
424        // inside the bridge rather than on the synchronous CLI thread; doing
425        // so outside a Tokio reactor panics in hyper-util before the first
426        // RPC (for example, `nap list <repository>`).
427        block_on_grpc(async move {
428            let mut builder = LoreGrpcClient::builder().endpoint(endpoint);
429            if let Some(token) = token {
430                builder = builder.token(token);
431            }
432            builder.build()
433        })
434    }
435
436    fn remote_manifest(
437        &self,
438        uri: &NapUri,
439        options: &ResolveOptions,
440    ) -> Result<Manifest, NapError> {
441        if options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false) {
442            return Err(NapError::Other(
443                "remote provenance is not supported yet".to_string(),
444            ));
445        }
446        let client = self.remote_client()?;
447        let repository = uri.repository.clone();
448        let path = uri.manifest_path();
449        let branch = options.branch.clone();
450        let commit = options.commit.clone();
451        let bytes = block_on_grpc(async move {
452            let repo = client.get_repository_by_name(&repository).await?;
453            let scoped = client.for_repository_id(repo.id.clone());
454            if let Some(commit) = commit {
455                let signature = hex::decode(commit.trim_start_matches("blake3:")).map_err(|e| {
456                    NapError::InvalidUri {
457                        uri: commit,
458                        reason: format!("invalid revision signature: {e}"),
459                    }
460                })?;
461                scoped
462                    .read_file_at_signature(signature, path)
463                    .await
464                    .map(|(bytes, _)| bytes)
465            } else {
466                let branch_id = match branch {
467                    Some(name) => scoped.get_branch_by_name(&name).await?.id.to_vec(),
468                    None => repo.default_branch_id.to_vec(),
469                };
470                scoped
471                    .read_file_at_revision(
472                        crate::grpc_client::proto_gen::lore::model::v1::RevisionIdentifier {
473                            branch_id: branch_id.into(),
474                            number: 0,
475                        },
476                        path,
477                    )
478                    .await
479                    .map(|(bytes, _)| bytes)
480            }
481        })?;
482        serde_yaml::from_slice(&bytes).map_err(|source| NapError::ManifestParseError {
483            path: uri.manifest_path(),
484            source,
485        })
486    }
487
488    fn remote_representation_address(
489        &self,
490        uri: &NapUri,
491        options: &PresignOptions,
492        path: String,
493    ) -> Result<
494        (
495            Vec<u8>,
496            crate::grpc_client::proto_gen::lore::model::v1::Address,
497            String,
498        ),
499        NapError,
500    > {
501        let client = self.remote_client()?;
502        let repository_name = uri.repository.clone();
503        let branch = options.branch.clone();
504        let commit = options.commit.clone();
505        block_on_grpc(async move {
506            let repo = client.get_repository_by_name(&repository_name).await?;
507            let scoped = client.for_repository_id(repo.id.clone());
508            let identifier = if let Some(commit) = commit {
509                let signature = hex::decode(commit.trim_start_matches("blake3:"))
510                    .map_err(|e| NapError::Other(format!("invalid revision signature: {e}")))?;
511                scoped
512                    .revision_info_at_signature(signature)
513                    .await?
514                    .identifier
515                    .ok_or_else(|| {
516                        NapError::GrpcError("RevisionInfo returned no identifier".to_string())
517                    })?
518            } else {
519                let branch_id = match branch {
520                    Some(name) => scoped.get_branch_by_name(&name).await?.id,
521                    None => repo.default_branch_id.clone(),
522                };
523                crate::grpc_client::proto_gen::lore::model::v1::RevisionIdentifier {
524                    branch_id,
525                    number: 0,
526                }
527            };
528            let (address, signature) = scoped.file_address_at_revision(identifier, path).await?;
529            Ok((repo.id.to_vec(), address, hex::encode(signature)))
530        })
531    }
532
533    async fn presign_remote_representation(
534        &self,
535        uri: &NapUri,
536        representation_name: &str,
537        options: &PresignOptions,
538    ) -> Result<PresignedRepresentation, NapError> {
539        let manifest = self.remote_manifest(
540            uri,
541            &ResolveOptions {
542                branch: options.branch.clone(),
543                commit: options.commit.clone(),
544                source: Some(ResolveSource::Remote),
545                ..Default::default()
546            },
547        )?;
548        let representation = manifest
549            .representations
550            .get(representation_name)
551            .ok_or_else(|| {
552                NapError::Other(format!(
553                    "representation '{representation_name}' does not exist on {}",
554                    manifest.id
555                ))
556            })?;
557        let representation_uri = representation.uri.as_deref().ok_or_else(|| {
558            NapError::Other(format!(
559                "representation '{representation_name}' has no repository-relative URI"
560            ))
561        })?;
562        let file_path = Self::resolve_representation_path(uri, representation_uri)?
563            .ok_or_else(|| NapError::Other(format!("representation '{representation_name}' is external; only direct repository files can be presigned")))?;
564        let (repository_id_bytes, address, revision) =
565            self.remote_representation_address(uri, options, file_path)?;
566        // `Representation::hash` is BLAKE3 over the reassembled file bytes,
567        // whereas a Lore address hashes its storage fragment tree for large
568        // files. They are separate identifiers and must not be compared.
569        let hash = hex::encode(&address.hash);
570        let repository_id = format_lore_repository_id(&repository_id_bytes);
571        let address_string = format!("{}-{}", hash, hex::encode(&address.context));
572        let http_url = options
573            .lore_http_url
574            .clone()
575            .or_else(|| std::env::var("NAP_LORE_HTTP_URL").ok())
576            .unwrap_or(
577                crate::provider::http::configured_origin(
578                    &self.base_path,
579                    &LoreBackend::configured_server_url(&self.base_path),
580                )
581                .map_err(|e| NapError::Other(e.to_string()))?,
582            );
583        let base_url = crate::provider::http::validate_origin(&http_url)
584            .map_err(|e| NapError::Other(e.to_string()))?;
585        let endpoint = base_url
586            .join(&format!(
587                "/v1/repository/{repository_id}/content/{address_string}/presign"
588            ))
589            .map_err(|e| NapError::Other(format!("failed to construct Lore presign URL: {e}")))?;
590        let token = options
591            .bearer_token
592            .clone()
593            .or_else(|| std::env::var("NAP_LORE_HTTP_TOKEN").ok())
594            .or_else(|| std::env::var("NAP_LORE_GRPC_TOKEN").ok());
595        let mut request = presign_http_client()?
596            .post(endpoint)
597            .json(&LorePresignRequest {
598                ttl_seconds: options.ttl_seconds,
599            });
600        if let Some(token) = token.filter(|token| !token.is_empty()) {
601            request = request.bearer_auth(token);
602        }
603        let (status, body) = read_bounded_response(
604            request
605                .send()
606                .await
607                .map_err(|e| NapError::Other(format!("Lore presign request failed: {e}")))?,
608            64 * 1024,
609        )
610        .await?;
611        if !status.is_success() {
612            return Err(NapError::Other(format!(
613                "Lore presign failed with HTTP {status}: {}",
614                String::from_utf8_lossy(&body).trim()
615            )));
616        }
617        let response: LorePresignResponse = serde_json::from_slice(&body)
618            .map_err(|e| NapError::Other(format!("invalid Lore presign response: {e}")))?;
619        let expected_path = format!("/v1/presigned/{repository_id}/{address_string}");
620        let url = validate_presigned_url(&base_url, &response.url_suffix, &expected_path)?;
621        Ok(PresignedRepresentation {
622            url: url.to_string(),
623            expires_at: response.expires_at,
624            revision,
625            repository_id,
626            address: address_string,
627            representation: representation_name.to_string(),
628            format: representation.format.clone(),
629        })
630    }
631
632    /// Open the repository for a given repository and read its resolve config.
633    fn open_repo(&self, repository: &str) -> Result<(Repository, ResolveConfig), NapError> {
634        let repo_path = self.base_path.join(repository);
635        let vcs = if self.use_vcs {
636            Some((self.vcs_factory)())
637        } else {
638            None
639        };
640        let repo = Repository::open_optional(&repo_path, vcs)?;
641        let repo_config = repo.read_resolve_config();
642        Ok((repo, repo_config))
643    }
644
645    /// Resolve a NAP URI string with options.
646    ///
647    /// # Examples
648    /// ```text
649    /// // Full manifest
650    /// resolver.resolve("nap://toystory/character/woody", &Default::default())
651    ///
652    /// // Without scheme (auto-normalized)
653    /// resolver.resolve("toystory/character/woody", &Default::default())
654    ///
655    /// // With branch
656    /// resolver.resolve("nap://toystory/character/woody", &ResolveOptions {
657    ///     branch: Some("canon".to_string()),
658    ///     ..Default::default()
659    /// })
660    ///
661    /// // With fragment query (via URI)
662    /// resolver.resolve("nap://toystory/character/woody#references.appears_in", &Default::default())
663    /// ```
664    pub fn resolve(
665        &self,
666        uri_str: &str,
667        options: &ResolveOptions,
668    ) -> Result<ResolveResult, NapError> {
669        // ── Normalization: Prepend nap:// if missing ─────────────────────
670        let normalized_uri_str = if uri_str.starts_with("nap://") {
671            uri_str.to_string()
672        } else {
673            format!("nap://{}", uri_str.trim_start_matches('/'))
674        };
675
676        debug!(
677            original_uri = %uri_str,
678            normalized_uri = %normalized_uri_str,
679            "normalized NAP URI"
680        );
681
682        let uri: NapUri = normalized_uri_str.parse()?;
683        self.resolve_uri(&uri, options)
684    }
685
686    /// Create a time-limited public URL for a direct, committed representation.
687    ///
688    /// `uri_str` identifies the entity (for example,
689    /// `25th-chapter/character/nathan-gunn`, with an optional `nap://` prefix).
690    /// `representation_name` is its manifest representation key, such as `item`.
691    /// The representation URI is relative to the entity's asset directory.
692    ///
693    /// The returned URL is a bearer capability. Callers must not log it or
694    /// persist it beyond `expires_at`.
695    pub async fn presign_representation(
696        &self,
697        uri_str: &str,
698        representation_name: &str,
699        options: &PresignOptions,
700    ) -> Result<PresignedRepresentation, NapError> {
701        if options.branch.is_some() && options.commit.is_some() {
702            return Err(NapError::Other(
703                "presign accepts either branch or commit, not both".to_string(),
704            ));
705        }
706
707        let normalized = if uri_str.starts_with("nap://") {
708            uri_str.to_string()
709        } else {
710            format!("nap://{}", uri_str.trim_start_matches('/'))
711        };
712        let uri: NapUri = normalized.parse()?;
713        if uri.fragment.is_some() {
714            return Err(NapError::InvalidUri {
715                uri: uri_str.to_string(),
716                reason: "fragments are not supported when presigning a representation".to_string(),
717            });
718        }
719
720        if self.source != ResolveSource::Local {
721            return self
722                .presign_remote_representation(&uri, representation_name, options)
723                .await;
724        }
725
726        let (repo, repo_config) = self.open_repo(&uri.repository)?;
727        let vcs = repo.vcs().ok_or_else(|| NapError::BackendNotConfigured {
728            operation: "presign a representation".to_string(),
729        })?;
730        let revision = match (&options.commit, &options.branch) {
731            (Some(commit), None) => commit.clone(),
732            (None, Some(branch)) => vcs.resolve_branch_head(&repo.root, branch)?,
733            (None, None) => {
734                let branch = repo_config
735                    .default_branch
736                    .as_ref()
737                    .or(self.config.default_branch.as_ref())
738                    .ok_or(NapError::NoDefaultBranch)?;
739                vcs.resolve_branch_head(&repo.root, branch)?
740            }
741            (Some(_), Some(_)) => unreachable!("validated above"),
742        };
743
744        let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
745        let representation = manifest
746            .representations
747            .get(representation_name)
748            .ok_or_else(|| {
749                NapError::Other(format!(
750                    "representation '{representation_name}' does not exist on {}",
751                    manifest.id
752                ))
753            })?;
754        let representation_uri = representation.uri.as_deref().ok_or_else(|| {
755            NapError::Other(format!(
756                "representation '{representation_name}' has no repository-relative URI"
757            ))
758        })?;
759        let file_path = Self::resolve_representation_path(&uri, representation_uri)?
760            .ok_or_else(|| {
761                NapError::Other(format!(
762                    "representation '{representation_name}' is external; only direct repository files can be presigned"
763                ))
764            })?;
765
766        let repository = vcs.repository_descriptor(&repo.root)?;
767        let content = vcs.file_content_address_at_ref(&repo.root, &file_path, &revision)?;
768        // `Representation::hash` is BLAKE3 over the reassembled file bytes,
769        // whereas a Lore address hashes its storage fragment tree for large
770        // files. They are separate identifiers and must not be compared.
771        let address = content.as_lore_address();
772
773        let configured_http_url = options
774            .lore_http_url
775            .clone()
776            .or_else(|| std::env::var("NAP_LORE_HTTP_URL").ok());
777        let http_url = match configured_http_url {
778            Some(url) => url,
779            None => {
780                crate::provider::http::configured_origin(&self.base_path, &repository.remote_url)
781                    .map_err(|e| NapError::Other(e.to_string()))?
782            }
783        };
784        let base_url = crate::provider::http::validate_origin(&http_url)
785            .map_err(|e| NapError::Other(e.to_string()))?;
786
787        let endpoint_path = format!(
788            "/v1/repository/{}/content/{}/presign",
789            repository.id, address
790        );
791        let endpoint = base_url
792            .join(&endpoint_path)
793            .map_err(|e| NapError::Other(format!("failed to construct Lore presign URL: {e}")))?;
794        let bearer_token = options
795            .bearer_token
796            .clone()
797            .or_else(|| std::env::var("NAP_LORE_HTTP_TOKEN").ok())
798            .or_else(|| std::env::var("NAP_LORE_GRPC_TOKEN").ok());
799        let mut request = presign_http_client()?
800            .post(endpoint)
801            .json(&LorePresignRequest {
802                ttl_seconds: options.ttl_seconds,
803            });
804        let explicit_token = bearer_token.as_ref().is_some_and(|token| !token.is_empty());
805        if let Some(token) = bearer_token.filter(|token| !token.is_empty()) {
806            let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
807                .map_err(|_| {
808                    NapError::Other("bearer token is not a valid HTTP header value".to_string())
809                })?;
810            value.set_sensitive(true);
811            request = request.header(AUTHORIZATION, value);
812        }
813
814        let retry = request.try_clone();
815        let mut response = request
816            .send()
817            .await
818            .map_err(|e| NapError::Other(format!("Lore presign request failed: {e}")))?;
819        if response.status() == reqwest::StatusCode::UNAUTHORIZED && !explicit_token {
820            let loopback = base_url.host_str().is_some_and(|host| {
821                host == "localhost"
822                    || host
823                        .trim_matches(['[', ']'])
824                        .parse::<std::net::IpAddr>()
825                        .is_ok_and(|ip| ip.is_loopback())
826            });
827            if base_url.scheme() != "https" && !loopback {
828                return Err(NapError::Other(
829                    "automatic Lore authentication requires HTTPS for remote HTTP endpoints".into(),
830                ));
831            }
832            if let Some(token) = vcs.http_bearer_token(&repo.root, &repository.id, &http_url)? {
833                let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
834                    .map_err(|_| NapError::Other("invalid Lore bearer token".into()))?;
835                value.set_sensitive(true);
836                response = retry
837                    .ok_or_else(|| NapError::Other("cannot retry Lore presign request".into()))?
838                    .header(AUTHORIZATION, value)
839                    .send()
840                    .await
841                    .map_err(|e| NapError::Other(format!("Lore presign request failed: {e}")))?;
842            }
843        }
844        let (status, body) = read_bounded_response(response, 64 * 1024).await?;
845        if !status.is_success() {
846            let detail = String::from_utf8_lossy(&body);
847            let message = match status {
848                reqwest::StatusCode::UNAUTHORIZED => {
849                    "Lore requires an authenticated repository identity; run nap auth login and retry".to_string()
850                }
851                reqwest::StatusCode::FORBIDDEN => {
852                    "Lore denied permission to presign this repository representation".to_string()
853                }
854                reqwest::StatusCode::NOT_FOUND if detail.contains("not enabled") => {
855                    "Lore presigned URLs are disabled; configure server.http.presigned_url_hmac_key and restart Lore".to_string()
856                }
857                reqwest::StatusCode::NOT_FOUND => {
858                    "representation content is not available in the Lore remote; push the pinned revision before presigning".to_string()
859                }
860                _ => format!("Lore presign failed with HTTP {status}: {}", detail.trim()),
861            };
862            return Err(NapError::Other(message));
863        }
864        let response: LorePresignResponse = serde_json::from_slice(&body)
865            .map_err(|e| NapError::Other(format!("invalid Lore presign response: {e}")))?;
866        let expected_redeem_path = format!("/v1/presigned/{}/{}", repository.id, address);
867        let url = validate_presigned_url(&base_url, &response.url_suffix, &expected_redeem_path)?;
868
869        info!(
870            repository_id = %repository.id,
871            revision = %revision,
872            representation = %representation_name,
873            expires_at = response.expires_at,
874            "created Lore presigned representation URL"
875        );
876        Ok(PresignedRepresentation {
877            url: url.to_string(),
878            expires_at: response.expires_at,
879            revision,
880            repository_id: repository.id,
881            address,
882            representation: representation_name.to_string(),
883            format: representation.format.clone(),
884        })
885    }
886
887    /// Resolve a parsed NAP URI with options.
888    pub fn resolve_uri(
889        &self,
890        uri: &NapUri,
891        options: &ResolveOptions,
892    ) -> Result<ResolveResult, NapError> {
893        debug!(
894            uri = %uri,
895            options = ?options,
896            "resolving NAP URI"
897        );
898
899        let wants_provenance =
900            options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
901
902        // Handle recursive resolution. Provenance is intentionally scoped to the
903        // requested manifest and its direct representations, not related entities.
904        if options.recursive.unwrap_or(false) && !wants_provenance {
905            return self.resolve_uri_recursive(
906                uri,
907                options,
908                0,
909                &mut std::collections::HashSet::new(),
910            );
911        }
912
913        self.resolve_uri_single(uri, options)
914    }
915
916    /// Resolve a single URI without recursion.
917    fn resolve_uri_single(
918        &self,
919        uri: &NapUri,
920        options: &ResolveOptions,
921    ) -> Result<ResolveResult, NapError> {
922        if self.effective_source(options) != ResolveSource::Local {
923            let manifest = self.remote_manifest(uri, options)?;
924            return match options.query_path(uri) {
925                Some(path) => {
926                    let value = ManifestQuery::query(&manifest.to_value()?, &path, &manifest.id)?;
927                    Ok(ResolveResult::Subtree(
928                        serde_json::to_value(value).map_err(|e| NapError::Other(e.to_string()))?,
929                    ))
930                }
931                None => Ok(ResolveResult::Full(Box::new(manifest))),
932            };
933        }
934        let (repo, repo_config) = self.open_repo(&uri.repository)?;
935        let query_path = options.query_path(uri);
936
937        // ── 4-Rule Resolution ────────────────────────────────────────
938        // Rule 1: commit provided → use directly (bypass branch logic)
939        // Rule 2: branch provided, no commit → resolve branch head
940        // Rule 3: both null → use default_branch from repo config (fallback to global)
941        // Rule 4: both null and no default_branch → hard error (versioned only)
942        // In unversioned mode (no backend), resolving without a revision reads
943        // the current filesystem state; branch/commit selectors are
944        // unsatisfiable and produce a ResolutionFailed error.
945        // ──────────────────────────────────────────────────────────────
946
947        let unsatisfiable = |what: &str| NapError::ResolutionFailed {
948            address: uri.to_string(),
949            message: format!(
950                "cannot resolve {what}: no version-control backend is configured. \
951                     Configure one with 'nap backend configure' to use branch/commit selectors."
952            ),
953        };
954
955        let revision: Option<String> = match (options.commit.as_ref(), options.branch.as_ref()) {
956            (Some(commit), _) => {
957                debug!(%commit, "resolve: rule 1 — commit provided");
958                if repo.vcs().is_none() {
959                    return Err(unsatisfiable(&format!("at commit '{commit}'")));
960                }
961                Some(commit.clone())
962            }
963            (None, Some(branch)) => {
964                debug!(%branch, "resolve: rule 2 — branch provided");
965                let vcs = repo
966                    .vcs()
967                    .ok_or_else(|| unsatisfiable(&format!("at branch '{branch}'")))?;
968                Some(vcs.resolve_branch_head(&repo.root, branch)?)
969            }
970            (None, None) => {
971                let default_branch = repo_config
972                    .default_branch
973                    .as_ref()
974                    .or(self.config.default_branch.as_ref());
975                match default_branch {
976                    Some(default_branch) => {
977                        debug!(%default_branch, "resolve: rule 3 — using default_branch");
978                        let vcs = repo.vcs().ok_or_else(|| {
979                            unsatisfiable(&format!("at default branch '{default_branch}'"))
980                        })?;
981                        Some(vcs.resolve_branch_head(&repo.root, default_branch)?)
982                    }
983                    None if repo.vcs().is_some() => {
984                        debug!("resolve: rule 4 — no branch, no commit, no default_branch");
985                        return Err(NapError::NoDefaultBranch);
986                    }
987                    None => {
988                        debug!("resolve: unversioned — reading current filesystem state");
989                        None
990                    }
991                }
992            }
993        };
994
995        // Read the manifest at the resolved revision, or the current filesystem
996        // state when resolving without a revision (unversioned mode).
997        let manifest = match &revision {
998            Some(revision) => {
999                repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, revision)?
1000            }
1001            None => repo.read_manifest(&uri.entity_type, &uri.entity_id)?,
1002        };
1003
1004        let wants_provenance =
1005            options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
1006        if wants_provenance {
1007            if let Some(path) = query_path {
1008                return Err(NapError::Other(format!(
1009                    "provenance envelopes are only supported for full manifest resolution, not subtree query '{path}'"
1010                )));
1011            }
1012
1013            // Provenance is VCS-backed; it cannot be produced in unversioned mode.
1014            let revision = revision
1015                .as_deref()
1016                .ok_or_else(|| NapError::BackendNotConfigured {
1017                    operation: "provenance".to_string(),
1018                })?;
1019
1020            let envelope = self.build_provenance_envelope(
1021                &repo,
1022                uri,
1023                manifest,
1024                revision,
1025                options.include_blobs.unwrap_or(false),
1026            )?;
1027            info!(uri = %uri, "resolved NAP URI with provenance");
1028            return Ok(ResolveResult::Provenance(Box::new(envelope)));
1029        }
1030
1031        // Apply query if present
1032        match query_path {
1033            Some(ref path) => {
1034                debug!(query_path = %path, "applying subtree query");
1035                let yaml_value = manifest.to_value()?;
1036                let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
1037
1038                // Convert YAML value to JSON for consistent API output
1039                let json_str = serde_yaml::to_string(&result)
1040                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
1041                let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
1042                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
1043
1044                info!(
1045                    uri = %uri,
1046                    query_path = %path,
1047                    "resolved NAP URI with query"
1048                );
1049                Ok(ResolveResult::Subtree(json_value))
1050            }
1051            None => {
1052                info!(uri = %uri, "resolved NAP URI (full manifest)");
1053                Ok(ResolveResult::Full(Box::new(manifest)))
1054            }
1055        }
1056    }
1057
1058    fn build_provenance_envelope(
1059        &self,
1060        repo: &Repository,
1061        uri: &NapUri,
1062        manifest: Manifest,
1063        revision: &str,
1064        include_blobs: bool,
1065    ) -> Result<ResolveEnvelope, NapError> {
1066        let manifest_path = uri.manifest_path();
1067        let mut files = vec![self.build_provenance_file(
1068            repo,
1069            revision,
1070            "manifest",
1071            None,
1072            Some(manifest_path.clone()),
1073            None,
1074            None,
1075            None,
1076            include_blobs,
1077        )?];
1078
1079        for (name, representation) in &manifest.representations {
1080            let resolved_path = representation
1081                .uri
1082                .as_deref()
1083                .map(|representation_uri| {
1084                    Self::resolve_representation_path(uri, representation_uri)
1085                })
1086                .transpose()?
1087                .flatten();
1088
1089            files.push(self.build_provenance_file(
1090                repo,
1091                revision,
1092                "representation",
1093                Some(name.clone()),
1094                resolved_path,
1095                representation.uri.clone(),
1096                Some(representation.hash.clone()),
1097                Some(representation.format.clone()),
1098                include_blobs,
1099            )?);
1100        }
1101
1102        Ok(ResolveEnvelope {
1103            manifest: Box::new(manifest),
1104            provenance: ResolveProvenanceEnvelope {
1105                revision: revision.to_string(),
1106                files,
1107            },
1108        })
1109    }
1110
1111    #[allow(clippy::too_many_arguments)]
1112    fn build_provenance_file(
1113        &self,
1114        repo: &Repository,
1115        revision: &str,
1116        role: &str,
1117        name: Option<String>,
1118        path: Option<String>,
1119        uri: Option<String>,
1120        hash: Option<String>,
1121        format: Option<String>,
1122        include_blobs: bool,
1123    ) -> Result<ResolveProvenanceFile, NapError> {
1124        // Provenance is VCS-backed; in unversioned mode there is nothing to read.
1125        let vcs = repo.vcs().ok_or_else(|| NapError::BackendNotConfigured {
1126            operation: "provenance".to_string(),
1127        })?;
1128
1129        let metadata = match path.as_deref() {
1130            Some(path) => vcs.file_metadata_at_ref(&repo.root, path, revision)?,
1131            None => None,
1132        };
1133
1134        let blobs = if include_blobs {
1135            match metadata.as_ref() {
1136                Some(metadata) => Self::hydrate_known_blobs(vcs, repo, metadata)?,
1137                None => BTreeMap::new(),
1138            }
1139        } else {
1140            BTreeMap::new()
1141        };
1142
1143        let provenance = match metadata {
1144            Some(metadata) => {
1145                let condensed = Self::condense_metadata(metadata);
1146                if condensed.is_empty() {
1147                    serde_json::Value::String("none".to_string())
1148                } else {
1149                    serde_json::to_value(condensed).map_err(|e| {
1150                        NapError::Other(format!("failed to serialize provenance metadata: {e}"))
1151                    })?
1152                }
1153            }
1154            None => serde_json::Value::String("none".to_string()),
1155        };
1156
1157        Ok(ResolveProvenanceFile {
1158            role: role.to_string(),
1159            name,
1160            path,
1161            uri,
1162            hash,
1163            format,
1164            provenance,
1165            blobs,
1166        })
1167    }
1168
1169    fn condense_metadata(metadata: BTreeMap<String, String>) -> BTreeMap<String, String> {
1170        metadata
1171            .into_iter()
1172            .filter(|(_, value)| value.len() <= MAX_CONDENSED_METADATA_VALUE_BYTES)
1173            .collect()
1174    }
1175
1176    fn hydrate_known_blobs(
1177        vcs: &dyn VcsBackend,
1178        repo: &Repository,
1179        metadata: &BTreeMap<String, String>,
1180    ) -> Result<BTreeMap<String, HydratedProvenanceBlob>, NapError> {
1181        let known_blob_keys = [
1182            ("prompt", "nap.provenance.prompt.address"),
1183            ("run", "nap.provenance.run.address"),
1184            ("parameters", "nap.provenance.parameters.address"),
1185        ];
1186
1187        let mut blobs = BTreeMap::new();
1188        for (name, metadata_key) in known_blob_keys {
1189            let Some(address) = metadata.get(metadata_key) else {
1190                continue;
1191            };
1192            let content = vcs.read_provenance_blob(&repo.root, address)?;
1193            blobs.insert(name.to_string(), Self::truncate_blob(address, &content));
1194        }
1195        Ok(blobs)
1196    }
1197
1198    fn truncate_blob(address: &str, content: &str) -> HydratedProvenanceBlob {
1199        let original_bytes = content.len();
1200        let mut included_bytes = 0;
1201        let mut truncated_content = String::new();
1202
1203        for ch in content.chars() {
1204            let next_len = included_bytes + ch.len_utf8();
1205            if next_len > MAX_HYDRATED_BLOB_BYTES {
1206                break;
1207            }
1208            truncated_content.push(ch);
1209            included_bytes = next_len;
1210        }
1211
1212        HydratedProvenanceBlob {
1213            address: address.to_string(),
1214            content: truncated_content,
1215            truncated: included_bytes < original_bytes,
1216            original_bytes,
1217            included_bytes,
1218        }
1219    }
1220
1221    fn resolve_representation_path(
1222        uri: &NapUri,
1223        representation_uri: &str,
1224    ) -> Result<Option<String>, NapError> {
1225        if representation_uri.contains("://") {
1226            return Ok(None);
1227        }
1228
1229        let representation_path = Path::new(representation_uri);
1230        if representation_path.is_absolute() {
1231            return Err(NapError::InvalidQueryPath(format!(
1232                "representation URI must be relative for provenance lookup: {representation_uri}"
1233            )));
1234        }
1235
1236        let mut clean = PathBuf::new();
1237        for component in representation_path.components() {
1238            match component {
1239                Component::Normal(part) => clean.push(part),
1240                Component::CurDir => {}
1241                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
1242                    return Err(NapError::InvalidQueryPath(format!(
1243                        "unsafe representation URI for provenance lookup: {representation_uri}"
1244                    )));
1245                }
1246            }
1247        }
1248
1249        // Match nap add: representation URIs are relative to the entity's
1250        // asset directory, including world entities whose manifest is at root.
1251        let entity_dir = Path::new(uri.entity_type.as_str()).join(&uri.entity_id);
1252        Ok(Some(Self::path_to_lore_path(&entity_dir.join(clean))))
1253    }
1254
1255    fn path_to_lore_path(path: &Path) -> String {
1256        path.components()
1257            .filter_map(|component| match component {
1258                Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
1259                _ => None,
1260            })
1261            .collect::<Vec<_>>()
1262            .join("/")
1263    }
1264
1265    /// Resolve a URI recursively, following nested nap:// URIs.
1266    fn resolve_uri_recursive(
1267        &self,
1268        uri: &NapUri,
1269        options: &ResolveOptions,
1270        depth: usize,
1271        visited: &mut std::collections::HashSet<String>,
1272    ) -> Result<ResolveResult, NapError> {
1273        // Check depth limit
1274        let max_depth = options.max_depth.unwrap_or(10);
1275        if depth >= max_depth {
1276            debug!(depth, max_depth, "reached maximum recursion depth");
1277            return self.resolve_uri_single(uri, options);
1278        }
1279
1280        // Check for circular references
1281        let uri_str = uri.to_string();
1282        if visited.contains(&uri_str) {
1283            debug!(uri = %uri_str, "detected circular reference, stopping recursion");
1284            return self.resolve_uri_single(uri, options);
1285        }
1286        visited.insert(uri_str.clone());
1287
1288        debug!(uri = %uri_str, depth, "recursively resolving URI");
1289
1290        // Resolve the current URI
1291        let result = self.resolve_uri_single(uri, options)?;
1292
1293        // Extract nested URIs from the result and resolve them
1294        match result {
1295            ResolveResult::Full(manifest) => {
1296                let nested_uris = self.extract_nested_uris(&manifest);
1297                if nested_uris.is_empty() {
1298                    debug!(uri = %uri_str, "no nested URIs found, returning manifest");
1299                    return Ok(ResolveResult::Full(manifest));
1300                }
1301
1302                debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
1303
1304                // Resolve nested URIs and merge them into the result
1305                let mut resolved_manifest = (*manifest).clone();
1306                for nested_uri in nested_uris {
1307                    let nested_uri_parsed: NapUri = nested_uri.parse()?;
1308
1309                    let nested_result = self
1310                        .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
1311                        .map_err(|e| {
1312                            NapError::Other(format!(
1313                                "failed to resolve nested URI '{}' while resolving '{}': {}",
1314                                nested_uri, uri_str, e
1315                            ))
1316                        })?;
1317
1318                    if let ResolveResult::Full(nested_manifest) = nested_result {
1319                        // Merge nested manifest into parent (simple merge for now)
1320                        // In the future, this could be more sophisticated based on schema
1321                        for (key, value) in nested_manifest.properties {
1322                            resolved_manifest.properties.insert(key, value);
1323                        }
1324                    }
1325                }
1326
1327                Ok(ResolveResult::Full(Box::new(resolved_manifest)))
1328            }
1329            ResolveResult::Subtree(value) => {
1330                // For subtree queries, we don't recurse (would be complex to merge)
1331                debug!("subtree query, skipping recursive resolution");
1332                Ok(ResolveResult::Subtree(value))
1333            }
1334            ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
1335        }
1336    }
1337
1338    /// Extract all nap:// URIs from a manifest.
1339    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
1340        let mut uris = Vec::new();
1341
1342        // Search in properties
1343        for value in manifest.properties.values() {
1344            self.extract_uris_from_yaml_value(value, &mut uris);
1345        }
1346
1347        // Search in references
1348        for value in manifest.references.values() {
1349            self.extract_uris_from_yaml_value(value, &mut uris);
1350        }
1351
1352        // Deduplicate URIs to avoid resolving the same URI multiple times
1353        uris.sort();
1354        uris.dedup();
1355        uris
1356    }
1357
1358    /// Recursively extract nap:// URIs from YAML values.
1359    fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
1360        match value {
1361            serde_yaml::Value::String(s) if s.starts_with("nap://") => {
1362                uris.push(s.clone());
1363            }
1364            serde_yaml::Value::Sequence(seq) => {
1365                for item in seq {
1366                    self.extract_uris_from_yaml_value(item, uris);
1367                }
1368            }
1369            serde_yaml::Value::Mapping(map) => {
1370                for (_, v) in map {
1371                    self.extract_uris_from_yaml_value(v, uris);
1372                }
1373            }
1374            _ => {}
1375        }
1376    }
1377
1378    /// Convenience: query a specific path on a URI.
1379    pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
1380        let options = ResolveOptions {
1381            path: Some(path.to_string()),
1382            ..Default::default()
1383        };
1384        match self.resolve(uri_str, &options)? {
1385            ResolveResult::Subtree(v) => Ok(v),
1386            ResolveResult::Full(m) => m.to_json_value(),
1387            ResolveResult::Provenance(envelope) => serde_json::to_value(envelope).map_err(|e| {
1388                NapError::Other(format!("failed to serialize provenance envelope: {e}"))
1389            }),
1390        }
1391    }
1392
1393    /// List all repositories available.
1394    pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
1395        if self.source != ResolveSource::Local {
1396            let client = self.remote_client()?;
1397            return block_on_grpc(async move { client.list_repositories().await });
1398        }
1399        let mut repositories = Vec::new();
1400        for entry in std::fs::read_dir(&self.base_path)? {
1401            let entry = entry?;
1402            let path = entry.path();
1403            // Check for repository.yaml or repository.yaml to identify valid repositories
1404            if path.is_dir()
1405                && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
1406                && let Some(name) = path.file_name().and_then(|n| n.to_str())
1407            {
1408                repositories.push(name.to_string());
1409            }
1410        }
1411        repositories.sort();
1412        Ok(repositories)
1413    }
1414
1415    /// List entity YAMLs from the default branch of the configured Lore server.
1416    pub fn list_remote_entities(
1417        &self,
1418        repository: &str,
1419        entity_type: &crate::types::EntityType,
1420    ) -> Result<Vec<String>, NapError> {
1421        let client = self.remote_client()?;
1422        let repository_name = repository.to_string();
1423        let prefix = format!("{}/", entity_type.directory_name());
1424        let paths = block_on_grpc(async move {
1425            let repo = client.get_repository_by_name(&repository_name).await?;
1426            client
1427                .for_repository_id(repo.id.clone())
1428                .list_paths_at_revision(
1429                    crate::grpc_client::proto_gen::lore::model::v1::RevisionIdentifier {
1430                        branch_id: repo.default_branch_id,
1431                        number: 0,
1432                    },
1433                    prefix,
1434                )
1435                .await
1436        })?;
1437        let mut entities = paths
1438            .into_iter()
1439            .filter_map(|path| {
1440                path.strip_prefix(&format!("{}/", entity_type.directory_name()))
1441                    .map(str::to_string)
1442            })
1443            .filter(|path| !path.contains('/'))
1444            .filter_map(|path| path.strip_suffix(".yaml").map(str::to_string))
1445            .collect::<Vec<_>>();
1446        entities.sort();
1447        Ok(entities)
1448    }
1449
1450    /// List repository-relative entity manifest paths from the default branch.
1451    pub fn list_remote_manifest_paths(&self, repository: &str) -> Result<Vec<String>, NapError> {
1452        let client = self.remote_client()?;
1453        let repository_name = repository.to_string();
1454        block_on_grpc(async move {
1455            let repo = client.get_repository_by_name(&repository_name).await?;
1456            let mut paths = client
1457                .for_repository_id(repo.id.clone())
1458                .list_paths_at_revision(
1459                    crate::grpc_client::proto_gen::lore::model::v1::RevisionIdentifier {
1460                        branch_id: repo.default_branch_id,
1461                        number: 0,
1462                    },
1463                    String::new(),
1464                )
1465                .await?;
1466            paths.retain(|path| {
1467                path != "repository.yaml"
1468                    && path.ends_with(".yaml")
1469                    && path.matches('/').count() == 1
1470            });
1471            paths.sort();
1472            Ok(paths)
1473        })
1474    }
1475
1476    pub fn list_remote_branches(&self, repository: &str) -> Result<Vec<String>, NapError> {
1477        let client = self.remote_client()?;
1478        let repository_name = repository.to_string();
1479        block_on_grpc(async move {
1480            let repo = client.get_repository_by_name(&repository_name).await?;
1481            let mut branches = client
1482                .for_repository_id(repo.id)
1483                .list_branches()
1484                .await?
1485                .into_iter()
1486                .map(|branch| branch.name)
1487                .collect::<Vec<_>>();
1488            branches.sort();
1489            Ok(branches)
1490        })
1491    }
1492
1493    pub fn remote_head_hash(&self, repository: &str) -> Result<String, NapError> {
1494        let client = self.remote_client()?;
1495        let repository_name = repository.to_string();
1496        block_on_grpc(async move {
1497            let repo = client.get_repository_by_name(&repository_name).await?;
1498            let branch = client
1499                .for_repository_id(repo.id)
1500                .get_branch_by_name(&repo.default_branch_name)
1501                .await?;
1502            Ok(hex::encode(branch.latest))
1503        })
1504    }
1505
1506    pub fn remote_history(
1507        &self,
1508        uri: &NapUri,
1509        limit: usize,
1510    ) -> Result<Vec<crate::vcs::CommitInfo>, NapError> {
1511        let client = self.remote_client()?;
1512        let repository_name = uri.repository.clone();
1513        let manifest_path = uri.manifest_path();
1514        block_on_grpc(async move {
1515            let repo = client.get_repository_by_name(&repository_name).await?;
1516            let scoped = client.for_repository_id(repo.id.clone());
1517            let items = scoped
1518                .list_revisions(
1519                    crate::grpc_client::proto_gen::lore::model::v1::RevisionIdentifier {
1520                        branch_id: repo.default_branch_id,
1521                        number: 0,
1522                    },
1523                )
1524                .await?;
1525            let mut history = Vec::new();
1526            for item in items {
1527                let revision = scoped
1528                    .revision_info_at_signature(item.signature.to_vec())
1529                    .await?;
1530                let identifier = revision.identifier.clone().ok_or_else(|| {
1531                    NapError::GrpcError("RevisionInfo returned no identifier".to_string())
1532                })?;
1533                let current = match scoped
1534                    .file_address_at_revision(identifier, manifest_path.clone())
1535                    .await
1536                {
1537                    Ok(value) => Some(value),
1538                    Err(NapError::ManifestNotFound(_)) => None,
1539                    Err(error) => return Err(error),
1540                };
1541                let parent = revision.parent_self.as_ref();
1542                let previous = match parent.and_then(|parent| parent.identifier.clone()) {
1543                    Some(identifier) => match scoped
1544                        .file_address_at_revision(identifier, manifest_path.clone())
1545                        .await
1546                    {
1547                        Ok(value) => Some(value),
1548                        Err(NapError::ManifestNotFound(_)) => None,
1549                        Err(error) => return Err(error),
1550                    },
1551                    None => None,
1552                };
1553                let current_address = current
1554                    .as_ref()
1555                    .map(|(address, _)| (&address.hash, &address.context));
1556                let previous_address = previous
1557                    .as_ref()
1558                    .map(|(address, _)| (&address.hash, &address.context));
1559                if current_address == previous_address {
1560                    continue;
1561                }
1562                let parent = revision
1563                    .parent_self
1564                    .as_ref()
1565                    .map(|parent| hex::encode(&parent.signature));
1566                let timestamp =
1567                    chrono::DateTime::<chrono::Utc>::from_timestamp(revision.timestamp as i64, 0)
1568                        .map(|time| time.to_rfc3339())
1569                        .unwrap_or_default();
1570                history.push(crate::vcs::CommitInfo::from_lore_revision(
1571                    &hex::encode(revision.signature),
1572                    parent.as_deref(),
1573                    &revision.committed_by,
1574                    &revision.commit_message,
1575                    &timestamp,
1576                ));
1577                if history.len() == limit {
1578                    break;
1579                }
1580            }
1581            Ok(history)
1582        })
1583    }
1584}
1585
1586#[cfg(test)]
1587mod unit_tests {
1588    use super::*;
1589    use crate::manifest::Representation;
1590    use crate::test_utils::MockBackend;
1591    use crate::types::EntityType;
1592    use tempfile::TempDir;
1593
1594    fn setup() -> (TempDir, Resolver) {
1595        let tmp = TempDir::new().unwrap();
1596        let repo_path = tmp.path().join("toystory");
1597        let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1598
1599        // Create a character
1600        let (mut manifest, _) = repo
1601            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1602            .unwrap();
1603
1604        // Add properties and commit
1605        manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1606        manifest.set_property(
1607            "homeworld",
1608            serde_yaml::Value::String("nap://toystory/location/andys-room".to_string()),
1609        );
1610        manifest.add_reference(
1611            "appears_in",
1612            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
1613                "nap://toystory/scene/pizza-planet".to_string(),
1614            )]),
1615        );
1616        manifest.set_representation(
1617            "face_image",
1618            Representation {
1619                hash: "blake3:9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a"
1620                    .to_string(),
1621                format: "png".to_string(),
1622                uri: Some("face_image.png".to_string()),
1623                tier: None,
1624            },
1625        );
1626
1627        use crate::commit::Change;
1628        repo.commit_manifest(
1629            &mut manifest,
1630            "add Woody details",
1631            "test",
1632            vec![Change::set(
1633                "properties.toy_type",
1634                None,
1635                "plush".to_string(),
1636            )],
1637        )
1638        .unwrap();
1639
1640        let resolver = Resolver::with_vcs_factory(
1641            tmp.path(),
1642            || Box::new(MockBackend::new()),
1643            ResolveConfig {
1644                default_branch: Some("main".to_string()),
1645            },
1646        );
1647        (tmp, resolver)
1648    }
1649
1650    #[test]
1651    fn test_resolve_full_manifest() {
1652        let (_tmp, resolver) = setup();
1653        let result = resolver
1654            .resolve("nap://toystory/character/woody", &Default::default())
1655            .unwrap();
1656        match result {
1657            ResolveResult::Full(m) => {
1658                assert_eq!(m.name, "Woody");
1659                assert_eq!(m.entity_type.as_str(), "character");
1660            }
1661            _ => panic!("expected full manifest"),
1662        }
1663    }
1664
1665    fn write_mock_metadata(repo_path: &Path, metadata: BTreeMap<String, BTreeMap<String, String>>) {
1666        std::fs::write(
1667            repo_path.join(".mock_file_metadata.json"),
1668            serde_json::to_string(&metadata).unwrap(),
1669        )
1670        .unwrap();
1671    }
1672
1673    fn write_mock_blobs(repo_path: &Path, blobs: BTreeMap<String, String>) {
1674        std::fs::write(
1675            repo_path.join(".mock_provenance_blobs.json"),
1676            serde_json::to_string(&blobs).unwrap(),
1677        )
1678        .unwrap();
1679    }
1680
1681    fn resolve_with_provenance(resolver: &Resolver) -> ResolveEnvelope {
1682        let result = resolver
1683            .resolve(
1684                "nap://toystory/character/woody",
1685                &ResolveOptions {
1686                    provenance: Some(true),
1687                    ..Default::default()
1688                },
1689            )
1690            .unwrap();
1691        match result {
1692            ResolveResult::Provenance(envelope) => *envelope,
1693            _ => panic!("expected provenance envelope"),
1694        }
1695    }
1696
1697    #[test]
1698    fn test_resolve_with_provenance_returns_manifest_and_direct_file_entries() {
1699        let (tmp, resolver) = setup();
1700        let repo_path = tmp.path().join("toystory");
1701        write_mock_metadata(
1702            &repo_path,
1703            BTreeMap::from([
1704                (
1705                    "character/woody.yaml".to_string(),
1706                    BTreeMap::from([
1707                        ("nap.provenance.kind".to_string(), "edit".to_string()),
1708                        ("nap.provenance.model".to_string(), "gpt-5".to_string()),
1709                        (
1710                            "nap.provenance.long".to_string(),
1711                            "x".repeat(MAX_CONDENSED_METADATA_VALUE_BYTES + 1),
1712                        ),
1713                    ]),
1714                ),
1715                (
1716                    "character/woody/face_image.png".to_string(),
1717                    BTreeMap::from([("nap.provenance.kind".to_string(), "generation".to_string())]),
1718                ),
1719            ]),
1720        );
1721
1722        let envelope = resolve_with_provenance(&resolver);
1723        assert_eq!(envelope.manifest.name, "Woody");
1724        assert_eq!(envelope.provenance.files.len(), 2);
1725
1726        let manifest_file = &envelope.provenance.files[0];
1727        assert_eq!(manifest_file.role, "manifest");
1728        assert_eq!(manifest_file.path.as_deref(), Some("character/woody.yaml"));
1729        assert_eq!(manifest_file.provenance["nap.provenance.kind"], "edit");
1730        assert!(
1731            manifest_file
1732                .provenance
1733                .get("nap.provenance.long")
1734                .is_none()
1735        );
1736
1737        let representation_file = &envelope.provenance.files[1];
1738        assert_eq!(representation_file.role, "representation");
1739        assert_eq!(representation_file.name.as_deref(), Some("face_image"));
1740        assert_eq!(
1741            representation_file.path.as_deref(),
1742            Some("character/woody/face_image.png")
1743        );
1744        assert_eq!(representation_file.uri.as_deref(), Some("face_image.png"));
1745        assert_eq!(representation_file.format.as_deref(), Some("png"));
1746    }
1747
1748    #[test]
1749    fn test_resolve_with_provenance_records_path_and_revision_metadata_lookups() {
1750        let (tmp, resolver) = setup();
1751        let repo_path = tmp.path().join("toystory");
1752        let envelope = resolve_with_provenance(&resolver);
1753
1754        let requests: Vec<BTreeMap<String, String>> = serde_json::from_str(
1755            &std::fs::read_to_string(repo_path.join(".mock_metadata_requests.json")).unwrap(),
1756        )
1757        .unwrap();
1758        assert_eq!(requests.len(), 2);
1759        assert_eq!(requests[0].get("path").unwrap(), "character/woody.yaml");
1760        assert_eq!(
1761            requests[0].get("revision").unwrap(),
1762            &envelope.provenance.revision
1763        );
1764        assert_eq!(
1765            requests[1].get("path").unwrap(),
1766            "character/woody/face_image.png"
1767        );
1768        assert_eq!(
1769            requests[1].get("revision").unwrap(),
1770            &envelope.provenance.revision
1771        );
1772        assert!(!requests.iter().any(|request| {
1773            request
1774                .get("path")
1775                .is_some_and(|path| path.starts_with("blake3:"))
1776        }));
1777    }
1778
1779    #[test]
1780    fn test_resolve_with_provenance_uses_none_for_missing_metadata() {
1781        let (_tmp, resolver) = setup();
1782        let envelope = resolve_with_provenance(&resolver);
1783        assert_eq!(envelope.provenance.files[0].provenance, "none");
1784        assert_eq!(envelope.provenance.files[1].provenance, "none");
1785    }
1786
1787    #[test]
1788    fn test_resolve_with_include_blobs_hydrates_known_readable_artifacts() {
1789        let (tmp, resolver) = setup();
1790        let repo_path = tmp.path().join("toystory");
1791        write_mock_metadata(
1792            &repo_path,
1793            BTreeMap::from([(
1794                "character/woody.yaml".to_string(),
1795                BTreeMap::from([
1796                    (
1797                        "nap.provenance.prompt.address".to_string(),
1798                        "lore:prompt:1".to_string(),
1799                    ),
1800                    (
1801                        "unrelated.artifact.address".to_string(),
1802                        "lore:binary:1".to_string(),
1803                    ),
1804                ]),
1805            )]),
1806        );
1807        write_mock_blobs(
1808            &repo_path,
1809            BTreeMap::from([("lore:prompt:1".to_string(), "Describe Woody".to_string())]),
1810        );
1811
1812        let result = resolver
1813            .resolve(
1814                "nap://toystory/character/woody",
1815                &ResolveOptions {
1816                    provenance: Some(true),
1817                    include_blobs: Some(true),
1818                    ..Default::default()
1819                },
1820            )
1821            .unwrap();
1822        let ResolveResult::Provenance(envelope) = result else {
1823            panic!("expected provenance envelope");
1824        };
1825
1826        let blobs = &envelope.provenance.files[0].blobs;
1827        assert_eq!(blobs.len(), 1);
1828        assert_eq!(blobs["prompt"].content, "Describe Woody");
1829        assert!(!blobs["prompt"].truncated);
1830    }
1831
1832    #[test]
1833    fn test_include_blobs_implies_provenance_envelope() {
1834        let (_tmp, resolver) = setup();
1835        let result = resolver
1836            .resolve(
1837                "nap://toystory/character/woody",
1838                &ResolveOptions {
1839                    include_blobs: Some(true),
1840                    ..Default::default()
1841                },
1842            )
1843            .unwrap();
1844        assert!(matches!(result, ResolveResult::Provenance(_)));
1845    }
1846
1847    #[test]
1848    fn test_resolve_with_include_blobs_truncates_readable_artifacts() {
1849        let (tmp, resolver) = setup();
1850        let repo_path = tmp.path().join("toystory");
1851        write_mock_metadata(
1852            &repo_path,
1853            BTreeMap::from([(
1854                "character/woody.yaml".to_string(),
1855                BTreeMap::from([(
1856                    "nap.provenance.prompt.address".to_string(),
1857                    "lore:prompt:large".to_string(),
1858                )]),
1859            )]),
1860        );
1861        write_mock_blobs(
1862            &repo_path,
1863            BTreeMap::from([(
1864                "lore:prompt:large".to_string(),
1865                "x".repeat(MAX_HYDRATED_BLOB_BYTES + 10),
1866            )]),
1867        );
1868
1869        let result = resolver
1870            .resolve(
1871                "nap://toystory/character/woody",
1872                &ResolveOptions {
1873                    provenance: Some(true),
1874                    include_blobs: Some(true),
1875                    ..Default::default()
1876                },
1877            )
1878            .unwrap();
1879        let ResolveResult::Provenance(envelope) = result else {
1880            panic!("expected provenance envelope");
1881        };
1882        let blob = &envelope.provenance.files[0].blobs["prompt"];
1883        assert!(blob.truncated);
1884        assert_eq!(blob.original_bytes, MAX_HYDRATED_BLOB_BYTES + 10);
1885        assert_eq!(blob.included_bytes, MAX_HYDRATED_BLOB_BYTES);
1886        assert_eq!(blob.content.len(), MAX_HYDRATED_BLOB_BYTES);
1887    }
1888
1889    #[test]
1890    fn test_provenance_rejects_unsafe_representation_paths() {
1891        let tmp = TempDir::new().unwrap();
1892        let repo_path = tmp.path().join("toystory");
1893        let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1894        let (mut manifest, _) = repo
1895            .create_entity(&EntityType::new("character"), "jessie", "Jessie", "test")
1896            .unwrap();
1897        manifest.set_representation(
1898            "unsafe",
1899            Representation {
1900                hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1901                    .to_string(),
1902                format: "png".to_string(),
1903                uri: Some("../secret.png".to_string()),
1904                tier: None,
1905            },
1906        );
1907        use crate::commit::Change;
1908        repo.commit_manifest(
1909            &mut manifest,
1910            "add unsafe representation",
1911            "test",
1912            vec![Change::set(
1913                "representations.unsafe",
1914                None,
1915                "unsafe".to_string(),
1916            )],
1917        )
1918        .unwrap();
1919
1920        let resolver = Resolver::with_vcs_factory(
1921            tmp.path(),
1922            || Box::new(MockBackend::new()),
1923            ResolveConfig {
1924                default_branch: Some("main".to_string()),
1925            },
1926        );
1927        let err = resolver
1928            .resolve(
1929                "nap://toystory/character/jessie",
1930                &ResolveOptions {
1931                    provenance: Some(true),
1932                    ..Default::default()
1933                },
1934            )
1935            .unwrap_err();
1936        assert!(err.to_string().contains("unsafe representation URI"));
1937    }
1938
1939    #[test]
1940    fn test_resolve_with_fragment() {
1941        let (_tmp, resolver) = setup();
1942        let result = resolver
1943            .resolve(
1944                "nap://toystory/character/woody#properties.toy_type",
1945                &Default::default(),
1946            )
1947            .unwrap();
1948        match result {
1949            ResolveResult::Subtree(v) => {
1950                assert_eq!(v.as_str(), Some("plush"));
1951            }
1952            _ => panic!("expected subtree"),
1953        }
1954    }
1955
1956    #[test]
1957    fn test_resolve_with_options_path() {
1958        let (_tmp, resolver) = setup();
1959        let result = resolver
1960            .resolve(
1961                "nap://toystory/character/woody",
1962                &ResolveOptions {
1963                    path: Some("properties.homeworld".to_string()),
1964                    ..Default::default()
1965                },
1966            )
1967            .unwrap();
1968        match result {
1969            ResolveResult::Subtree(v) => {
1970                assert_eq!(v.as_str(), Some("nap://toystory/location/andys-room"));
1971            }
1972            _ => panic!("expected subtree"),
1973        }
1974    }
1975
1976    #[test]
1977    fn test_query_convenience() {
1978        let (_tmp, resolver) = setup();
1979        let result = resolver
1980            .query("nap://toystory/character/woody", "properties.toy_type")
1981            .unwrap();
1982        assert_eq!(result.as_str(), Some("plush"));
1983    }
1984
1985    #[test]
1986    fn test_list_repositories() {
1987        let (_tmp, resolver) = setup();
1988        let repositories = resolver.list_repositories().unwrap();
1989        assert!(repositories.contains(&"toystory".to_string()));
1990    }
1991
1992    #[test]
1993    fn test_resolve_not_found() {
1994        let (_tmp, resolver) = setup();
1995        let result = resolver.resolve("nap://toystory/character/nonexistent", &Default::default());
1996        assert!(result.is_err());
1997    }
1998
1999    #[test]
2000    fn test_resolve_without_scheme() {
2001        let (_tmp, resolver) = setup();
2002        let result = resolver
2003            .resolve("toystory/character/woody", &Default::default())
2004            .unwrap();
2005        match result {
2006            ResolveResult::Full(m) => {
2007                assert_eq!(m.name, "Woody");
2008                assert_eq!(m.entity_type.as_str(), "character");
2009            }
2010            _ => panic!("expected full manifest"),
2011        }
2012    }
2013
2014    #[test]
2015    fn test_resolve_without_scheme_with_fragment() {
2016        let (_tmp, resolver) = setup();
2017        let result = resolver
2018            .resolve(
2019                "toystory/character/woody#properties.toy_type",
2020                &Default::default(),
2021            )
2022            .unwrap();
2023        match result {
2024            ResolveResult::Subtree(v) => {
2025                assert_eq!(v.as_str(), Some("plush"));
2026            }
2027            _ => panic!("expected subtree"),
2028        }
2029    }
2030
2031    #[test]
2032    fn test_resolve_without_leading_slash() {
2033        let (_tmp, resolver) = setup();
2034        let result = resolver
2035            .resolve("toystory/character/woody", &Default::default())
2036            .unwrap();
2037        match result {
2038            ResolveResult::Full(m) => {
2039                assert_eq!(m.name, "Woody");
2040            }
2041            _ => panic!("expected full manifest"),
2042        }
2043    }
2044
2045    #[test]
2046    fn test_resolve_with_leading_slash_without_scheme() {
2047        let (_tmp, resolver) = setup();
2048        let result = resolver
2049            .resolve("/toystory/character/woody", &Default::default())
2050            .unwrap();
2051        match result {
2052            ResolveResult::Full(m) => {
2053                assert_eq!(m.name, "Woody");
2054            }
2055            _ => panic!("expected full manifest"),
2056        }
2057    }
2058
2059    #[test]
2060    fn presign_debug_output_redacts_secrets() {
2061        let options = PresignOptions {
2062            bearer_token: Some("secret-token".to_string()),
2063            ..Default::default()
2064        };
2065        let rendered = format!("{options:?}");
2066        assert!(rendered.contains("<redacted>"));
2067        assert!(!rendered.contains("secret-token"));
2068
2069        let result = PresignedRepresentation {
2070            url: "https://example.test/v1/presigned/x?token=secret".to_string(),
2071            expires_at: 1,
2072            revision: "revision".to_string(),
2073            repository_id: "repository".to_string(),
2074            address: "address".to_string(),
2075            representation: "face_image".to_string(),
2076            format: "png".to_string(),
2077        };
2078        assert!(!format!("{result:?}").contains("token=secret"));
2079    }
2080
2081    #[test]
2082    fn presigned_url_validation_rejects_cross_origin_and_extra_query_data() {
2083        let base = reqwest::Url::parse("https://lore.example.test").unwrap();
2084        let path = "/v1/presigned/repository/address";
2085        assert!(validate_presigned_url(&base, "//evil.test/x?token=x", path).is_err());
2086        assert!(
2087            validate_presigned_url(
2088                &base,
2089                "/v1/presigned/repository/address?token=x&redirect=https://evil.test",
2090                path,
2091            )
2092            .is_err()
2093        );
2094        assert!(
2095            validate_presigned_url(&base, "/v1/presigned/repository/address?token=opaque", path,)
2096                .is_ok()
2097        );
2098    }
2099
2100    #[tokio::test]
2101    async fn presign_uses_repository_id_and_file_context_separately() {
2102        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2103
2104        let (_tmp, resolver) = setup();
2105        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2106        let address = listener.local_addr().unwrap();
2107        let server = tokio::spawn(async move {
2108            let (mut stream, _) = listener.accept().await.unwrap();
2109            let mut request = vec![0_u8; 8192];
2110            let read = stream.read(&mut request).await.unwrap();
2111            let request = String::from_utf8_lossy(&request[..read]);
2112            assert!(request.contains(
2113                "POST /v1/repository/0123456789abcdef0123456789abcdef/content/9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-fedcba9876543210fedcba9876543210/presign"
2114            ));
2115            assert!(request.contains("authorization: Bearer test-token"));
2116            assert!(request.contains("\"ttl_seconds\":90"));
2117            let body = concat!(
2118                "{\"url_suffix\":\"/v1/presigned/0123456789abcdef0123456789abcdef/",
2119                "9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-",
2120                "fedcba9876543210fedcba9876543210?token=opaque\",\"expires_at\":12345}"
2121            );
2122            let response = format!(
2123                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2124                body.len(),
2125                body
2126            );
2127            stream.write_all(response.as_bytes()).await.unwrap();
2128        });
2129
2130        let result = resolver
2131            .presign_representation(
2132                "toystory/character/woody",
2133                "face_image",
2134                &PresignOptions {
2135                    ttl_seconds: Some(90),
2136                    lore_http_url: Some(format!("http://{address}")),
2137                    bearer_token: Some("test-token".to_string()),
2138                    ..Default::default()
2139                },
2140            )
2141            .await
2142            .unwrap();
2143        server.await.unwrap();
2144        assert_eq!(result.expires_at, 12345);
2145        assert_eq!(result.repository_id, "0123456789abcdef0123456789abcdef");
2146        assert!(result.url.ends_with("?token=opaque"));
2147    }
2148
2149    #[tokio::test]
2150    async fn presign_reuses_existing_login_after_http_challenge() {
2151        use tokio::io::{AsyncReadExt, AsyncWriteExt};
2152        let (tmp, resolver) = setup();
2153        std::fs::write(
2154            tmp.path().join("toystory/.mock_http_token"),
2155            "cached-repository-token",
2156        )
2157        .unwrap();
2158        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
2159        let origin = format!("http://{}", listener.local_addr().unwrap());
2160        let server = tokio::spawn(async move {
2161            for authenticated in [false, true] {
2162                let (mut stream, _) = listener.accept().await.unwrap();
2163                let mut request = vec![0; 8192];
2164                let read = stream.read(&mut request).await.unwrap();
2165                let request = String::from_utf8_lossy(&request[..read]);
2166                assert_eq!(
2167                    request.contains("authorization: Bearer cached-repository-token"),
2168                    authenticated
2169                );
2170                let (status, body) = if authenticated {
2171                    (
2172                        "200 OK",
2173                        r#"{"url_suffix":"/v1/presigned/0123456789abcdef0123456789abcdef/9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-fedcba9876543210fedcba9876543210?token=opaque","expires_at":12345}"#,
2174                    )
2175                } else {
2176                    ("401 Unauthorized", "unauthenticated")
2177                };
2178                stream.write_all(format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
2179            }
2180        });
2181        let result = resolver
2182            .presign_representation(
2183                "toystory/character/woody",
2184                "face_image",
2185                &PresignOptions {
2186                    lore_http_url: Some(origin),
2187                    ..Default::default()
2188                },
2189            )
2190            .await
2191            .unwrap();
2192        assert_eq!(result.expires_at, 12345);
2193        server.await.unwrap();
2194    }
2195
2196    #[test]
2197    fn representation_paths_use_the_entity_asset_directory() {
2198        for (entity, asset, expected) in [
2199            (
2200                "nap://25th-chapter/character/nathan-gunn",
2201                "item.jpg",
2202                "character/nathan-gunn/item.jpg",
2203            ),
2204            (
2205                "nap://25th-chapter/world/25th-chapter",
2206                "images/map.png",
2207                "world/25th-chapter/images/map.png",
2208            ),
2209        ] {
2210            let uri: NapUri = entity.parse().unwrap();
2211            assert_eq!(
2212                Resolver::resolve_representation_path(&uri, asset).unwrap(),
2213                Some(expected.to_string()),
2214            );
2215        }
2216    }
2217
2218    #[tokio::test]
2219    async fn presign_rejects_fragment_and_conflicting_revision_selectors() {
2220        let (_tmp, resolver) = setup();
2221        assert!(
2222            resolver
2223                .presign_representation(
2224                    "nap://toystory/character/woody#properties",
2225                    "face_image",
2226                    &PresignOptions::default(),
2227                )
2228                .await
2229                .is_err()
2230        );
2231        assert!(
2232            resolver
2233                .presign_representation(
2234                    "nap://toystory/character/woody",
2235                    "face_image",
2236                    &PresignOptions {
2237                        branch: Some("main".to_string()),
2238                        commit: Some("abc".to_string()),
2239                        ..Default::default()
2240                    },
2241                )
2242                .await
2243                .unwrap_err()
2244                .to_string()
2245                .contains("either branch or commit")
2246        );
2247    }
2248}
2249
2250#[cfg(all(test, feature = "lore-integration"))]
2251mod lore_tests {
2252    use super::*;
2253    use crate::types::EntityType;
2254    use crate::vcs_lore::LoreBackend;
2255    use std::time::{SystemTime, UNIX_EPOCH};
2256    use tempfile::TempDir;
2257
2258    fn unique_suffix() -> u64 {
2259        SystemTime::now()
2260            .duration_since(UNIX_EPOCH)
2261            .unwrap()
2262            .as_nanos() as u64
2263    }
2264
2265    fn setup_lore() -> (TempDir, Resolver, String) {
2266        let repository = format!("lr-{}", unique_suffix());
2267        let tmp = TempDir::new().unwrap();
2268        let repo_path = tmp.path().join(&repository);
2269        let repo =
2270            Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
2271
2272        // Create a character
2273        let (mut manifest, _) = repo
2274            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
2275            .unwrap();
2276
2277        // Add properties and commit
2278        manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
2279        use crate::commit::Change;
2280        repo.commit_manifest(
2281            &mut manifest,
2282            "add Woody details",
2283            "test",
2284            vec![Change::set(
2285                "properties.toy_type",
2286                None,
2287                "plush".to_string(),
2288            )],
2289        )
2290        .unwrap();
2291
2292        let resolver = Resolver::with_vcs_factory(
2293            tmp.path(),
2294            || Box::new(LoreBackend::from_env()),
2295            ResolveConfig {
2296                default_branch: Some("main".to_string()),
2297            },
2298        );
2299        (tmp, resolver, repository)
2300    }
2301
2302    #[test]
2303    fn test_resolve_lore_full_manifest() {
2304        let (_tmp, resolver, repository) = setup_lore();
2305        let uri = format!("nap://{}/character/woody", repository);
2306        let result = resolver.resolve(&uri, &Default::default()).unwrap();
2307        match result {
2308            ResolveResult::Full(m) => {
2309                assert_eq!(m.name, "Woody");
2310            }
2311            _ => panic!("expected full manifest"),
2312        }
2313    }
2314}