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