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