Skip to main content

nap_core/
resolver.rs

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