1use 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "lowercase")]
37pub enum ResolveSource {
38 #[default]
41 Auto,
42 Remote,
44 Local,
46}
47
48#[derive(Debug, Clone, Default)]
53pub struct ResolveConfig {
54 pub default_branch: Option<String>,
59}
60
61#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct ResolveOptions {
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub source: Option<ResolveSource>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub branch: Option<String>,
73
74 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub commit: Option<String>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub path: Option<String>,
83
84 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub recursive: Option<bool>,
88
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub max_depth: Option<usize>,
93
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub provenance: Option<bool>,
97
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub include_blobs: Option<bool>,
101}
102
103#[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#[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 hex::encode(bytes)
242}
243
244impl ResolveOptions {
245 fn query_path(&self, uri: &NapUri) -> Option<String> {
247 self.path.clone().or_else(|| uri.fragment.clone())
248 }
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(untagged)]
254pub enum ResolveResult {
255 Full(Box<Manifest>),
257 Provenance(Box<ResolveEnvelope>),
259 Subtree(serde_json::Value),
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ResolveEnvelope {
266 pub manifest: Box<Manifest>,
267 pub provenance: ResolveProvenanceEnvelope,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct ResolveProvenanceEnvelope {
273 pub revision: String,
274 pub files: Vec<ResolveProvenanceFile>,
275}
276
277#[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#[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
309pub struct Resolver {
311 base_path: PathBuf,
313 vcs_factory: fn() -> Box<dyn VcsBackend>,
315 use_vcs: bool,
319 config: ResolveConfig,
321 source: ResolveSource,
322}
323
324impl Resolver {
325 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 use_vcs: true,
356 config: ResolveConfig::default(),
357 source,
358 }
359 }
360
361 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 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 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 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 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 pub fn resolve(
656 &self,
657 uri_str: &str,
658 options: &ResolveOptions,
659 ) -> Result<ResolveResult, NapError> {
660 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let result = self.resolve_uri_single(uri, options)?;
1283
1284 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 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 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 debug!("subtree query, skipping recursive resolution");
1323 Ok(ResolveResult::Subtree(value))
1324 }
1325 ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
1326 }
1327 }
1328
1329 fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
1331 let mut uris = Vec::new();
1332
1333 for value in manifest.properties.values() {
1335 self.extract_uris_from_yaml_value(value, &mut uris);
1336 }
1337
1338 for value in manifest.references.values() {
1340 self.extract_uris_from_yaml_value(value, &mut uris);
1341 }
1342
1343 uris.sort();
1345 uris.dedup();
1346 uris
1347 }
1348
1349 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 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 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 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 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 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 ×tamp,
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 let (mut manifest, _) = repo
1592 .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1593 .unwrap();
1594
1595 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 let (mut manifest, _) = repo
2273 .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
2274 .unwrap();
2275
2276 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}