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