Skip to main content

nap_core/
resolver.rs

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