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::manifest::Manifest;
27use crate::query::ManifestQuery;
28use crate::repository::Repository;
29use crate::uri::NapUri;
30use crate::vcs::VcsBackend;
31use crate::vcs_lore::LoreBackend;
32
33/// Resolver configuration — set at construction time.
34///
35/// Controls how the resolver resolves URIs when no explicit branch or
36/// commit is provided by the caller.
37#[derive(Debug, Clone, Default)]
38pub struct ResolveConfig {
39    /// Branch to resolve when neither `branch` nor `commit` is specified
40    /// in [`ResolveOptions`].  If `None`, resolves without a branch or
41    /// commit — this will trigger a [`NapError::NoDefaultBranch`] error
42    /// for any resolve call that omits both `branch` and `commit`.
43    pub default_branch: Option<String>,
44}
45
46/// Options for resolving a NAP URI. All are optional — omitting all
47/// causes the resolver to use its [`ResolveConfig::default_branch`] (if
48/// configured) or fail with [`NapError::NoDefaultBranch`].
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ResolveOptions {
51    /// Resolve at a specific branch. e.g., `"canon"`.
52    /// Takes precedence over [`ResolveConfig::default_branch`].
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub branch: Option<String>,
55
56    /// Resolve at a specific commit hash (BLAKE3). e.g.,
57    /// `"af1349b9f5f9a1a6a0404deb36d020949b834f2a42e37e5f8d2e4ba2765f1a2f"`.
58    /// Takes precedence over `branch` and [`ResolveConfig::default_branch`].
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub commit: Option<String>,
61
62    /// Subtree query path (overrides URI fragment). e.g., `"appearances.audienceVotes"`.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub path: Option<String>,
65
66    /// Recursively resolve nested URIs. When true, the resolver will follow
67    /// all nap:// URIs found in the resolved manifest and resolve them as well.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub recursive: Option<bool>,
70
71    /// Maximum recursion depth for recursive resolution. Defaults to 10 to prevent
72    /// infinite loops. Set to None for unlimited depth (not recommended).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub max_depth: Option<usize>,
75
76    /// Include per-file provenance metadata for the manifest and direct representations.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub provenance: Option<bool>,
79
80    /// Hydrate known readable provenance artifacts such as prompts and run records.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub include_blobs: Option<bool>,
83}
84
85/// Options for creating a bearer URL for a committed representation.
86#[derive(Clone, Default)]
87pub struct PresignOptions {
88    pub branch: Option<String>,
89    pub commit: Option<String>,
90    pub ttl_seconds: Option<u64>,
91    pub lore_http_url: Option<String>,
92    pub bearer_token: Option<String>,
93}
94
95impl fmt::Debug for PresignOptions {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.debug_struct("PresignOptions")
98            .field("branch", &self.branch)
99            .field("commit", &self.commit)
100            .field("ttl_seconds", &self.ttl_seconds)
101            .field("lore_http_url", &self.lore_http_url)
102            .field(
103                "bearer_token",
104                &self.bearer_token.as_ref().map(|_| "<redacted>"),
105            )
106            .finish()
107    }
108}
109
110/// A time-limited public URL for one immutable representation.
111#[derive(Clone, Serialize, Deserialize)]
112pub struct PresignedRepresentation {
113    pub url: String,
114    pub expires_at: u64,
115    pub revision: String,
116    pub repository_id: String,
117    pub address: String,
118    pub representation: String,
119    pub format: String,
120}
121
122impl fmt::Debug for PresignedRepresentation {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        f.debug_struct("PresignedRepresentation")
125            .field("url", &"<redacted>")
126            .field("expires_at", &self.expires_at)
127            .field("revision", &self.revision)
128            .field("repository_id", &self.repository_id)
129            .field("address", &self.address)
130            .field("representation", &self.representation)
131            .field("format", &self.format)
132            .finish()
133    }
134}
135
136#[derive(Serialize)]
137struct LorePresignRequest {
138    #[serde(skip_serializing_if = "Option::is_none")]
139    ttl_seconds: Option<u64>,
140}
141
142#[derive(Deserialize)]
143struct LorePresignResponse {
144    url_suffix: String,
145    expires_at: u64,
146}
147
148fn presign_http_client() -> Result<&'static reqwest::Client, NapError> {
149    static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
150    CLIENT
151        .get_or_init(|| {
152            reqwest::Client::builder()
153                .connect_timeout(Duration::from_secs(5))
154                .timeout(Duration::from_secs(30))
155                .redirect(reqwest::redirect::Policy::none())
156                .build()
157                .map_err(|e| e.to_string())
158        })
159        .as_ref()
160        .map_err(|e| NapError::Other(format!("failed to initialize presign HTTP client: {e}")))
161}
162
163async fn read_bounded_response(
164    mut response: reqwest::Response,
165    limit: usize,
166) -> Result<(reqwest::StatusCode, Vec<u8>), NapError> {
167    let status = response.status();
168    if response
169        .content_length()
170        .is_some_and(|length| length > limit as u64)
171    {
172        return Err(NapError::Other(format!(
173            "Lore presign response exceeded {limit} bytes"
174        )));
175    }
176    let mut body = Vec::new();
177    while let Some(chunk) = response
178        .chunk()
179        .await
180        .map_err(|e| NapError::Other(format!("failed to read Lore presign response: {e}")))?
181    {
182        if body.len().saturating_add(chunk.len()) > limit {
183            return Err(NapError::Other(format!(
184                "Lore presign response exceeded {limit} bytes"
185            )));
186        }
187        body.extend_from_slice(&chunk);
188    }
189    Ok((status, body))
190}
191
192fn validate_presigned_url(
193    base_url: &reqwest::Url,
194    suffix: &str,
195    expected_path: &str,
196) -> Result<reqwest::Url, NapError> {
197    if suffix.starts_with("//") || !suffix.starts_with('/') {
198        return Err(NapError::Other(
199            "Lore returned an unexpected presigned URL path".to_string(),
200        ));
201    }
202    let url = base_url
203        .join(suffix)
204        .map_err(|e| NapError::Other(format!("invalid Lore presigned URL: {e}")))?;
205    let query: Vec<_> = url.query_pairs().collect();
206    if url.origin() != base_url.origin()
207        || url.path() != expected_path
208        || query.len() != 1
209        || query[0].0 != "token"
210        || query[0].1.is_empty()
211    {
212        return Err(NapError::Other(
213            "Lore returned a cross-origin or malformed presigned URL".to_string(),
214        ));
215    }
216    Ok(url)
217}
218
219impl ResolveOptions {
220    /// Returns the query path (from options or URI fragment).
221    fn query_path(&self, uri: &NapUri) -> Option<String> {
222        self.path.clone().or_else(|| uri.fragment.clone())
223    }
224}
225
226/// The result of resolving a NAP URI.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(untagged)]
229pub enum ResolveResult {
230    /// Full manifest (no query applied).
231    Full(Box<Manifest>),
232    /// Full manifest with Lore-backed per-file provenance envelope.
233    Provenance(Box<ResolveEnvelope>),
234    /// Subtree result from a query.
235    Subtree(serde_json::Value),
236}
237
238/// Envelope returned when `ResolveOptions::provenance` is enabled.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ResolveEnvelope {
241    pub manifest: Box<Manifest>,
242    pub provenance: ResolveProvenanceEnvelope,
243}
244
245/// Per-resolution provenance metadata.
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ResolveProvenanceEnvelope {
248    pub revision: String,
249    pub files: Vec<ResolveProvenanceFile>,
250}
251
252/// Provenance for one file participating in an entity resolution.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct ResolveProvenanceFile {
255    pub role: String,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub name: Option<String>,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub path: Option<String>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub uri: Option<String>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub hash: Option<String>,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub format: Option<String>,
266    pub provenance: serde_json::Value,
267    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
268    pub blobs: BTreeMap<String, HydratedProvenanceBlob>,
269}
270
271/// Hydrated readable provenance artifact.
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct HydratedProvenanceBlob {
274    pub address: String,
275    pub content: String,
276    pub truncated: bool,
277    pub original_bytes: usize,
278    pub included_bytes: usize,
279}
280
281const MAX_CONDENSED_METADATA_VALUE_BYTES: usize = 256;
282const MAX_HYDRATED_BLOB_BYTES: usize = 12_000;
283
284/// The NAP resolver — resolves URIs to manifests or subtrees.
285pub struct Resolver {
286    /// Base directory containing repository repositories.
287    base_path: PathBuf,
288    /// VCS backend factory (creates backend per-repo).
289    vcs_factory: fn() -> Box<dyn VcsBackend>,
290    /// Whether a version-control backend is configured. When `false`,
291    /// repositories are opened in unversioned mode and resolution reads the
292    /// current filesystem state (no branch/commit selectors available).
293    use_vcs: bool,
294    /// Resolution configuration (default branch, etc.).
295    config: ResolveConfig,
296}
297
298impl Resolver {
299    /// Create a resolver that looks for repository repos under `base_path`.
300    ///
301    /// Uses [`LoreBackend::from_env()`] by default **when a version-control
302    /// backend is configured** for `base_path` (i.e. a valid `provider.toml`
303    /// exists). Otherwise repositories are opened in unversioned mode. For
304    /// testing, use [`Resolver::with_vcs_factory()`] with a mock backend.
305    ///
306    /// Uses [`ResolveConfig::default()`] — meaning `default_branch` is
307    /// `None`. In versioned mode any resolve that omits both `branch` and
308    /// `commit` will fail with [`NapError::NoDefaultBranch`]; in unversioned
309    /// mode such a resolve reads the current filesystem state.
310    ///
311    /// # Example layout
312    /// ```text
313    /// base_path/
314    /// ├── toystory/    ← repository repo
315    /// ├── toystory/    ← repository repo
316    /// └── marvel/      ← repository repo
317    /// ```
318    pub fn new(base_path: &Path) -> Self {
319        Self {
320            base_path: base_path.to_path_buf(),
321            vcs_factory: || Box::new(LoreBackend::from_env()),
322            use_vcs: crate::provider::version_control_configured(base_path),
323            config: ResolveConfig::default(),
324        }
325    }
326
327    /// Create a resolver with a custom VCS backend factory and config.
328    ///
329    /// Repositories are always opened in versioned mode.
330    pub fn with_vcs_factory(
331        base_path: &Path,
332        factory: fn() -> Box<dyn VcsBackend>,
333        config: ResolveConfig,
334    ) -> Self {
335        Self {
336            base_path: base_path.to_path_buf(),
337            vcs_factory: factory,
338            use_vcs: true,
339            config,
340        }
341    }
342
343    /// Open the repository for a given repository and read its resolve config.
344    fn open_repo(&self, repository: &str) -> Result<(Repository, ResolveConfig), NapError> {
345        let repo_path = self.base_path.join(repository);
346        let vcs = if self.use_vcs {
347            Some((self.vcs_factory)())
348        } else {
349            None
350        };
351        let repo = Repository::open_optional(&repo_path, vcs)?;
352        let repo_config = repo.read_resolve_config();
353        Ok((repo, repo_config))
354    }
355
356    /// Resolve a NAP URI string with options.
357    ///
358    /// # Examples
359    /// ```text
360    /// // Full manifest
361    /// resolver.resolve("nap://toystory/character/woody", &Default::default())
362    ///
363    /// // Without scheme (auto-normalized)
364    /// resolver.resolve("toystory/character/woody", &Default::default())
365    ///
366    /// // With branch
367    /// resolver.resolve("nap://toystory/character/woody", &ResolveOptions {
368    ///     branch: Some("canon".to_string()),
369    ///     ..Default::default()
370    /// })
371    ///
372    /// // With fragment query (via URI)
373    /// resolver.resolve("nap://toystory/character/woody#references.appears_in", &Default::default())
374    /// ```
375    pub fn resolve(
376        &self,
377        uri_str: &str,
378        options: &ResolveOptions,
379    ) -> Result<ResolveResult, NapError> {
380        // ── Normalization: Prepend nap:// if missing ─────────────────────
381        let normalized_uri_str = if uri_str.starts_with("nap://") {
382            uri_str.to_string()
383        } else {
384            format!("nap://{}", uri_str.trim_start_matches('/'))
385        };
386
387        debug!(
388            original_uri = %uri_str,
389            normalized_uri = %normalized_uri_str,
390            "normalized NAP URI"
391        );
392
393        let uri: NapUri = normalized_uri_str.parse()?;
394        self.resolve_uri(&uri, options)
395    }
396
397    /// Create a time-limited public URL for a direct, committed representation.
398    ///
399    /// The returned URL is a bearer capability. Callers must not log it or
400    /// persist it beyond `expires_at`.
401    pub async fn presign_representation(
402        &self,
403        uri_str: &str,
404        representation_name: &str,
405        options: &PresignOptions,
406    ) -> Result<PresignedRepresentation, NapError> {
407        if options.branch.is_some() && options.commit.is_some() {
408            return Err(NapError::Other(
409                "presign accepts either branch or commit, not both".to_string(),
410            ));
411        }
412
413        let normalized = if uri_str.starts_with("nap://") {
414            uri_str.to_string()
415        } else {
416            format!("nap://{}", uri_str.trim_start_matches('/'))
417        };
418        let uri: NapUri = normalized.parse()?;
419        if uri.fragment.is_some() {
420            return Err(NapError::InvalidUri {
421                uri: uri_str.to_string(),
422                reason: "fragments are not supported when presigning a representation".to_string(),
423            });
424        }
425
426        let (repo, repo_config) = self.open_repo(&uri.repository)?;
427        let vcs = repo.vcs().ok_or_else(|| NapError::BackendNotConfigured {
428            operation: "presign a representation".to_string(),
429        })?;
430        let revision = match (&options.commit, &options.branch) {
431            (Some(commit), None) => commit.clone(),
432            (None, Some(branch)) => vcs.resolve_branch_head(&repo.root, branch)?,
433            (None, None) => {
434                let branch = repo_config
435                    .default_branch
436                    .as_ref()
437                    .or(self.config.default_branch.as_ref())
438                    .ok_or(NapError::NoDefaultBranch)?;
439                vcs.resolve_branch_head(&repo.root, branch)?
440            }
441            (Some(_), Some(_)) => unreachable!("validated above"),
442        };
443
444        let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
445        let representation = manifest
446            .representations
447            .get(representation_name)
448            .ok_or_else(|| {
449                NapError::Other(format!(
450                    "representation '{representation_name}' does not exist on {}",
451                    manifest.id
452                ))
453            })?;
454        let representation_uri = representation.uri.as_deref().ok_or_else(|| {
455            NapError::Other(format!(
456                "representation '{representation_name}' has no repository-relative URI"
457            ))
458        })?;
459        let file_path = Self::resolve_representation_path(&uri, representation_uri)?
460            .ok_or_else(|| {
461                NapError::Other(format!(
462                    "representation '{representation_name}' is external; only direct repository files can be presigned"
463                ))
464            })?;
465
466        let repository = vcs.repository_descriptor(&repo.root)?;
467        let content = vcs.file_content_address_at_ref(&repo.root, &file_path, &revision)?;
468        if let Some(expected_hash) = representation.hash.strip_prefix("blake3:")
469            && expected_hash != content.hash
470        {
471            return Err(NapError::ContentHashMismatch {
472                expected: representation.hash.clone(),
473                actual: format!("blake3:{}", content.hash),
474            });
475        }
476        let address = content.as_lore_address();
477
478        let configured_http_url = options
479            .lore_http_url
480            .clone()
481            .or_else(|| std::env::var("NAP_LORE_HTTP_URL").ok());
482        let http_url = match configured_http_url {
483            Some(url) => url,
484            None if repository.remote_url.contains("lore.portals.works") => {
485                // TODO(PORTALS-CLOUD-PRESIGN): Do not infer an HTTP origin from
486                // the production gRPC hostname until the separately reviewed
487                // 41339 target group, HMAC secret, and narrow path routes exist.
488                return Err(NapError::Other(
489                    "Portals Cloud presigned URLs are not enabled yet; production HTTP ingress is WIP. Supply --http-url or NAP_LORE_HTTP_URL only for an explicitly configured Lore HTTP endpoint."
490                        .to_string(),
491                ));
492            }
493            None if repository.remote_url.is_empty()
494                || repository.remote_url.contains("localhost")
495                || repository.remote_url.contains("127.0.0.1") =>
496            {
497                "http://127.0.0.1:41339".to_string()
498            }
499            None => {
500                return Err(NapError::Other(
501                    "Lore HTTP endpoint is not configured; set NAP_LORE_HTTP_URL or pass --http-url"
502                        .to_string(),
503                ));
504            }
505        };
506        let base_url = reqwest::Url::parse(&http_url)
507            .map_err(|e| NapError::Other(format!("invalid Lore HTTP URL: {e}")))?;
508        if !matches!(base_url.scheme(), "http" | "https")
509            || base_url.host_str().is_none()
510            || !base_url.username().is_empty()
511            || base_url.password().is_some()
512            || base_url.query().is_some()
513            || base_url.fragment().is_some()
514            || !matches!(base_url.path(), "" | "/")
515        {
516            return Err(NapError::Other(
517                "Lore HTTP URL must be an http(s) origin without credentials, path, query, or fragment"
518                    .to_string(),
519            ));
520        }
521
522        let endpoint_path = format!(
523            "/v1/repository/{}/content/{}/presign",
524            repository.id, address
525        );
526        let endpoint = base_url
527            .join(&endpoint_path)
528            .map_err(|e| NapError::Other(format!("failed to construct Lore presign URL: {e}")))?;
529        let bearer_token = options
530            .bearer_token
531            .clone()
532            .or_else(|| std::env::var("NAP_LORE_HTTP_TOKEN").ok())
533            .or_else(|| std::env::var("NAP_LORE_GRPC_TOKEN").ok());
534        let mut request = presign_http_client()?
535            .post(endpoint)
536            .json(&LorePresignRequest {
537                ttl_seconds: options.ttl_seconds,
538            });
539        if let Some(token) = bearer_token.filter(|token| !token.is_empty()) {
540            let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
541                .map_err(|_| {
542                    NapError::Other("bearer token is not a valid HTTP header value".to_string())
543                })?;
544            value.set_sensitive(true);
545            request = request.header(AUTHORIZATION, value);
546        }
547
548        let response = request
549            .send()
550            .await
551            .map_err(|e| NapError::Other(format!("Lore presign request failed: {e}")))?;
552        let (status, body) = read_bounded_response(response, 64 * 1024).await?;
553        if !status.is_success() {
554            let detail = String::from_utf8_lossy(&body);
555            let message = match status {
556                reqwest::StatusCode::UNAUTHORIZED => {
557                    "Lore rejected the request as unauthenticated; set NAP_LORE_HTTP_TOKEN (or NAP_LORE_GRPC_TOKEN) to a repository-scoped bearer token".to_string()
558                }
559                reqwest::StatusCode::FORBIDDEN => {
560                    "Lore denied permission to presign this repository representation".to_string()
561                }
562                reqwest::StatusCode::NOT_FOUND if detail.contains("not enabled") => {
563                    "Lore presigned URLs are disabled; configure server.http.presigned_url_hmac_key and restart Lore".to_string()
564                }
565                reqwest::StatusCode::NOT_FOUND => {
566                    "representation content is not available in the Lore remote; push the pinned revision before presigning".to_string()
567                }
568                _ => format!("Lore presign failed with HTTP {status}: {}", detail.trim()),
569            };
570            return Err(NapError::Other(message));
571        }
572        let response: LorePresignResponse = serde_json::from_slice(&body)
573            .map_err(|e| NapError::Other(format!("invalid Lore presign response: {e}")))?;
574        let expected_redeem_path = format!("/v1/presigned/{}/{}", repository.id, address);
575        let url = validate_presigned_url(&base_url, &response.url_suffix, &expected_redeem_path)?;
576
577        info!(
578            repository_id = %repository.id,
579            revision = %revision,
580            representation = %representation_name,
581            expires_at = response.expires_at,
582            "created Lore presigned representation URL"
583        );
584        Ok(PresignedRepresentation {
585            url: url.to_string(),
586            expires_at: response.expires_at,
587            revision,
588            repository_id: repository.id,
589            address,
590            representation: representation_name.to_string(),
591            format: representation.format.clone(),
592        })
593    }
594
595    /// Resolve a parsed NAP URI with options.
596    pub fn resolve_uri(
597        &self,
598        uri: &NapUri,
599        options: &ResolveOptions,
600    ) -> Result<ResolveResult, NapError> {
601        debug!(
602            uri = %uri,
603            options = ?options,
604            "resolving NAP URI"
605        );
606
607        let wants_provenance =
608            options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
609
610        // Handle recursive resolution. Provenance is intentionally scoped to the
611        // requested manifest and its direct representations, not related entities.
612        if options.recursive.unwrap_or(false) && !wants_provenance {
613            return self.resolve_uri_recursive(
614                uri,
615                options,
616                0,
617                &mut std::collections::HashSet::new(),
618            );
619        }
620
621        self.resolve_uri_single(uri, options)
622    }
623
624    /// Resolve a single URI without recursion.
625    fn resolve_uri_single(
626        &self,
627        uri: &NapUri,
628        options: &ResolveOptions,
629    ) -> Result<ResolveResult, NapError> {
630        let (repo, repo_config) = self.open_repo(&uri.repository)?;
631        let query_path = options.query_path(uri);
632
633        // ── 4-Rule Resolution ────────────────────────────────────────
634        // Rule 1: commit provided → use directly (bypass branch logic)
635        // Rule 2: branch provided, no commit → resolve branch head
636        // Rule 3: both null → use default_branch from repo config (fallback to global)
637        // Rule 4: both null and no default_branch → hard error (versioned only)
638        // In unversioned mode (no backend), resolving without a revision reads
639        // the current filesystem state; branch/commit selectors are
640        // unsatisfiable and produce a ResolutionFailed error.
641        // ──────────────────────────────────────────────────────────────
642
643        let unsatisfiable = |what: &str| NapError::ResolutionFailed {
644            address: uri.to_string(),
645            message: format!(
646                "cannot resolve {what}: no version-control backend is configured. \
647                     Configure one with 'nap backend configure' to use branch/commit selectors."
648            ),
649        };
650
651        let revision: Option<String> = match (options.commit.as_ref(), options.branch.as_ref()) {
652            (Some(commit), _) => {
653                debug!(%commit, "resolve: rule 1 — commit provided");
654                if repo.vcs().is_none() {
655                    return Err(unsatisfiable(&format!("at commit '{commit}'")));
656                }
657                Some(commit.clone())
658            }
659            (None, Some(branch)) => {
660                debug!(%branch, "resolve: rule 2 — branch provided");
661                let vcs = repo
662                    .vcs()
663                    .ok_or_else(|| unsatisfiable(&format!("at branch '{branch}'")))?;
664                Some(vcs.resolve_branch_head(&repo.root, branch)?)
665            }
666            (None, None) => {
667                let default_branch = repo_config
668                    .default_branch
669                    .as_ref()
670                    .or(self.config.default_branch.as_ref());
671                match default_branch {
672                    Some(default_branch) => {
673                        debug!(%default_branch, "resolve: rule 3 — using default_branch");
674                        let vcs = repo.vcs().ok_or_else(|| {
675                            unsatisfiable(&format!("at default branch '{default_branch}'"))
676                        })?;
677                        Some(vcs.resolve_branch_head(&repo.root, default_branch)?)
678                    }
679                    None if repo.vcs().is_some() => {
680                        debug!("resolve: rule 4 — no branch, no commit, no default_branch");
681                        return Err(NapError::NoDefaultBranch);
682                    }
683                    None => {
684                        debug!("resolve: unversioned — reading current filesystem state");
685                        None
686                    }
687                }
688            }
689        };
690
691        // Read the manifest at the resolved revision, or the current filesystem
692        // state when resolving without a revision (unversioned mode).
693        let manifest = match &revision {
694            Some(revision) => {
695                repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, revision)?
696            }
697            None => repo.read_manifest(&uri.entity_type, &uri.entity_id)?,
698        };
699
700        let wants_provenance =
701            options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
702        if wants_provenance {
703            if let Some(path) = query_path {
704                return Err(NapError::Other(format!(
705                    "provenance envelopes are only supported for full manifest resolution, not subtree query '{path}'"
706                )));
707            }
708
709            // Provenance is VCS-backed; it cannot be produced in unversioned mode.
710            let revision = revision
711                .as_deref()
712                .ok_or_else(|| NapError::BackendNotConfigured {
713                    operation: "provenance".to_string(),
714                })?;
715
716            let envelope = self.build_provenance_envelope(
717                &repo,
718                uri,
719                manifest,
720                revision,
721                options.include_blobs.unwrap_or(false),
722            )?;
723            info!(uri = %uri, "resolved NAP URI with provenance");
724            return Ok(ResolveResult::Provenance(Box::new(envelope)));
725        }
726
727        // Apply query if present
728        match query_path {
729            Some(ref path) => {
730                debug!(query_path = %path, "applying subtree query");
731                let yaml_value = manifest.to_value()?;
732                let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
733
734                // Convert YAML value to JSON for consistent API output
735                let json_str = serde_yaml::to_string(&result)
736                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
737                let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
738                    .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
739
740                info!(
741                    uri = %uri,
742                    query_path = %path,
743                    "resolved NAP URI with query"
744                );
745                Ok(ResolveResult::Subtree(json_value))
746            }
747            None => {
748                info!(uri = %uri, "resolved NAP URI (full manifest)");
749                Ok(ResolveResult::Full(Box::new(manifest)))
750            }
751        }
752    }
753
754    fn build_provenance_envelope(
755        &self,
756        repo: &Repository,
757        uri: &NapUri,
758        manifest: Manifest,
759        revision: &str,
760        include_blobs: bool,
761    ) -> Result<ResolveEnvelope, NapError> {
762        let manifest_path = uri.manifest_path();
763        let mut files = vec![self.build_provenance_file(
764            repo,
765            revision,
766            "manifest",
767            None,
768            Some(manifest_path.clone()),
769            None,
770            None,
771            None,
772            include_blobs,
773        )?];
774
775        for (name, representation) in &manifest.representations {
776            let resolved_path = representation
777                .uri
778                .as_deref()
779                .map(|representation_uri| {
780                    Self::resolve_representation_path(uri, representation_uri)
781                })
782                .transpose()?
783                .flatten();
784
785            files.push(self.build_provenance_file(
786                repo,
787                revision,
788                "representation",
789                Some(name.clone()),
790                resolved_path,
791                representation.uri.clone(),
792                Some(representation.hash.clone()),
793                Some(representation.format.clone()),
794                include_blobs,
795            )?);
796        }
797
798        Ok(ResolveEnvelope {
799            manifest: Box::new(manifest),
800            provenance: ResolveProvenanceEnvelope {
801                revision: revision.to_string(),
802                files,
803            },
804        })
805    }
806
807    #[allow(clippy::too_many_arguments)]
808    fn build_provenance_file(
809        &self,
810        repo: &Repository,
811        revision: &str,
812        role: &str,
813        name: Option<String>,
814        path: Option<String>,
815        uri: Option<String>,
816        hash: Option<String>,
817        format: Option<String>,
818        include_blobs: bool,
819    ) -> Result<ResolveProvenanceFile, NapError> {
820        // Provenance is VCS-backed; in unversioned mode there is nothing to read.
821        let vcs = repo.vcs().ok_or_else(|| NapError::BackendNotConfigured {
822            operation: "provenance".to_string(),
823        })?;
824
825        let metadata = match path.as_deref() {
826            Some(path) => vcs.file_metadata_at_ref(&repo.root, path, revision)?,
827            None => None,
828        };
829
830        let blobs = if include_blobs {
831            match metadata.as_ref() {
832                Some(metadata) => Self::hydrate_known_blobs(vcs, repo, metadata)?,
833                None => BTreeMap::new(),
834            }
835        } else {
836            BTreeMap::new()
837        };
838
839        let provenance = match metadata {
840            Some(metadata) => {
841                let condensed = Self::condense_metadata(metadata);
842                if condensed.is_empty() {
843                    serde_json::Value::String("none".to_string())
844                } else {
845                    serde_json::to_value(condensed).map_err(|e| {
846                        NapError::Other(format!("failed to serialize provenance metadata: {e}"))
847                    })?
848                }
849            }
850            None => serde_json::Value::String("none".to_string()),
851        };
852
853        Ok(ResolveProvenanceFile {
854            role: role.to_string(),
855            name,
856            path,
857            uri,
858            hash,
859            format,
860            provenance,
861            blobs,
862        })
863    }
864
865    fn condense_metadata(metadata: BTreeMap<String, String>) -> BTreeMap<String, String> {
866        metadata
867            .into_iter()
868            .filter(|(_, value)| value.len() <= MAX_CONDENSED_METADATA_VALUE_BYTES)
869            .collect()
870    }
871
872    fn hydrate_known_blobs(
873        vcs: &dyn VcsBackend,
874        repo: &Repository,
875        metadata: &BTreeMap<String, String>,
876    ) -> Result<BTreeMap<String, HydratedProvenanceBlob>, NapError> {
877        let known_blob_keys = [
878            ("prompt", "nap.provenance.prompt.address"),
879            ("run", "nap.provenance.run.address"),
880            ("parameters", "nap.provenance.parameters.address"),
881        ];
882
883        let mut blobs = BTreeMap::new();
884        for (name, metadata_key) in known_blob_keys {
885            let Some(address) = metadata.get(metadata_key) else {
886                continue;
887            };
888            let content = vcs.read_provenance_blob(&repo.root, address)?;
889            blobs.insert(name.to_string(), Self::truncate_blob(address, &content));
890        }
891        Ok(blobs)
892    }
893
894    fn truncate_blob(address: &str, content: &str) -> HydratedProvenanceBlob {
895        let original_bytes = content.len();
896        let mut included_bytes = 0;
897        let mut truncated_content = String::new();
898
899        for ch in content.chars() {
900            let next_len = included_bytes + ch.len_utf8();
901            if next_len > MAX_HYDRATED_BLOB_BYTES {
902                break;
903            }
904            truncated_content.push(ch);
905            included_bytes = next_len;
906        }
907
908        HydratedProvenanceBlob {
909            address: address.to_string(),
910            content: truncated_content,
911            truncated: included_bytes < original_bytes,
912            original_bytes,
913            included_bytes,
914        }
915    }
916
917    fn resolve_representation_path(
918        uri: &NapUri,
919        representation_uri: &str,
920    ) -> Result<Option<String>, NapError> {
921        if representation_uri.contains("://") {
922            return Ok(None);
923        }
924
925        let representation_path = Path::new(representation_uri);
926        if representation_path.is_absolute() {
927            return Err(NapError::InvalidQueryPath(format!(
928                "representation URI must be relative for provenance lookup: {representation_uri}"
929            )));
930        }
931
932        let mut clean = PathBuf::new();
933        for component in representation_path.components() {
934            match component {
935                Component::Normal(part) => clean.push(part),
936                Component::CurDir => {}
937                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
938                    return Err(NapError::InvalidQueryPath(format!(
939                        "unsafe representation URI for provenance lookup: {representation_uri}"
940                    )));
941                }
942            }
943        }
944
945        // Match nap add: representation URIs are relative to the entity's
946        // asset directory, including world entities whose manifest is at root.
947        let entity_dir = Path::new(uri.entity_type.as_str()).join(&uri.entity_id);
948        Ok(Some(Self::path_to_lore_path(&entity_dir.join(clean))))
949    }
950
951    fn path_to_lore_path(path: &Path) -> String {
952        path.components()
953            .filter_map(|component| match component {
954                Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
955                _ => None,
956            })
957            .collect::<Vec<_>>()
958            .join("/")
959    }
960
961    /// Resolve a URI recursively, following nested nap:// URIs.
962    fn resolve_uri_recursive(
963        &self,
964        uri: &NapUri,
965        options: &ResolveOptions,
966        depth: usize,
967        visited: &mut std::collections::HashSet<String>,
968    ) -> Result<ResolveResult, NapError> {
969        // Check depth limit
970        let max_depth = options.max_depth.unwrap_or(10);
971        if depth >= max_depth {
972            debug!(depth, max_depth, "reached maximum recursion depth");
973            return self.resolve_uri_single(uri, options);
974        }
975
976        // Check for circular references
977        let uri_str = uri.to_string();
978        if visited.contains(&uri_str) {
979            debug!(uri = %uri_str, "detected circular reference, stopping recursion");
980            return self.resolve_uri_single(uri, options);
981        }
982        visited.insert(uri_str.clone());
983
984        debug!(uri = %uri_str, depth, "recursively resolving URI");
985
986        // Resolve the current URI
987        let result = self.resolve_uri_single(uri, options)?;
988
989        // Extract nested URIs from the result and resolve them
990        match result {
991            ResolveResult::Full(manifest) => {
992                let nested_uris = self.extract_nested_uris(&manifest);
993                if nested_uris.is_empty() {
994                    debug!(uri = %uri_str, "no nested URIs found, returning manifest");
995                    return Ok(ResolveResult::Full(manifest));
996                }
997
998                debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
999
1000                // Resolve nested URIs and merge them into the result
1001                let mut resolved_manifest = (*manifest).clone();
1002                for nested_uri in nested_uris {
1003                    let nested_uri_parsed: NapUri = nested_uri.parse()?;
1004
1005                    let nested_result = self
1006                        .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
1007                        .map_err(|e| {
1008                            NapError::Other(format!(
1009                                "failed to resolve nested URI '{}' while resolving '{}': {}",
1010                                nested_uri, uri_str, e
1011                            ))
1012                        })?;
1013
1014                    if let ResolveResult::Full(nested_manifest) = nested_result {
1015                        // Merge nested manifest into parent (simple merge for now)
1016                        // In the future, this could be more sophisticated based on schema
1017                        for (key, value) in nested_manifest.properties {
1018                            resolved_manifest.properties.insert(key, value);
1019                        }
1020                    }
1021                }
1022
1023                Ok(ResolveResult::Full(Box::new(resolved_manifest)))
1024            }
1025            ResolveResult::Subtree(value) => {
1026                // For subtree queries, we don't recurse (would be complex to merge)
1027                debug!("subtree query, skipping recursive resolution");
1028                Ok(ResolveResult::Subtree(value))
1029            }
1030            ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
1031        }
1032    }
1033
1034    /// Extract all nap:// URIs from a manifest.
1035    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
1036        let mut uris = Vec::new();
1037
1038        // Search in properties
1039        for value in manifest.properties.values() {
1040            self.extract_uris_from_yaml_value(value, &mut uris);
1041        }
1042
1043        // Search in references
1044        for value in manifest.references.values() {
1045            self.extract_uris_from_yaml_value(value, &mut uris);
1046        }
1047
1048        // Deduplicate URIs to avoid resolving the same URI multiple times
1049        uris.sort();
1050        uris.dedup();
1051        uris
1052    }
1053
1054    /// Recursively extract nap:// URIs from YAML values.
1055    fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
1056        match value {
1057            serde_yaml::Value::String(s) if s.starts_with("nap://") => {
1058                uris.push(s.clone());
1059            }
1060            serde_yaml::Value::Sequence(seq) => {
1061                for item in seq {
1062                    self.extract_uris_from_yaml_value(item, uris);
1063                }
1064            }
1065            serde_yaml::Value::Mapping(map) => {
1066                for (_, v) in map {
1067                    self.extract_uris_from_yaml_value(v, uris);
1068                }
1069            }
1070            _ => {}
1071        }
1072    }
1073
1074    /// Convenience: query a specific path on a URI.
1075    pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
1076        let options = ResolveOptions {
1077            path: Some(path.to_string()),
1078            ..Default::default()
1079        };
1080        match self.resolve(uri_str, &options)? {
1081            ResolveResult::Subtree(v) => Ok(v),
1082            ResolveResult::Full(m) => m.to_json_value(),
1083            ResolveResult::Provenance(envelope) => serde_json::to_value(envelope).map_err(|e| {
1084                NapError::Other(format!("failed to serialize provenance envelope: {e}"))
1085            }),
1086        }
1087    }
1088
1089    /// List all repositories available.
1090    pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
1091        let mut repositories = Vec::new();
1092        for entry in std::fs::read_dir(&self.base_path)? {
1093            let entry = entry?;
1094            let path = entry.path();
1095            // Check for repository.yaml or repository.yaml to identify valid repositories
1096            if path.is_dir()
1097                && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
1098                && let Some(name) = path.file_name().and_then(|n| n.to_str())
1099            {
1100                repositories.push(name.to_string());
1101            }
1102        }
1103        repositories.sort();
1104        Ok(repositories)
1105    }
1106}
1107
1108#[cfg(test)]
1109mod unit_tests {
1110    use super::*;
1111    use crate::manifest::Representation;
1112    use crate::test_utils::MockBackend;
1113    use crate::types::EntityType;
1114    use tempfile::TempDir;
1115
1116    fn setup() -> (TempDir, Resolver) {
1117        let tmp = TempDir::new().unwrap();
1118        let repo_path = tmp.path().join("toystory");
1119        let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1120
1121        // Create a character
1122        let (mut manifest, _) = repo
1123            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1124            .unwrap();
1125
1126        // Add properties and commit
1127        manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1128        manifest.set_property(
1129            "homeworld",
1130            serde_yaml::Value::String("nap://toystory/location/andys-room".to_string()),
1131        );
1132        manifest.add_reference(
1133            "appears_in",
1134            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
1135                "nap://toystory/scene/pizza-planet".to_string(),
1136            )]),
1137        );
1138        manifest.set_representation(
1139            "face_image",
1140            Representation {
1141                hash: "blake3:9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a"
1142                    .to_string(),
1143                format: "png".to_string(),
1144                uri: Some("face_image.png".to_string()),
1145                tier: None,
1146            },
1147        );
1148
1149        use crate::commit::Change;
1150        repo.commit_manifest(
1151            &mut manifest,
1152            "add Woody details",
1153            "test",
1154            vec![Change::set(
1155                "properties.toy_type",
1156                None,
1157                "plush".to_string(),
1158            )],
1159        )
1160        .unwrap();
1161
1162        let resolver = Resolver::with_vcs_factory(
1163            tmp.path(),
1164            || Box::new(MockBackend::new()),
1165            ResolveConfig {
1166                default_branch: Some("main".to_string()),
1167            },
1168        );
1169        (tmp, resolver)
1170    }
1171
1172    #[test]
1173    fn test_resolve_full_manifest() {
1174        let (_tmp, resolver) = setup();
1175        let result = resolver
1176            .resolve("nap://toystory/character/woody", &Default::default())
1177            .unwrap();
1178        match result {
1179            ResolveResult::Full(m) => {
1180                assert_eq!(m.name, "Woody");
1181                assert_eq!(m.entity_type.as_str(), "character");
1182            }
1183            _ => panic!("expected full manifest"),
1184        }
1185    }
1186
1187    fn write_mock_metadata(repo_path: &Path, metadata: BTreeMap<String, BTreeMap<String, String>>) {
1188        std::fs::write(
1189            repo_path.join(".mock_file_metadata.json"),
1190            serde_json::to_string(&metadata).unwrap(),
1191        )
1192        .unwrap();
1193    }
1194
1195    fn write_mock_blobs(repo_path: &Path, blobs: BTreeMap<String, String>) {
1196        std::fs::write(
1197            repo_path.join(".mock_provenance_blobs.json"),
1198            serde_json::to_string(&blobs).unwrap(),
1199        )
1200        .unwrap();
1201    }
1202
1203    fn resolve_with_provenance(resolver: &Resolver) -> ResolveEnvelope {
1204        let result = resolver
1205            .resolve(
1206                "nap://toystory/character/woody",
1207                &ResolveOptions {
1208                    provenance: Some(true),
1209                    ..Default::default()
1210                },
1211            )
1212            .unwrap();
1213        match result {
1214            ResolveResult::Provenance(envelope) => *envelope,
1215            _ => panic!("expected provenance envelope"),
1216        }
1217    }
1218
1219    #[test]
1220    fn test_resolve_with_provenance_returns_manifest_and_direct_file_entries() {
1221        let (tmp, resolver) = setup();
1222        let repo_path = tmp.path().join("toystory");
1223        write_mock_metadata(
1224            &repo_path,
1225            BTreeMap::from([
1226                (
1227                    "character/woody.yaml".to_string(),
1228                    BTreeMap::from([
1229                        ("nap.provenance.kind".to_string(), "edit".to_string()),
1230                        ("nap.provenance.model".to_string(), "gpt-5".to_string()),
1231                        (
1232                            "nap.provenance.long".to_string(),
1233                            "x".repeat(MAX_CONDENSED_METADATA_VALUE_BYTES + 1),
1234                        ),
1235                    ]),
1236                ),
1237                (
1238                    "character/woody/face_image.png".to_string(),
1239                    BTreeMap::from([("nap.provenance.kind".to_string(), "generation".to_string())]),
1240                ),
1241            ]),
1242        );
1243
1244        let envelope = resolve_with_provenance(&resolver);
1245        assert_eq!(envelope.manifest.name, "Woody");
1246        assert_eq!(envelope.provenance.files.len(), 2);
1247
1248        let manifest_file = &envelope.provenance.files[0];
1249        assert_eq!(manifest_file.role, "manifest");
1250        assert_eq!(manifest_file.path.as_deref(), Some("character/woody.yaml"));
1251        assert_eq!(manifest_file.provenance["nap.provenance.kind"], "edit");
1252        assert!(
1253            manifest_file
1254                .provenance
1255                .get("nap.provenance.long")
1256                .is_none()
1257        );
1258
1259        let representation_file = &envelope.provenance.files[1];
1260        assert_eq!(representation_file.role, "representation");
1261        assert_eq!(representation_file.name.as_deref(), Some("face_image"));
1262        assert_eq!(
1263            representation_file.path.as_deref(),
1264            Some("character/woody/face_image.png")
1265        );
1266        assert_eq!(representation_file.uri.as_deref(), Some("face_image.png"));
1267        assert_eq!(representation_file.format.as_deref(), Some("png"));
1268    }
1269
1270    #[test]
1271    fn test_resolve_with_provenance_records_path_and_revision_metadata_lookups() {
1272        let (tmp, resolver) = setup();
1273        let repo_path = tmp.path().join("toystory");
1274        let envelope = resolve_with_provenance(&resolver);
1275
1276        let requests: Vec<BTreeMap<String, String>> = serde_json::from_str(
1277            &std::fs::read_to_string(repo_path.join(".mock_metadata_requests.json")).unwrap(),
1278        )
1279        .unwrap();
1280        assert_eq!(requests.len(), 2);
1281        assert_eq!(requests[0].get("path").unwrap(), "character/woody.yaml");
1282        assert_eq!(
1283            requests[0].get("revision").unwrap(),
1284            &envelope.provenance.revision
1285        );
1286        assert_eq!(
1287            requests[1].get("path").unwrap(),
1288            "character/woody/face_image.png"
1289        );
1290        assert_eq!(
1291            requests[1].get("revision").unwrap(),
1292            &envelope.provenance.revision
1293        );
1294        assert!(!requests.iter().any(|request| {
1295            request
1296                .get("path")
1297                .is_some_and(|path| path.starts_with("blake3:"))
1298        }));
1299    }
1300
1301    #[test]
1302    fn test_resolve_with_provenance_uses_none_for_missing_metadata() {
1303        let (_tmp, resolver) = setup();
1304        let envelope = resolve_with_provenance(&resolver);
1305        assert_eq!(envelope.provenance.files[0].provenance, "none");
1306        assert_eq!(envelope.provenance.files[1].provenance, "none");
1307    }
1308
1309    #[test]
1310    fn test_resolve_with_include_blobs_hydrates_known_readable_artifacts() {
1311        let (tmp, resolver) = setup();
1312        let repo_path = tmp.path().join("toystory");
1313        write_mock_metadata(
1314            &repo_path,
1315            BTreeMap::from([(
1316                "character/woody.yaml".to_string(),
1317                BTreeMap::from([
1318                    (
1319                        "nap.provenance.prompt.address".to_string(),
1320                        "lore:prompt:1".to_string(),
1321                    ),
1322                    (
1323                        "unrelated.artifact.address".to_string(),
1324                        "lore:binary:1".to_string(),
1325                    ),
1326                ]),
1327            )]),
1328        );
1329        write_mock_blobs(
1330            &repo_path,
1331            BTreeMap::from([("lore:prompt:1".to_string(), "Describe Woody".to_string())]),
1332        );
1333
1334        let result = resolver
1335            .resolve(
1336                "nap://toystory/character/woody",
1337                &ResolveOptions {
1338                    provenance: Some(true),
1339                    include_blobs: Some(true),
1340                    ..Default::default()
1341                },
1342            )
1343            .unwrap();
1344        let ResolveResult::Provenance(envelope) = result else {
1345            panic!("expected provenance envelope");
1346        };
1347
1348        let blobs = &envelope.provenance.files[0].blobs;
1349        assert_eq!(blobs.len(), 1);
1350        assert_eq!(blobs["prompt"].content, "Describe Woody");
1351        assert!(!blobs["prompt"].truncated);
1352    }
1353
1354    #[test]
1355    fn test_include_blobs_implies_provenance_envelope() {
1356        let (_tmp, resolver) = setup();
1357        let result = resolver
1358            .resolve(
1359                "nap://toystory/character/woody",
1360                &ResolveOptions {
1361                    include_blobs: Some(true),
1362                    ..Default::default()
1363                },
1364            )
1365            .unwrap();
1366        assert!(matches!(result, ResolveResult::Provenance(_)));
1367    }
1368
1369    #[test]
1370    fn test_resolve_with_include_blobs_truncates_readable_artifacts() {
1371        let (tmp, resolver) = setup();
1372        let repo_path = tmp.path().join("toystory");
1373        write_mock_metadata(
1374            &repo_path,
1375            BTreeMap::from([(
1376                "character/woody.yaml".to_string(),
1377                BTreeMap::from([(
1378                    "nap.provenance.prompt.address".to_string(),
1379                    "lore:prompt:large".to_string(),
1380                )]),
1381            )]),
1382        );
1383        write_mock_blobs(
1384            &repo_path,
1385            BTreeMap::from([(
1386                "lore:prompt:large".to_string(),
1387                "x".repeat(MAX_HYDRATED_BLOB_BYTES + 10),
1388            )]),
1389        );
1390
1391        let result = resolver
1392            .resolve(
1393                "nap://toystory/character/woody",
1394                &ResolveOptions {
1395                    provenance: Some(true),
1396                    include_blobs: Some(true),
1397                    ..Default::default()
1398                },
1399            )
1400            .unwrap();
1401        let ResolveResult::Provenance(envelope) = result else {
1402            panic!("expected provenance envelope");
1403        };
1404        let blob = &envelope.provenance.files[0].blobs["prompt"];
1405        assert!(blob.truncated);
1406        assert_eq!(blob.original_bytes, MAX_HYDRATED_BLOB_BYTES + 10);
1407        assert_eq!(blob.included_bytes, MAX_HYDRATED_BLOB_BYTES);
1408        assert_eq!(blob.content.len(), MAX_HYDRATED_BLOB_BYTES);
1409    }
1410
1411    #[test]
1412    fn test_provenance_rejects_unsafe_representation_paths() {
1413        let tmp = TempDir::new().unwrap();
1414        let repo_path = tmp.path().join("toystory");
1415        let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1416        let (mut manifest, _) = repo
1417            .create_entity(&EntityType::new("character"), "jessie", "Jessie", "test")
1418            .unwrap();
1419        manifest.set_representation(
1420            "unsafe",
1421            Representation {
1422                hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1423                    .to_string(),
1424                format: "png".to_string(),
1425                uri: Some("../secret.png".to_string()),
1426                tier: None,
1427            },
1428        );
1429        use crate::commit::Change;
1430        repo.commit_manifest(
1431            &mut manifest,
1432            "add unsafe representation",
1433            "test",
1434            vec![Change::set(
1435                "representations.unsafe",
1436                None,
1437                "unsafe".to_string(),
1438            )],
1439        )
1440        .unwrap();
1441
1442        let resolver = Resolver::with_vcs_factory(
1443            tmp.path(),
1444            || Box::new(MockBackend::new()),
1445            ResolveConfig {
1446                default_branch: Some("main".to_string()),
1447            },
1448        );
1449        let err = resolver
1450            .resolve(
1451                "nap://toystory/character/jessie",
1452                &ResolveOptions {
1453                    provenance: Some(true),
1454                    ..Default::default()
1455                },
1456            )
1457            .unwrap_err();
1458        assert!(err.to_string().contains("unsafe representation URI"));
1459    }
1460
1461    #[test]
1462    fn test_resolve_with_fragment() {
1463        let (_tmp, resolver) = setup();
1464        let result = resolver
1465            .resolve(
1466                "nap://toystory/character/woody#properties.toy_type",
1467                &Default::default(),
1468            )
1469            .unwrap();
1470        match result {
1471            ResolveResult::Subtree(v) => {
1472                assert_eq!(v.as_str(), Some("plush"));
1473            }
1474            _ => panic!("expected subtree"),
1475        }
1476    }
1477
1478    #[test]
1479    fn test_resolve_with_options_path() {
1480        let (_tmp, resolver) = setup();
1481        let result = resolver
1482            .resolve(
1483                "nap://toystory/character/woody",
1484                &ResolveOptions {
1485                    path: Some("properties.homeworld".to_string()),
1486                    ..Default::default()
1487                },
1488            )
1489            .unwrap();
1490        match result {
1491            ResolveResult::Subtree(v) => {
1492                assert_eq!(v.as_str(), Some("nap://toystory/location/andys-room"));
1493            }
1494            _ => panic!("expected subtree"),
1495        }
1496    }
1497
1498    #[test]
1499    fn test_query_convenience() {
1500        let (_tmp, resolver) = setup();
1501        let result = resolver
1502            .query("nap://toystory/character/woody", "properties.toy_type")
1503            .unwrap();
1504        assert_eq!(result.as_str(), Some("plush"));
1505    }
1506
1507    #[test]
1508    fn test_list_repositories() {
1509        let (_tmp, resolver) = setup();
1510        let repositories = resolver.list_repositories().unwrap();
1511        assert!(repositories.contains(&"toystory".to_string()));
1512    }
1513
1514    #[test]
1515    fn test_resolve_not_found() {
1516        let (_tmp, resolver) = setup();
1517        let result = resolver.resolve("nap://toystory/character/nonexistent", &Default::default());
1518        assert!(result.is_err());
1519    }
1520
1521    #[test]
1522    fn test_resolve_without_scheme() {
1523        let (_tmp, resolver) = setup();
1524        let result = resolver
1525            .resolve("toystory/character/woody", &Default::default())
1526            .unwrap();
1527        match result {
1528            ResolveResult::Full(m) => {
1529                assert_eq!(m.name, "Woody");
1530                assert_eq!(m.entity_type.as_str(), "character");
1531            }
1532            _ => panic!("expected full manifest"),
1533        }
1534    }
1535
1536    #[test]
1537    fn test_resolve_without_scheme_with_fragment() {
1538        let (_tmp, resolver) = setup();
1539        let result = resolver
1540            .resolve(
1541                "toystory/character/woody#properties.toy_type",
1542                &Default::default(),
1543            )
1544            .unwrap();
1545        match result {
1546            ResolveResult::Subtree(v) => {
1547                assert_eq!(v.as_str(), Some("plush"));
1548            }
1549            _ => panic!("expected subtree"),
1550        }
1551    }
1552
1553    #[test]
1554    fn test_resolve_without_leading_slash() {
1555        let (_tmp, resolver) = setup();
1556        let result = resolver
1557            .resolve("toystory/character/woody", &Default::default())
1558            .unwrap();
1559        match result {
1560            ResolveResult::Full(m) => {
1561                assert_eq!(m.name, "Woody");
1562            }
1563            _ => panic!("expected full manifest"),
1564        }
1565    }
1566
1567    #[test]
1568    fn test_resolve_with_leading_slash_without_scheme() {
1569        let (_tmp, resolver) = setup();
1570        let result = resolver
1571            .resolve("/toystory/character/woody", &Default::default())
1572            .unwrap();
1573        match result {
1574            ResolveResult::Full(m) => {
1575                assert_eq!(m.name, "Woody");
1576            }
1577            _ => panic!("expected full manifest"),
1578        }
1579    }
1580
1581    #[test]
1582    fn presign_debug_output_redacts_secrets() {
1583        let options = PresignOptions {
1584            bearer_token: Some("secret-token".to_string()),
1585            ..Default::default()
1586        };
1587        let rendered = format!("{options:?}");
1588        assert!(rendered.contains("<redacted>"));
1589        assert!(!rendered.contains("secret-token"));
1590
1591        let result = PresignedRepresentation {
1592            url: "https://example.test/v1/presigned/x?token=secret".to_string(),
1593            expires_at: 1,
1594            revision: "revision".to_string(),
1595            repository_id: "repository".to_string(),
1596            address: "address".to_string(),
1597            representation: "face_image".to_string(),
1598            format: "png".to_string(),
1599        };
1600        assert!(!format!("{result:?}").contains("token=secret"));
1601    }
1602
1603    #[test]
1604    fn presigned_url_validation_rejects_cross_origin_and_extra_query_data() {
1605        let base = reqwest::Url::parse("https://lore.example.test").unwrap();
1606        let path = "/v1/presigned/repository/address";
1607        assert!(validate_presigned_url(&base, "//evil.test/x?token=x", path).is_err());
1608        assert!(
1609            validate_presigned_url(
1610                &base,
1611                "/v1/presigned/repository/address?token=x&redirect=https://evil.test",
1612                path,
1613            )
1614            .is_err()
1615        );
1616        assert!(
1617            validate_presigned_url(&base, "/v1/presigned/repository/address?token=opaque", path,)
1618                .is_ok()
1619        );
1620    }
1621
1622    #[tokio::test]
1623    async fn presign_uses_repository_id_and_file_context_separately() {
1624        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1625
1626        let (_tmp, resolver) = setup();
1627        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1628        let address = listener.local_addr().unwrap();
1629        let server = tokio::spawn(async move {
1630            let (mut stream, _) = listener.accept().await.unwrap();
1631            let mut request = vec![0_u8; 8192];
1632            let read = stream.read(&mut request).await.unwrap();
1633            let request = String::from_utf8_lossy(&request[..read]);
1634            assert!(request.contains(
1635                "POST /v1/repository/0123456789abcdef0123456789abcdef/content/9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-fedcba9876543210fedcba9876543210/presign"
1636            ));
1637            assert!(request.contains("authorization: Bearer test-token"));
1638            assert!(request.contains("\"ttl_seconds\":90"));
1639            let body = concat!(
1640                "{\"url_suffix\":\"/v1/presigned/0123456789abcdef0123456789abcdef/",
1641                "9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-",
1642                "fedcba9876543210fedcba9876543210?token=opaque\",\"expires_at\":12345}"
1643            );
1644            let response = format!(
1645                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1646                body.len(),
1647                body
1648            );
1649            stream.write_all(response.as_bytes()).await.unwrap();
1650        });
1651
1652        let result = resolver
1653            .presign_representation(
1654                "toystory/character/woody",
1655                "face_image",
1656                &PresignOptions {
1657                    ttl_seconds: Some(90),
1658                    lore_http_url: Some(format!("http://{address}")),
1659                    bearer_token: Some("test-token".to_string()),
1660                    ..Default::default()
1661                },
1662            )
1663            .await
1664            .unwrap();
1665        server.await.unwrap();
1666        assert_eq!(result.expires_at, 12345);
1667        assert_eq!(result.repository_id, "0123456789abcdef0123456789abcdef");
1668        assert!(result.url.ends_with("?token=opaque"));
1669    }
1670
1671    #[test]
1672    fn representation_paths_use_the_entity_asset_directory() {
1673        for (entity, asset, expected) in [
1674            (
1675                "nap://25th-chapter/character/nathan-gunn",
1676                "item.jpg",
1677                "character/nathan-gunn/item.jpg",
1678            ),
1679            (
1680                "nap://25th-chapter/world/25th-chapter",
1681                "images/map.png",
1682                "world/25th-chapter/images/map.png",
1683            ),
1684        ] {
1685            let uri: NapUri = entity.parse().unwrap();
1686            assert_eq!(
1687                Resolver::resolve_representation_path(&uri, asset).unwrap(),
1688                Some(expected.to_string()),
1689            );
1690        }
1691    }
1692
1693    #[tokio::test]
1694    async fn presign_rejects_fragment_and_conflicting_revision_selectors() {
1695        let (_tmp, resolver) = setup();
1696        assert!(
1697            resolver
1698                .presign_representation(
1699                    "nap://toystory/character/woody#properties",
1700                    "face_image",
1701                    &PresignOptions::default(),
1702                )
1703                .await
1704                .is_err()
1705        );
1706        assert!(
1707            resolver
1708                .presign_representation(
1709                    "nap://toystory/character/woody",
1710                    "face_image",
1711                    &PresignOptions {
1712                        branch: Some("main".to_string()),
1713                        commit: Some("abc".to_string()),
1714                        ..Default::default()
1715                    },
1716                )
1717                .await
1718                .unwrap_err()
1719                .to_string()
1720                .contains("either branch or commit")
1721        );
1722    }
1723}
1724
1725#[cfg(all(test, feature = "lore-integration"))]
1726mod lore_tests {
1727    use super::*;
1728    use crate::types::EntityType;
1729    use crate::vcs_lore::LoreBackend;
1730    use std::time::{SystemTime, UNIX_EPOCH};
1731    use tempfile::TempDir;
1732
1733    fn unique_suffix() -> u64 {
1734        SystemTime::now()
1735            .duration_since(UNIX_EPOCH)
1736            .unwrap()
1737            .as_nanos() as u64
1738    }
1739
1740    fn setup_lore() -> (TempDir, Resolver, String) {
1741        let repository = format!("lr-{}", unique_suffix());
1742        let tmp = TempDir::new().unwrap();
1743        let repo_path = tmp.path().join(&repository);
1744        let repo =
1745            Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
1746
1747        // Create a character
1748        let (mut manifest, _) = repo
1749            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1750            .unwrap();
1751
1752        // Add properties and commit
1753        manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1754        use crate::commit::Change;
1755        repo.commit_manifest(
1756            &mut manifest,
1757            "add Woody details",
1758            "test",
1759            vec![Change::set(
1760                "properties.toy_type",
1761                None,
1762                "plush".to_string(),
1763            )],
1764        )
1765        .unwrap();
1766
1767        let resolver = Resolver::with_vcs_factory(
1768            tmp.path(),
1769            || Box::new(LoreBackend::from_env()),
1770            ResolveConfig {
1771                default_branch: Some("main".to_string()),
1772            },
1773        );
1774        (tmp, resolver, repository)
1775    }
1776
1777    #[test]
1778    fn test_resolve_lore_full_manifest() {
1779        let (_tmp, resolver, repository) = setup_lore();
1780        let uri = format!("nap://{}/character/woody", repository);
1781        let result = resolver.resolve(&uri, &Default::default()).unwrap();
1782        match result {
1783            ResolveResult::Full(m) => {
1784                assert_eq!(m.name, "Woody");
1785            }
1786            _ => panic!("expected full manifest"),
1787        }
1788    }
1789}