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.manifest_path(), 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(&manifest_path, 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        manifest_path: &str,
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        let manifest_dir = Path::new(manifest_path).parent().unwrap_or(Path::new(""));
946        Ok(Some(Self::path_to_lore_path(&manifest_dir.join(clean))))
947    }
948
949    fn path_to_lore_path(path: &Path) -> String {
950        path.components()
951            .filter_map(|component| match component {
952                Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
953                _ => None,
954            })
955            .collect::<Vec<_>>()
956            .join("/")
957    }
958
959    /// Resolve a URI recursively, following nested nap:// URIs.
960    fn resolve_uri_recursive(
961        &self,
962        uri: &NapUri,
963        options: &ResolveOptions,
964        depth: usize,
965        visited: &mut std::collections::HashSet<String>,
966    ) -> Result<ResolveResult, NapError> {
967        // Check depth limit
968        let max_depth = options.max_depth.unwrap_or(10);
969        if depth >= max_depth {
970            debug!(depth, max_depth, "reached maximum recursion depth");
971            return self.resolve_uri_single(uri, options);
972        }
973
974        // Check for circular references
975        let uri_str = uri.to_string();
976        if visited.contains(&uri_str) {
977            debug!(uri = %uri_str, "detected circular reference, stopping recursion");
978            return self.resolve_uri_single(uri, options);
979        }
980        visited.insert(uri_str.clone());
981
982        debug!(uri = %uri_str, depth, "recursively resolving URI");
983
984        // Resolve the current URI
985        let result = self.resolve_uri_single(uri, options)?;
986
987        // Extract nested URIs from the result and resolve them
988        match result {
989            ResolveResult::Full(manifest) => {
990                let nested_uris = self.extract_nested_uris(&manifest);
991                if nested_uris.is_empty() {
992                    debug!(uri = %uri_str, "no nested URIs found, returning manifest");
993                    return Ok(ResolveResult::Full(manifest));
994                }
995
996                debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
997
998                // Resolve nested URIs and merge them into the result
999                let mut resolved_manifest = (*manifest).clone();
1000                for nested_uri in nested_uris {
1001                    let nested_uri_parsed: NapUri = nested_uri.parse()?;
1002
1003                    let nested_result = self
1004                        .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
1005                        .map_err(|e| {
1006                            NapError::Other(format!(
1007                                "failed to resolve nested URI '{}' while resolving '{}': {}",
1008                                nested_uri, uri_str, e
1009                            ))
1010                        })?;
1011
1012                    if let ResolveResult::Full(nested_manifest) = nested_result {
1013                        // Merge nested manifest into parent (simple merge for now)
1014                        // In the future, this could be more sophisticated based on schema
1015                        for (key, value) in nested_manifest.properties {
1016                            resolved_manifest.properties.insert(key, value);
1017                        }
1018                    }
1019                }
1020
1021                Ok(ResolveResult::Full(Box::new(resolved_manifest)))
1022            }
1023            ResolveResult::Subtree(value) => {
1024                // For subtree queries, we don't recurse (would be complex to merge)
1025                debug!("subtree query, skipping recursive resolution");
1026                Ok(ResolveResult::Subtree(value))
1027            }
1028            ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
1029        }
1030    }
1031
1032    /// Extract all nap:// URIs from a manifest.
1033    fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
1034        let mut uris = Vec::new();
1035
1036        // Search in properties
1037        for value in manifest.properties.values() {
1038            self.extract_uris_from_yaml_value(value, &mut uris);
1039        }
1040
1041        // Search in references
1042        for value in manifest.references.values() {
1043            self.extract_uris_from_yaml_value(value, &mut uris);
1044        }
1045
1046        // Deduplicate URIs to avoid resolving the same URI multiple times
1047        uris.sort();
1048        uris.dedup();
1049        uris
1050    }
1051
1052    /// Recursively extract nap:// URIs from YAML values.
1053    fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
1054        match value {
1055            serde_yaml::Value::String(s) if s.starts_with("nap://") => {
1056                uris.push(s.clone());
1057            }
1058            serde_yaml::Value::Sequence(seq) => {
1059                for item in seq {
1060                    self.extract_uris_from_yaml_value(item, uris);
1061                }
1062            }
1063            serde_yaml::Value::Mapping(map) => {
1064                for (_, v) in map {
1065                    self.extract_uris_from_yaml_value(v, uris);
1066                }
1067            }
1068            _ => {}
1069        }
1070    }
1071
1072    /// Convenience: query a specific path on a URI.
1073    pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
1074        let options = ResolveOptions {
1075            path: Some(path.to_string()),
1076            ..Default::default()
1077        };
1078        match self.resolve(uri_str, &options)? {
1079            ResolveResult::Subtree(v) => Ok(v),
1080            ResolveResult::Full(m) => m.to_json_value(),
1081            ResolveResult::Provenance(envelope) => serde_json::to_value(envelope).map_err(|e| {
1082                NapError::Other(format!("failed to serialize provenance envelope: {e}"))
1083            }),
1084        }
1085    }
1086
1087    /// List all repositories available.
1088    pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
1089        let mut repositories = Vec::new();
1090        for entry in std::fs::read_dir(&self.base_path)? {
1091            let entry = entry?;
1092            let path = entry.path();
1093            // Check for repository.yaml or repository.yaml to identify valid repositories
1094            if path.is_dir()
1095                && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
1096                && let Some(name) = path.file_name().and_then(|n| n.to_str())
1097            {
1098                repositories.push(name.to_string());
1099            }
1100        }
1101        repositories.sort();
1102        Ok(repositories)
1103    }
1104}
1105
1106#[cfg(test)]
1107mod unit_tests {
1108    use super::*;
1109    use crate::manifest::Representation;
1110    use crate::test_utils::MockBackend;
1111    use crate::types::EntityType;
1112    use tempfile::TempDir;
1113
1114    fn setup() -> (TempDir, Resolver) {
1115        let tmp = TempDir::new().unwrap();
1116        let repo_path = tmp.path().join("toystory");
1117        let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1118
1119        // Create a character
1120        let (mut manifest, _) = repo
1121            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1122            .unwrap();
1123
1124        // Add properties and commit
1125        manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1126        manifest.set_property(
1127            "homeworld",
1128            serde_yaml::Value::String("nap://toystory/location/andys-room".to_string()),
1129        );
1130        manifest.add_reference(
1131            "appears_in",
1132            serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
1133                "nap://toystory/scene/pizza-planet".to_string(),
1134            )]),
1135        );
1136        manifest.set_representation(
1137            "face_image",
1138            Representation {
1139                hash: "blake3:9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a"
1140                    .to_string(),
1141                format: "png".to_string(),
1142                uri: Some("face_image.png".to_string()),
1143                tier: None,
1144            },
1145        );
1146
1147        use crate::commit::Change;
1148        repo.commit_manifest(
1149            &mut manifest,
1150            "add Woody details",
1151            "test",
1152            vec![Change::set(
1153                "properties.toy_type",
1154                None,
1155                "plush".to_string(),
1156            )],
1157        )
1158        .unwrap();
1159
1160        let resolver = Resolver::with_vcs_factory(
1161            tmp.path(),
1162            || Box::new(MockBackend::new()),
1163            ResolveConfig {
1164                default_branch: Some("main".to_string()),
1165            },
1166        );
1167        (tmp, resolver)
1168    }
1169
1170    #[test]
1171    fn test_resolve_full_manifest() {
1172        let (_tmp, resolver) = setup();
1173        let result = resolver
1174            .resolve("nap://toystory/character/woody", &Default::default())
1175            .unwrap();
1176        match result {
1177            ResolveResult::Full(m) => {
1178                assert_eq!(m.name, "Woody");
1179                assert_eq!(m.entity_type.as_str(), "character");
1180            }
1181            _ => panic!("expected full manifest"),
1182        }
1183    }
1184
1185    fn write_mock_metadata(repo_path: &Path, metadata: BTreeMap<String, BTreeMap<String, String>>) {
1186        std::fs::write(
1187            repo_path.join(".mock_file_metadata.json"),
1188            serde_json::to_string(&metadata).unwrap(),
1189        )
1190        .unwrap();
1191    }
1192
1193    fn write_mock_blobs(repo_path: &Path, blobs: BTreeMap<String, String>) {
1194        std::fs::write(
1195            repo_path.join(".mock_provenance_blobs.json"),
1196            serde_json::to_string(&blobs).unwrap(),
1197        )
1198        .unwrap();
1199    }
1200
1201    fn resolve_with_provenance(resolver: &Resolver) -> ResolveEnvelope {
1202        let result = resolver
1203            .resolve(
1204                "nap://toystory/character/woody",
1205                &ResolveOptions {
1206                    provenance: Some(true),
1207                    ..Default::default()
1208                },
1209            )
1210            .unwrap();
1211        match result {
1212            ResolveResult::Provenance(envelope) => *envelope,
1213            _ => panic!("expected provenance envelope"),
1214        }
1215    }
1216
1217    #[test]
1218    fn test_resolve_with_provenance_returns_manifest_and_direct_file_entries() {
1219        let (tmp, resolver) = setup();
1220        let repo_path = tmp.path().join("toystory");
1221        write_mock_metadata(
1222            &repo_path,
1223            BTreeMap::from([
1224                (
1225                    "character/woody.yaml".to_string(),
1226                    BTreeMap::from([
1227                        ("nap.provenance.kind".to_string(), "edit".to_string()),
1228                        ("nap.provenance.model".to_string(), "gpt-5".to_string()),
1229                        (
1230                            "nap.provenance.long".to_string(),
1231                            "x".repeat(MAX_CONDENSED_METADATA_VALUE_BYTES + 1),
1232                        ),
1233                    ]),
1234                ),
1235                (
1236                    "character/face_image.png".to_string(),
1237                    BTreeMap::from([("nap.provenance.kind".to_string(), "generation".to_string())]),
1238                ),
1239            ]),
1240        );
1241
1242        let envelope = resolve_with_provenance(&resolver);
1243        assert_eq!(envelope.manifest.name, "Woody");
1244        assert_eq!(envelope.provenance.files.len(), 2);
1245
1246        let manifest_file = &envelope.provenance.files[0];
1247        assert_eq!(manifest_file.role, "manifest");
1248        assert_eq!(manifest_file.path.as_deref(), Some("character/woody.yaml"));
1249        assert_eq!(manifest_file.provenance["nap.provenance.kind"], "edit");
1250        assert!(
1251            manifest_file
1252                .provenance
1253                .get("nap.provenance.long")
1254                .is_none()
1255        );
1256
1257        let representation_file = &envelope.provenance.files[1];
1258        assert_eq!(representation_file.role, "representation");
1259        assert_eq!(representation_file.name.as_deref(), Some("face_image"));
1260        assert_eq!(
1261            representation_file.path.as_deref(),
1262            Some("character/face_image.png")
1263        );
1264        assert_eq!(representation_file.uri.as_deref(), Some("face_image.png"));
1265        assert_eq!(representation_file.format.as_deref(), Some("png"));
1266    }
1267
1268    #[test]
1269    fn test_resolve_with_provenance_records_path_and_revision_metadata_lookups() {
1270        let (tmp, resolver) = setup();
1271        let repo_path = tmp.path().join("toystory");
1272        let envelope = resolve_with_provenance(&resolver);
1273
1274        let requests: Vec<BTreeMap<String, String>> = serde_json::from_str(
1275            &std::fs::read_to_string(repo_path.join(".mock_metadata_requests.json")).unwrap(),
1276        )
1277        .unwrap();
1278        assert_eq!(requests.len(), 2);
1279        assert_eq!(requests[0].get("path").unwrap(), "character/woody.yaml");
1280        assert_eq!(
1281            requests[0].get("revision").unwrap(),
1282            &envelope.provenance.revision
1283        );
1284        assert_eq!(requests[1].get("path").unwrap(), "character/face_image.png");
1285        assert_eq!(
1286            requests[1].get("revision").unwrap(),
1287            &envelope.provenance.revision
1288        );
1289        assert!(!requests.iter().any(|request| {
1290            request
1291                .get("path")
1292                .is_some_and(|path| path.starts_with("blake3:"))
1293        }));
1294    }
1295
1296    #[test]
1297    fn test_resolve_with_provenance_uses_none_for_missing_metadata() {
1298        let (_tmp, resolver) = setup();
1299        let envelope = resolve_with_provenance(&resolver);
1300        assert_eq!(envelope.provenance.files[0].provenance, "none");
1301        assert_eq!(envelope.provenance.files[1].provenance, "none");
1302    }
1303
1304    #[test]
1305    fn test_resolve_with_include_blobs_hydrates_known_readable_artifacts() {
1306        let (tmp, resolver) = setup();
1307        let repo_path = tmp.path().join("toystory");
1308        write_mock_metadata(
1309            &repo_path,
1310            BTreeMap::from([(
1311                "character/woody.yaml".to_string(),
1312                BTreeMap::from([
1313                    (
1314                        "nap.provenance.prompt.address".to_string(),
1315                        "lore:prompt:1".to_string(),
1316                    ),
1317                    (
1318                        "unrelated.artifact.address".to_string(),
1319                        "lore:binary:1".to_string(),
1320                    ),
1321                ]),
1322            )]),
1323        );
1324        write_mock_blobs(
1325            &repo_path,
1326            BTreeMap::from([("lore:prompt:1".to_string(), "Describe Woody".to_string())]),
1327        );
1328
1329        let result = resolver
1330            .resolve(
1331                "nap://toystory/character/woody",
1332                &ResolveOptions {
1333                    provenance: Some(true),
1334                    include_blobs: Some(true),
1335                    ..Default::default()
1336                },
1337            )
1338            .unwrap();
1339        let ResolveResult::Provenance(envelope) = result else {
1340            panic!("expected provenance envelope");
1341        };
1342
1343        let blobs = &envelope.provenance.files[0].blobs;
1344        assert_eq!(blobs.len(), 1);
1345        assert_eq!(blobs["prompt"].content, "Describe Woody");
1346        assert!(!blobs["prompt"].truncated);
1347    }
1348
1349    #[test]
1350    fn test_include_blobs_implies_provenance_envelope() {
1351        let (_tmp, resolver) = setup();
1352        let result = resolver
1353            .resolve(
1354                "nap://toystory/character/woody",
1355                &ResolveOptions {
1356                    include_blobs: Some(true),
1357                    ..Default::default()
1358                },
1359            )
1360            .unwrap();
1361        assert!(matches!(result, ResolveResult::Provenance(_)));
1362    }
1363
1364    #[test]
1365    fn test_resolve_with_include_blobs_truncates_readable_artifacts() {
1366        let (tmp, resolver) = setup();
1367        let repo_path = tmp.path().join("toystory");
1368        write_mock_metadata(
1369            &repo_path,
1370            BTreeMap::from([(
1371                "character/woody.yaml".to_string(),
1372                BTreeMap::from([(
1373                    "nap.provenance.prompt.address".to_string(),
1374                    "lore:prompt:large".to_string(),
1375                )]),
1376            )]),
1377        );
1378        write_mock_blobs(
1379            &repo_path,
1380            BTreeMap::from([(
1381                "lore:prompt:large".to_string(),
1382                "x".repeat(MAX_HYDRATED_BLOB_BYTES + 10),
1383            )]),
1384        );
1385
1386        let result = resolver
1387            .resolve(
1388                "nap://toystory/character/woody",
1389                &ResolveOptions {
1390                    provenance: Some(true),
1391                    include_blobs: Some(true),
1392                    ..Default::default()
1393                },
1394            )
1395            .unwrap();
1396        let ResolveResult::Provenance(envelope) = result else {
1397            panic!("expected provenance envelope");
1398        };
1399        let blob = &envelope.provenance.files[0].blobs["prompt"];
1400        assert!(blob.truncated);
1401        assert_eq!(blob.original_bytes, MAX_HYDRATED_BLOB_BYTES + 10);
1402        assert_eq!(blob.included_bytes, MAX_HYDRATED_BLOB_BYTES);
1403        assert_eq!(blob.content.len(), MAX_HYDRATED_BLOB_BYTES);
1404    }
1405
1406    #[test]
1407    fn test_provenance_rejects_unsafe_representation_paths() {
1408        let tmp = TempDir::new().unwrap();
1409        let repo_path = tmp.path().join("toystory");
1410        let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1411        let (mut manifest, _) = repo
1412            .create_entity(&EntityType::new("character"), "jessie", "Jessie", "test")
1413            .unwrap();
1414        manifest.set_representation(
1415            "unsafe",
1416            Representation {
1417                hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1418                    .to_string(),
1419                format: "png".to_string(),
1420                uri: Some("../secret.png".to_string()),
1421                tier: None,
1422            },
1423        );
1424        use crate::commit::Change;
1425        repo.commit_manifest(
1426            &mut manifest,
1427            "add unsafe representation",
1428            "test",
1429            vec![Change::set(
1430                "representations.unsafe",
1431                None,
1432                "unsafe".to_string(),
1433            )],
1434        )
1435        .unwrap();
1436
1437        let resolver = Resolver::with_vcs_factory(
1438            tmp.path(),
1439            || Box::new(MockBackend::new()),
1440            ResolveConfig {
1441                default_branch: Some("main".to_string()),
1442            },
1443        );
1444        let err = resolver
1445            .resolve(
1446                "nap://toystory/character/jessie",
1447                &ResolveOptions {
1448                    provenance: Some(true),
1449                    ..Default::default()
1450                },
1451            )
1452            .unwrap_err();
1453        assert!(err.to_string().contains("unsafe representation URI"));
1454    }
1455
1456    #[test]
1457    fn test_resolve_with_fragment() {
1458        let (_tmp, resolver) = setup();
1459        let result = resolver
1460            .resolve(
1461                "nap://toystory/character/woody#properties.toy_type",
1462                &Default::default(),
1463            )
1464            .unwrap();
1465        match result {
1466            ResolveResult::Subtree(v) => {
1467                assert_eq!(v.as_str(), Some("plush"));
1468            }
1469            _ => panic!("expected subtree"),
1470        }
1471    }
1472
1473    #[test]
1474    fn test_resolve_with_options_path() {
1475        let (_tmp, resolver) = setup();
1476        let result = resolver
1477            .resolve(
1478                "nap://toystory/character/woody",
1479                &ResolveOptions {
1480                    path: Some("properties.homeworld".to_string()),
1481                    ..Default::default()
1482                },
1483            )
1484            .unwrap();
1485        match result {
1486            ResolveResult::Subtree(v) => {
1487                assert_eq!(v.as_str(), Some("nap://toystory/location/andys-room"));
1488            }
1489            _ => panic!("expected subtree"),
1490        }
1491    }
1492
1493    #[test]
1494    fn test_query_convenience() {
1495        let (_tmp, resolver) = setup();
1496        let result = resolver
1497            .query("nap://toystory/character/woody", "properties.toy_type")
1498            .unwrap();
1499        assert_eq!(result.as_str(), Some("plush"));
1500    }
1501
1502    #[test]
1503    fn test_list_repositories() {
1504        let (_tmp, resolver) = setup();
1505        let repositories = resolver.list_repositories().unwrap();
1506        assert!(repositories.contains(&"toystory".to_string()));
1507    }
1508
1509    #[test]
1510    fn test_resolve_not_found() {
1511        let (_tmp, resolver) = setup();
1512        let result = resolver.resolve("nap://toystory/character/nonexistent", &Default::default());
1513        assert!(result.is_err());
1514    }
1515
1516    #[test]
1517    fn test_resolve_without_scheme() {
1518        let (_tmp, resolver) = setup();
1519        let result = resolver
1520            .resolve("toystory/character/woody", &Default::default())
1521            .unwrap();
1522        match result {
1523            ResolveResult::Full(m) => {
1524                assert_eq!(m.name, "Woody");
1525                assert_eq!(m.entity_type.as_str(), "character");
1526            }
1527            _ => panic!("expected full manifest"),
1528        }
1529    }
1530
1531    #[test]
1532    fn test_resolve_without_scheme_with_fragment() {
1533        let (_tmp, resolver) = setup();
1534        let result = resolver
1535            .resolve(
1536                "toystory/character/woody#properties.toy_type",
1537                &Default::default(),
1538            )
1539            .unwrap();
1540        match result {
1541            ResolveResult::Subtree(v) => {
1542                assert_eq!(v.as_str(), Some("plush"));
1543            }
1544            _ => panic!("expected subtree"),
1545        }
1546    }
1547
1548    #[test]
1549    fn test_resolve_without_leading_slash() {
1550        let (_tmp, resolver) = setup();
1551        let result = resolver
1552            .resolve("toystory/character/woody", &Default::default())
1553            .unwrap();
1554        match result {
1555            ResolveResult::Full(m) => {
1556                assert_eq!(m.name, "Woody");
1557            }
1558            _ => panic!("expected full manifest"),
1559        }
1560    }
1561
1562    #[test]
1563    fn test_resolve_with_leading_slash_without_scheme() {
1564        let (_tmp, resolver) = setup();
1565        let result = resolver
1566            .resolve("/toystory/character/woody", &Default::default())
1567            .unwrap();
1568        match result {
1569            ResolveResult::Full(m) => {
1570                assert_eq!(m.name, "Woody");
1571            }
1572            _ => panic!("expected full manifest"),
1573        }
1574    }
1575
1576    #[test]
1577    fn presign_debug_output_redacts_secrets() {
1578        let options = PresignOptions {
1579            bearer_token: Some("secret-token".to_string()),
1580            ..Default::default()
1581        };
1582        let rendered = format!("{options:?}");
1583        assert!(rendered.contains("<redacted>"));
1584        assert!(!rendered.contains("secret-token"));
1585
1586        let result = PresignedRepresentation {
1587            url: "https://example.test/v1/presigned/x?token=secret".to_string(),
1588            expires_at: 1,
1589            revision: "revision".to_string(),
1590            repository_id: "repository".to_string(),
1591            address: "address".to_string(),
1592            representation: "face_image".to_string(),
1593            format: "png".to_string(),
1594        };
1595        assert!(!format!("{result:?}").contains("token=secret"));
1596    }
1597
1598    #[test]
1599    fn presigned_url_validation_rejects_cross_origin_and_extra_query_data() {
1600        let base = reqwest::Url::parse("https://lore.example.test").unwrap();
1601        let path = "/v1/presigned/repository/address";
1602        assert!(validate_presigned_url(&base, "//evil.test/x?token=x", path).is_err());
1603        assert!(
1604            validate_presigned_url(
1605                &base,
1606                "/v1/presigned/repository/address?token=x&redirect=https://evil.test",
1607                path,
1608            )
1609            .is_err()
1610        );
1611        assert!(
1612            validate_presigned_url(&base, "/v1/presigned/repository/address?token=opaque", path,)
1613                .is_ok()
1614        );
1615    }
1616
1617    #[tokio::test]
1618    async fn presign_uses_repository_id_and_file_context_separately() {
1619        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1620
1621        let (_tmp, resolver) = setup();
1622        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1623        let address = listener.local_addr().unwrap();
1624        let server = tokio::spawn(async move {
1625            let (mut stream, _) = listener.accept().await.unwrap();
1626            let mut request = vec![0_u8; 8192];
1627            let read = stream.read(&mut request).await.unwrap();
1628            let request = String::from_utf8_lossy(&request[..read]);
1629            assert!(request.contains(
1630                "POST /v1/repository/0123456789abcdef0123456789abcdef/content/9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-fedcba9876543210fedcba9876543210/presign"
1631            ));
1632            assert!(request.contains("authorization: Bearer test-token"));
1633            assert!(request.contains("\"ttl_seconds\":90"));
1634            let body = concat!(
1635                "{\"url_suffix\":\"/v1/presigned/0123456789abcdef0123456789abcdef/",
1636                "9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-",
1637                "fedcba9876543210fedcba9876543210?token=opaque\",\"expires_at\":12345}"
1638            );
1639            let response = format!(
1640                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1641                body.len(),
1642                body
1643            );
1644            stream.write_all(response.as_bytes()).await.unwrap();
1645        });
1646
1647        let result = resolver
1648            .presign_representation(
1649                "nap://toystory/character/woody",
1650                "face_image",
1651                &PresignOptions {
1652                    ttl_seconds: Some(90),
1653                    lore_http_url: Some(format!("http://{address}")),
1654                    bearer_token: Some("test-token".to_string()),
1655                    ..Default::default()
1656                },
1657            )
1658            .await
1659            .unwrap();
1660        server.await.unwrap();
1661        assert_eq!(result.expires_at, 12345);
1662        assert_eq!(result.repository_id, "0123456789abcdef0123456789abcdef");
1663        assert!(result.url.ends_with("?token=opaque"));
1664    }
1665
1666    #[tokio::test]
1667    async fn presign_rejects_fragment_and_conflicting_revision_selectors() {
1668        let (_tmp, resolver) = setup();
1669        assert!(
1670            resolver
1671                .presign_representation(
1672                    "nap://toystory/character/woody#properties",
1673                    "face_image",
1674                    &PresignOptions::default(),
1675                )
1676                .await
1677                .is_err()
1678        );
1679        assert!(
1680            resolver
1681                .presign_representation(
1682                    "nap://toystory/character/woody",
1683                    "face_image",
1684                    &PresignOptions {
1685                        branch: Some("main".to_string()),
1686                        commit: Some("abc".to_string()),
1687                        ..Default::default()
1688                    },
1689                )
1690                .await
1691                .unwrap_err()
1692                .to_string()
1693                .contains("either branch or commit")
1694        );
1695    }
1696}
1697
1698#[cfg(all(test, feature = "lore-integration"))]
1699mod lore_tests {
1700    use super::*;
1701    use crate::types::EntityType;
1702    use crate::vcs_lore::LoreBackend;
1703    use std::time::{SystemTime, UNIX_EPOCH};
1704    use tempfile::TempDir;
1705
1706    fn unique_suffix() -> u64 {
1707        SystemTime::now()
1708            .duration_since(UNIX_EPOCH)
1709            .unwrap()
1710            .as_nanos() as u64
1711    }
1712
1713    fn setup_lore() -> (TempDir, Resolver, String) {
1714        let repository = format!("lr-{}", unique_suffix());
1715        let tmp = TempDir::new().unwrap();
1716        let repo_path = tmp.path().join(&repository);
1717        let repo =
1718            Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
1719
1720        // Create a character
1721        let (mut manifest, _) = repo
1722            .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1723            .unwrap();
1724
1725        // Add properties and commit
1726        manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1727        use crate::commit::Change;
1728        repo.commit_manifest(
1729            &mut manifest,
1730            "add Woody details",
1731            "test",
1732            vec![Change::set(
1733                "properties.toy_type",
1734                None,
1735                "plush".to_string(),
1736            )],
1737        )
1738        .unwrap();
1739
1740        let resolver = Resolver::with_vcs_factory(
1741            tmp.path(),
1742            || Box::new(LoreBackend::from_env()),
1743            ResolveConfig {
1744                default_branch: Some("main".to_string()),
1745            },
1746        );
1747        (tmp, resolver, repository)
1748    }
1749
1750    #[test]
1751    fn test_resolve_lore_full_manifest() {
1752        let (_tmp, resolver, repository) = setup_lore();
1753        let uri = format!("nap://{}/character/woody", repository);
1754        let result = resolver.resolve(&uri, &Default::default()).unwrap();
1755        match result {
1756            ResolveResult::Full(m) => {
1757                assert_eq!(m.name, "Woody");
1758            }
1759            _ => panic!("expected full manifest"),
1760        }
1761    }
1762}