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