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::manifest::Manifest;
27use crate::query::ManifestQuery;
28use crate::repository::Repository;
29use crate::uri::NapUri;
30use crate::vcs::VcsBackend;
31use crate::vcs_lore::LoreBackend;
32
33#[derive(Debug, Clone, Default)]
38pub struct ResolveConfig {
39 pub default_branch: Option<String>,
44}
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct ResolveOptions {
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub branch: Option<String>,
55
56 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub commit: Option<String>,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub path: Option<String>,
65
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub recursive: Option<bool>,
70
71 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub max_depth: Option<usize>,
75
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub provenance: Option<bool>,
79
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub include_blobs: Option<bool>,
83}
84
85#[derive(Clone, Default)]
87pub struct PresignOptions {
88 pub branch: Option<String>,
89 pub commit: Option<String>,
90 pub ttl_seconds: Option<u64>,
91 pub lore_http_url: Option<String>,
92 pub bearer_token: Option<String>,
93}
94
95impl fmt::Debug for PresignOptions {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.debug_struct("PresignOptions")
98 .field("branch", &self.branch)
99 .field("commit", &self.commit)
100 .field("ttl_seconds", &self.ttl_seconds)
101 .field("lore_http_url", &self.lore_http_url)
102 .field(
103 "bearer_token",
104 &self.bearer_token.as_ref().map(|_| "<redacted>"),
105 )
106 .finish()
107 }
108}
109
110#[derive(Clone, Serialize, Deserialize)]
112pub struct PresignedRepresentation {
113 pub url: String,
114 pub expires_at: u64,
115 pub revision: String,
116 pub repository_id: String,
117 pub address: String,
118 pub representation: String,
119 pub format: String,
120}
121
122impl fmt::Debug for PresignedRepresentation {
123 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124 f.debug_struct("PresignedRepresentation")
125 .field("url", &"<redacted>")
126 .field("expires_at", &self.expires_at)
127 .field("revision", &self.revision)
128 .field("repository_id", &self.repository_id)
129 .field("address", &self.address)
130 .field("representation", &self.representation)
131 .field("format", &self.format)
132 .finish()
133 }
134}
135
136#[derive(Serialize)]
137struct LorePresignRequest {
138 #[serde(skip_serializing_if = "Option::is_none")]
139 ttl_seconds: Option<u64>,
140}
141
142#[derive(Deserialize)]
143struct LorePresignResponse {
144 url_suffix: String,
145 expires_at: u64,
146}
147
148fn presign_http_client() -> Result<&'static reqwest::Client, NapError> {
149 static CLIENT: OnceLock<Result<reqwest::Client, String>> = OnceLock::new();
150 CLIENT
151 .get_or_init(|| {
152 reqwest::Client::builder()
153 .connect_timeout(Duration::from_secs(5))
154 .timeout(Duration::from_secs(30))
155 .redirect(reqwest::redirect::Policy::none())
156 .build()
157 .map_err(|e| e.to_string())
158 })
159 .as_ref()
160 .map_err(|e| NapError::Other(format!("failed to initialize presign HTTP client: {e}")))
161}
162
163async fn read_bounded_response(
164 mut response: reqwest::Response,
165 limit: usize,
166) -> Result<(reqwest::StatusCode, Vec<u8>), NapError> {
167 let status = response.status();
168 if response
169 .content_length()
170 .is_some_and(|length| length > limit as u64)
171 {
172 return Err(NapError::Other(format!(
173 "Lore presign response exceeded {limit} bytes"
174 )));
175 }
176 let mut body = Vec::new();
177 while let Some(chunk) = response
178 .chunk()
179 .await
180 .map_err(|e| NapError::Other(format!("failed to read Lore presign response: {e}")))?
181 {
182 if body.len().saturating_add(chunk.len()) > limit {
183 return Err(NapError::Other(format!(
184 "Lore presign response exceeded {limit} bytes"
185 )));
186 }
187 body.extend_from_slice(&chunk);
188 }
189 Ok((status, body))
190}
191
192fn validate_presigned_url(
193 base_url: &reqwest::Url,
194 suffix: &str,
195 expected_path: &str,
196) -> Result<reqwest::Url, NapError> {
197 if suffix.starts_with("//") || !suffix.starts_with('/') {
198 return Err(NapError::Other(
199 "Lore returned an unexpected presigned URL path".to_string(),
200 ));
201 }
202 let url = base_url
203 .join(suffix)
204 .map_err(|e| NapError::Other(format!("invalid Lore presigned URL: {e}")))?;
205 let query: Vec<_> = url.query_pairs().collect();
206 if url.origin() != base_url.origin()
207 || url.path() != expected_path
208 || query.len() != 1
209 || query[0].0 != "token"
210 || query[0].1.is_empty()
211 {
212 return Err(NapError::Other(
213 "Lore returned a cross-origin or malformed presigned URL".to_string(),
214 ));
215 }
216 Ok(url)
217}
218
219impl ResolveOptions {
220 fn query_path(&self, uri: &NapUri) -> Option<String> {
222 self.path.clone().or_else(|| uri.fragment.clone())
223 }
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(untagged)]
229pub enum ResolveResult {
230 Full(Box<Manifest>),
232 Provenance(Box<ResolveEnvelope>),
234 Subtree(serde_json::Value),
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ResolveEnvelope {
241 pub manifest: Box<Manifest>,
242 pub provenance: ResolveProvenanceEnvelope,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct ResolveProvenanceEnvelope {
248 pub revision: String,
249 pub files: Vec<ResolveProvenanceFile>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
254pub struct ResolveProvenanceFile {
255 pub role: String,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub name: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub path: Option<String>,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub uri: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub hash: Option<String>,
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub format: Option<String>,
266 pub provenance: serde_json::Value,
267 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
268 pub blobs: BTreeMap<String, HydratedProvenanceBlob>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct HydratedProvenanceBlob {
274 pub address: String,
275 pub content: String,
276 pub truncated: bool,
277 pub original_bytes: usize,
278 pub included_bytes: usize,
279}
280
281const MAX_CONDENSED_METADATA_VALUE_BYTES: usize = 256;
282const MAX_HYDRATED_BLOB_BYTES: usize = 12_000;
283
284pub struct Resolver {
286 base_path: PathBuf,
288 vcs_factory: fn() -> Box<dyn VcsBackend>,
290 use_vcs: bool,
294 config: ResolveConfig,
296}
297
298impl Resolver {
299 pub fn new(base_path: &Path) -> Self {
319 Self {
320 base_path: base_path.to_path_buf(),
321 vcs_factory: || Box::new(LoreBackend::from_env()),
322 use_vcs: crate::provider::version_control_configured(base_path),
323 config: ResolveConfig::default(),
324 }
325 }
326
327 pub fn with_vcs_factory(
331 base_path: &Path,
332 factory: fn() -> Box<dyn VcsBackend>,
333 config: ResolveConfig,
334 ) -> Self {
335 Self {
336 base_path: base_path.to_path_buf(),
337 vcs_factory: factory,
338 use_vcs: true,
339 config,
340 }
341 }
342
343 fn open_repo(&self, repository: &str) -> Result<(Repository, ResolveConfig), NapError> {
345 let repo_path = self.base_path.join(repository);
346 let vcs = if self.use_vcs {
347 Some((self.vcs_factory)())
348 } else {
349 None
350 };
351 let repo = Repository::open_optional(&repo_path, vcs)?;
352 let repo_config = repo.read_resolve_config();
353 Ok((repo, repo_config))
354 }
355
356 pub fn resolve(
376 &self,
377 uri_str: &str,
378 options: &ResolveOptions,
379 ) -> Result<ResolveResult, NapError> {
380 let normalized_uri_str = if uri_str.starts_with("nap://") {
382 uri_str.to_string()
383 } else {
384 format!("nap://{}", uri_str.trim_start_matches('/'))
385 };
386
387 debug!(
388 original_uri = %uri_str,
389 normalized_uri = %normalized_uri_str,
390 "normalized NAP URI"
391 );
392
393 let uri: NapUri = normalized_uri_str.parse()?;
394 self.resolve_uri(&uri, options)
395 }
396
397 pub async fn presign_representation(
402 &self,
403 uri_str: &str,
404 representation_name: &str,
405 options: &PresignOptions,
406 ) -> Result<PresignedRepresentation, NapError> {
407 if options.branch.is_some() && options.commit.is_some() {
408 return Err(NapError::Other(
409 "presign accepts either branch or commit, not both".to_string(),
410 ));
411 }
412
413 let normalized = if uri_str.starts_with("nap://") {
414 uri_str.to_string()
415 } else {
416 format!("nap://{}", uri_str.trim_start_matches('/'))
417 };
418 let uri: NapUri = normalized.parse()?;
419 if uri.fragment.is_some() {
420 return Err(NapError::InvalidUri {
421 uri: uri_str.to_string(),
422 reason: "fragments are not supported when presigning a representation".to_string(),
423 });
424 }
425
426 let (repo, repo_config) = self.open_repo(&uri.repository)?;
427 let vcs = repo.vcs().ok_or_else(|| NapError::BackendNotConfigured {
428 operation: "presign a representation".to_string(),
429 })?;
430 let revision = match (&options.commit, &options.branch) {
431 (Some(commit), None) => commit.clone(),
432 (None, Some(branch)) => vcs.resolve_branch_head(&repo.root, branch)?,
433 (None, None) => {
434 let branch = repo_config
435 .default_branch
436 .as_ref()
437 .or(self.config.default_branch.as_ref())
438 .ok_or(NapError::NoDefaultBranch)?;
439 vcs.resolve_branch_head(&repo.root, branch)?
440 }
441 (Some(_), Some(_)) => unreachable!("validated above"),
442 };
443
444 let manifest = repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, &revision)?;
445 let representation = manifest
446 .representations
447 .get(representation_name)
448 .ok_or_else(|| {
449 NapError::Other(format!(
450 "representation '{representation_name}' does not exist on {}",
451 manifest.id
452 ))
453 })?;
454 let representation_uri = representation.uri.as_deref().ok_or_else(|| {
455 NapError::Other(format!(
456 "representation '{representation_name}' has no repository-relative URI"
457 ))
458 })?;
459 let file_path = Self::resolve_representation_path(&uri, representation_uri)?
460 .ok_or_else(|| {
461 NapError::Other(format!(
462 "representation '{representation_name}' is external; only direct repository files can be presigned"
463 ))
464 })?;
465
466 let repository = vcs.repository_descriptor(&repo.root)?;
467 let content = vcs.file_content_address_at_ref(&repo.root, &file_path, &revision)?;
468 if let Some(expected_hash) = representation.hash.strip_prefix("blake3:")
469 && expected_hash != content.hash
470 {
471 return Err(NapError::ContentHashMismatch {
472 expected: representation.hash.clone(),
473 actual: format!("blake3:{}", content.hash),
474 });
475 }
476 let address = content.as_lore_address();
477
478 let configured_http_url = options
479 .lore_http_url
480 .clone()
481 .or_else(|| std::env::var("NAP_LORE_HTTP_URL").ok());
482 let http_url = match configured_http_url {
483 Some(url) => url,
484 None if repository.remote_url.contains("lore.portals.works") => {
485 return Err(NapError::Other(
489 "Portals Cloud presigned URLs are not enabled yet; production HTTP ingress is WIP. Supply --http-url or NAP_LORE_HTTP_URL only for an explicitly configured Lore HTTP endpoint."
490 .to_string(),
491 ));
492 }
493 None if repository.remote_url.is_empty()
494 || repository.remote_url.contains("localhost")
495 || repository.remote_url.contains("127.0.0.1") =>
496 {
497 "http://127.0.0.1:41339".to_string()
498 }
499 None => {
500 return Err(NapError::Other(
501 "Lore HTTP endpoint is not configured; set NAP_LORE_HTTP_URL or pass --http-url"
502 .to_string(),
503 ));
504 }
505 };
506 let base_url = reqwest::Url::parse(&http_url)
507 .map_err(|e| NapError::Other(format!("invalid Lore HTTP URL: {e}")))?;
508 if !matches!(base_url.scheme(), "http" | "https")
509 || base_url.host_str().is_none()
510 || !base_url.username().is_empty()
511 || base_url.password().is_some()
512 || base_url.query().is_some()
513 || base_url.fragment().is_some()
514 || !matches!(base_url.path(), "" | "/")
515 {
516 return Err(NapError::Other(
517 "Lore HTTP URL must be an http(s) origin without credentials, path, query, or fragment"
518 .to_string(),
519 ));
520 }
521
522 let endpoint_path = format!(
523 "/v1/repository/{}/content/{}/presign",
524 repository.id, address
525 );
526 let endpoint = base_url
527 .join(&endpoint_path)
528 .map_err(|e| NapError::Other(format!("failed to construct Lore presign URL: {e}")))?;
529 let bearer_token = options
530 .bearer_token
531 .clone()
532 .or_else(|| std::env::var("NAP_LORE_HTTP_TOKEN").ok())
533 .or_else(|| std::env::var("NAP_LORE_GRPC_TOKEN").ok());
534 let mut request = presign_http_client()?
535 .post(endpoint)
536 .json(&LorePresignRequest {
537 ttl_seconds: options.ttl_seconds,
538 });
539 if let Some(token) = bearer_token.filter(|token| !token.is_empty()) {
540 let mut value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
541 .map_err(|_| {
542 NapError::Other("bearer token is not a valid HTTP header value".to_string())
543 })?;
544 value.set_sensitive(true);
545 request = request.header(AUTHORIZATION, value);
546 }
547
548 let response = request
549 .send()
550 .await
551 .map_err(|e| NapError::Other(format!("Lore presign request failed: {e}")))?;
552 let (status, body) = read_bounded_response(response, 64 * 1024).await?;
553 if !status.is_success() {
554 let detail = String::from_utf8_lossy(&body);
555 let message = match status {
556 reqwest::StatusCode::UNAUTHORIZED => {
557 "Lore rejected the request as unauthenticated; set NAP_LORE_HTTP_TOKEN (or NAP_LORE_GRPC_TOKEN) to a repository-scoped bearer token".to_string()
558 }
559 reqwest::StatusCode::FORBIDDEN => {
560 "Lore denied permission to presign this repository representation".to_string()
561 }
562 reqwest::StatusCode::NOT_FOUND if detail.contains("not enabled") => {
563 "Lore presigned URLs are disabled; configure server.http.presigned_url_hmac_key and restart Lore".to_string()
564 }
565 reqwest::StatusCode::NOT_FOUND => {
566 "representation content is not available in the Lore remote; push the pinned revision before presigning".to_string()
567 }
568 _ => format!("Lore presign failed with HTTP {status}: {}", detail.trim()),
569 };
570 return Err(NapError::Other(message));
571 }
572 let response: LorePresignResponse = serde_json::from_slice(&body)
573 .map_err(|e| NapError::Other(format!("invalid Lore presign response: {e}")))?;
574 let expected_redeem_path = format!("/v1/presigned/{}/{}", repository.id, address);
575 let url = validate_presigned_url(&base_url, &response.url_suffix, &expected_redeem_path)?;
576
577 info!(
578 repository_id = %repository.id,
579 revision = %revision,
580 representation = %representation_name,
581 expires_at = response.expires_at,
582 "created Lore presigned representation URL"
583 );
584 Ok(PresignedRepresentation {
585 url: url.to_string(),
586 expires_at: response.expires_at,
587 revision,
588 repository_id: repository.id,
589 address,
590 representation: representation_name.to_string(),
591 format: representation.format.clone(),
592 })
593 }
594
595 pub fn resolve_uri(
597 &self,
598 uri: &NapUri,
599 options: &ResolveOptions,
600 ) -> Result<ResolveResult, NapError> {
601 debug!(
602 uri = %uri,
603 options = ?options,
604 "resolving NAP URI"
605 );
606
607 let wants_provenance =
608 options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
609
610 if options.recursive.unwrap_or(false) && !wants_provenance {
613 return self.resolve_uri_recursive(
614 uri,
615 options,
616 0,
617 &mut std::collections::HashSet::new(),
618 );
619 }
620
621 self.resolve_uri_single(uri, options)
622 }
623
624 fn resolve_uri_single(
626 &self,
627 uri: &NapUri,
628 options: &ResolveOptions,
629 ) -> Result<ResolveResult, NapError> {
630 let (repo, repo_config) = self.open_repo(&uri.repository)?;
631 let query_path = options.query_path(uri);
632
633 let unsatisfiable = |what: &str| NapError::ResolutionFailed {
644 address: uri.to_string(),
645 message: format!(
646 "cannot resolve {what}: no version-control backend is configured. \
647 Configure one with 'nap backend configure' to use branch/commit selectors."
648 ),
649 };
650
651 let revision: Option<String> = match (options.commit.as_ref(), options.branch.as_ref()) {
652 (Some(commit), _) => {
653 debug!(%commit, "resolve: rule 1 — commit provided");
654 if repo.vcs().is_none() {
655 return Err(unsatisfiable(&format!("at commit '{commit}'")));
656 }
657 Some(commit.clone())
658 }
659 (None, Some(branch)) => {
660 debug!(%branch, "resolve: rule 2 — branch provided");
661 let vcs = repo
662 .vcs()
663 .ok_or_else(|| unsatisfiable(&format!("at branch '{branch}'")))?;
664 Some(vcs.resolve_branch_head(&repo.root, branch)?)
665 }
666 (None, None) => {
667 let default_branch = repo_config
668 .default_branch
669 .as_ref()
670 .or(self.config.default_branch.as_ref());
671 match default_branch {
672 Some(default_branch) => {
673 debug!(%default_branch, "resolve: rule 3 — using default_branch");
674 let vcs = repo.vcs().ok_or_else(|| {
675 unsatisfiable(&format!("at default branch '{default_branch}'"))
676 })?;
677 Some(vcs.resolve_branch_head(&repo.root, default_branch)?)
678 }
679 None if repo.vcs().is_some() => {
680 debug!("resolve: rule 4 — no branch, no commit, no default_branch");
681 return Err(NapError::NoDefaultBranch);
682 }
683 None => {
684 debug!("resolve: unversioned — reading current filesystem state");
685 None
686 }
687 }
688 }
689 };
690
691 let manifest = match &revision {
694 Some(revision) => {
695 repo.read_manifest_at_ref(&uri.entity_type, &uri.entity_id, revision)?
696 }
697 None => repo.read_manifest(&uri.entity_type, &uri.entity_id)?,
698 };
699
700 let wants_provenance =
701 options.provenance.unwrap_or(false) || options.include_blobs.unwrap_or(false);
702 if wants_provenance {
703 if let Some(path) = query_path {
704 return Err(NapError::Other(format!(
705 "provenance envelopes are only supported for full manifest resolution, not subtree query '{path}'"
706 )));
707 }
708
709 let revision = revision
711 .as_deref()
712 .ok_or_else(|| NapError::BackendNotConfigured {
713 operation: "provenance".to_string(),
714 })?;
715
716 let envelope = self.build_provenance_envelope(
717 &repo,
718 uri,
719 manifest,
720 revision,
721 options.include_blobs.unwrap_or(false),
722 )?;
723 info!(uri = %uri, "resolved NAP URI with provenance");
724 return Ok(ResolveResult::Provenance(Box::new(envelope)));
725 }
726
727 match query_path {
729 Some(ref path) => {
730 debug!(query_path = %path, "applying subtree query");
731 let yaml_value = manifest.to_value()?;
732 let result = ManifestQuery::query(&yaml_value, path, &manifest.id)?;
733
734 let json_str = serde_yaml::to_string(&result)
736 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
737 let json_value: serde_json::Value = serde_yaml::from_str(&json_str)
738 .map_err(|e| NapError::ManifestValidationError(e.to_string()))?;
739
740 info!(
741 uri = %uri,
742 query_path = %path,
743 "resolved NAP URI with query"
744 );
745 Ok(ResolveResult::Subtree(json_value))
746 }
747 None => {
748 info!(uri = %uri, "resolved NAP URI (full manifest)");
749 Ok(ResolveResult::Full(Box::new(manifest)))
750 }
751 }
752 }
753
754 fn build_provenance_envelope(
755 &self,
756 repo: &Repository,
757 uri: &NapUri,
758 manifest: Manifest,
759 revision: &str,
760 include_blobs: bool,
761 ) -> Result<ResolveEnvelope, NapError> {
762 let manifest_path = uri.manifest_path();
763 let mut files = vec![self.build_provenance_file(
764 repo,
765 revision,
766 "manifest",
767 None,
768 Some(manifest_path.clone()),
769 None,
770 None,
771 None,
772 include_blobs,
773 )?];
774
775 for (name, representation) in &manifest.representations {
776 let resolved_path = representation
777 .uri
778 .as_deref()
779 .map(|representation_uri| {
780 Self::resolve_representation_path(uri, representation_uri)
781 })
782 .transpose()?
783 .flatten();
784
785 files.push(self.build_provenance_file(
786 repo,
787 revision,
788 "representation",
789 Some(name.clone()),
790 resolved_path,
791 representation.uri.clone(),
792 Some(representation.hash.clone()),
793 Some(representation.format.clone()),
794 include_blobs,
795 )?);
796 }
797
798 Ok(ResolveEnvelope {
799 manifest: Box::new(manifest),
800 provenance: ResolveProvenanceEnvelope {
801 revision: revision.to_string(),
802 files,
803 },
804 })
805 }
806
807 #[allow(clippy::too_many_arguments)]
808 fn build_provenance_file(
809 &self,
810 repo: &Repository,
811 revision: &str,
812 role: &str,
813 name: Option<String>,
814 path: Option<String>,
815 uri: Option<String>,
816 hash: Option<String>,
817 format: Option<String>,
818 include_blobs: bool,
819 ) -> Result<ResolveProvenanceFile, NapError> {
820 let vcs = repo.vcs().ok_or_else(|| NapError::BackendNotConfigured {
822 operation: "provenance".to_string(),
823 })?;
824
825 let metadata = match path.as_deref() {
826 Some(path) => vcs.file_metadata_at_ref(&repo.root, path, revision)?,
827 None => None,
828 };
829
830 let blobs = if include_blobs {
831 match metadata.as_ref() {
832 Some(metadata) => Self::hydrate_known_blobs(vcs, repo, metadata)?,
833 None => BTreeMap::new(),
834 }
835 } else {
836 BTreeMap::new()
837 };
838
839 let provenance = match metadata {
840 Some(metadata) => {
841 let condensed = Self::condense_metadata(metadata);
842 if condensed.is_empty() {
843 serde_json::Value::String("none".to_string())
844 } else {
845 serde_json::to_value(condensed).map_err(|e| {
846 NapError::Other(format!("failed to serialize provenance metadata: {e}"))
847 })?
848 }
849 }
850 None => serde_json::Value::String("none".to_string()),
851 };
852
853 Ok(ResolveProvenanceFile {
854 role: role.to_string(),
855 name,
856 path,
857 uri,
858 hash,
859 format,
860 provenance,
861 blobs,
862 })
863 }
864
865 fn condense_metadata(metadata: BTreeMap<String, String>) -> BTreeMap<String, String> {
866 metadata
867 .into_iter()
868 .filter(|(_, value)| value.len() <= MAX_CONDENSED_METADATA_VALUE_BYTES)
869 .collect()
870 }
871
872 fn hydrate_known_blobs(
873 vcs: &dyn VcsBackend,
874 repo: &Repository,
875 metadata: &BTreeMap<String, String>,
876 ) -> Result<BTreeMap<String, HydratedProvenanceBlob>, NapError> {
877 let known_blob_keys = [
878 ("prompt", "nap.provenance.prompt.address"),
879 ("run", "nap.provenance.run.address"),
880 ("parameters", "nap.provenance.parameters.address"),
881 ];
882
883 let mut blobs = BTreeMap::new();
884 for (name, metadata_key) in known_blob_keys {
885 let Some(address) = metadata.get(metadata_key) else {
886 continue;
887 };
888 let content = vcs.read_provenance_blob(&repo.root, address)?;
889 blobs.insert(name.to_string(), Self::truncate_blob(address, &content));
890 }
891 Ok(blobs)
892 }
893
894 fn truncate_blob(address: &str, content: &str) -> HydratedProvenanceBlob {
895 let original_bytes = content.len();
896 let mut included_bytes = 0;
897 let mut truncated_content = String::new();
898
899 for ch in content.chars() {
900 let next_len = included_bytes + ch.len_utf8();
901 if next_len > MAX_HYDRATED_BLOB_BYTES {
902 break;
903 }
904 truncated_content.push(ch);
905 included_bytes = next_len;
906 }
907
908 HydratedProvenanceBlob {
909 address: address.to_string(),
910 content: truncated_content,
911 truncated: included_bytes < original_bytes,
912 original_bytes,
913 included_bytes,
914 }
915 }
916
917 fn resolve_representation_path(
918 uri: &NapUri,
919 representation_uri: &str,
920 ) -> Result<Option<String>, NapError> {
921 if representation_uri.contains("://") {
922 return Ok(None);
923 }
924
925 let representation_path = Path::new(representation_uri);
926 if representation_path.is_absolute() {
927 return Err(NapError::InvalidQueryPath(format!(
928 "representation URI must be relative for provenance lookup: {representation_uri}"
929 )));
930 }
931
932 let mut clean = PathBuf::new();
933 for component in representation_path.components() {
934 match component {
935 Component::Normal(part) => clean.push(part),
936 Component::CurDir => {}
937 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
938 return Err(NapError::InvalidQueryPath(format!(
939 "unsafe representation URI for provenance lookup: {representation_uri}"
940 )));
941 }
942 }
943 }
944
945 let entity_dir = Path::new(uri.entity_type.as_str()).join(&uri.entity_id);
948 Ok(Some(Self::path_to_lore_path(&entity_dir.join(clean))))
949 }
950
951 fn path_to_lore_path(path: &Path) -> String {
952 path.components()
953 .filter_map(|component| match component {
954 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
955 _ => None,
956 })
957 .collect::<Vec<_>>()
958 .join("/")
959 }
960
961 fn resolve_uri_recursive(
963 &self,
964 uri: &NapUri,
965 options: &ResolveOptions,
966 depth: usize,
967 visited: &mut std::collections::HashSet<String>,
968 ) -> Result<ResolveResult, NapError> {
969 let max_depth = options.max_depth.unwrap_or(10);
971 if depth >= max_depth {
972 debug!(depth, max_depth, "reached maximum recursion depth");
973 return self.resolve_uri_single(uri, options);
974 }
975
976 let uri_str = uri.to_string();
978 if visited.contains(&uri_str) {
979 debug!(uri = %uri_str, "detected circular reference, stopping recursion");
980 return self.resolve_uri_single(uri, options);
981 }
982 visited.insert(uri_str.clone());
983
984 debug!(uri = %uri_str, depth, "recursively resolving URI");
985
986 let result = self.resolve_uri_single(uri, options)?;
988
989 match result {
991 ResolveResult::Full(manifest) => {
992 let nested_uris = self.extract_nested_uris(&manifest);
993 if nested_uris.is_empty() {
994 debug!(uri = %uri_str, "no nested URIs found, returning manifest");
995 return Ok(ResolveResult::Full(manifest));
996 }
997
998 debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
999
1000 let mut resolved_manifest = (*manifest).clone();
1002 for nested_uri in nested_uris {
1003 let nested_uri_parsed: NapUri = nested_uri.parse()?;
1004
1005 let nested_result = self
1006 .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
1007 .map_err(|e| {
1008 NapError::Other(format!(
1009 "failed to resolve nested URI '{}' while resolving '{}': {}",
1010 nested_uri, uri_str, e
1011 ))
1012 })?;
1013
1014 if let ResolveResult::Full(nested_manifest) = nested_result {
1015 for (key, value) in nested_manifest.properties {
1018 resolved_manifest.properties.insert(key, value);
1019 }
1020 }
1021 }
1022
1023 Ok(ResolveResult::Full(Box::new(resolved_manifest)))
1024 }
1025 ResolveResult::Subtree(value) => {
1026 debug!("subtree query, skipping recursive resolution");
1028 Ok(ResolveResult::Subtree(value))
1029 }
1030 ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
1031 }
1032 }
1033
1034 fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
1036 let mut uris = Vec::new();
1037
1038 for value in manifest.properties.values() {
1040 self.extract_uris_from_yaml_value(value, &mut uris);
1041 }
1042
1043 for value in manifest.references.values() {
1045 self.extract_uris_from_yaml_value(value, &mut uris);
1046 }
1047
1048 uris.sort();
1050 uris.dedup();
1051 uris
1052 }
1053
1054 fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
1056 match value {
1057 serde_yaml::Value::String(s) if s.starts_with("nap://") => {
1058 uris.push(s.clone());
1059 }
1060 serde_yaml::Value::Sequence(seq) => {
1061 for item in seq {
1062 self.extract_uris_from_yaml_value(item, uris);
1063 }
1064 }
1065 serde_yaml::Value::Mapping(map) => {
1066 for (_, v) in map {
1067 self.extract_uris_from_yaml_value(v, uris);
1068 }
1069 }
1070 _ => {}
1071 }
1072 }
1073
1074 pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
1076 let options = ResolveOptions {
1077 path: Some(path.to_string()),
1078 ..Default::default()
1079 };
1080 match self.resolve(uri_str, &options)? {
1081 ResolveResult::Subtree(v) => Ok(v),
1082 ResolveResult::Full(m) => m.to_json_value(),
1083 ResolveResult::Provenance(envelope) => serde_json::to_value(envelope).map_err(|e| {
1084 NapError::Other(format!("failed to serialize provenance envelope: {e}"))
1085 }),
1086 }
1087 }
1088
1089 pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
1091 let mut repositories = Vec::new();
1092 for entry in std::fs::read_dir(&self.base_path)? {
1093 let entry = entry?;
1094 let path = entry.path();
1095 if path.is_dir()
1097 && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
1098 && let Some(name) = path.file_name().and_then(|n| n.to_str())
1099 {
1100 repositories.push(name.to_string());
1101 }
1102 }
1103 repositories.sort();
1104 Ok(repositories)
1105 }
1106}
1107
1108#[cfg(test)]
1109mod unit_tests {
1110 use super::*;
1111 use crate::manifest::Representation;
1112 use crate::test_utils::MockBackend;
1113 use crate::types::EntityType;
1114 use tempfile::TempDir;
1115
1116 fn setup() -> (TempDir, Resolver) {
1117 let tmp = TempDir::new().unwrap();
1118 let repo_path = tmp.path().join("toystory");
1119 let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1120
1121 let (mut manifest, _) = repo
1123 .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1124 .unwrap();
1125
1126 manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1128 manifest.set_property(
1129 "homeworld",
1130 serde_yaml::Value::String("nap://toystory/location/andys-room".to_string()),
1131 );
1132 manifest.add_reference(
1133 "appears_in",
1134 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
1135 "nap://toystory/scene/pizza-planet".to_string(),
1136 )]),
1137 );
1138 manifest.set_representation(
1139 "face_image",
1140 Representation {
1141 hash: "blake3:9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a"
1142 .to_string(),
1143 format: "png".to_string(),
1144 uri: Some("face_image.png".to_string()),
1145 tier: None,
1146 },
1147 );
1148
1149 use crate::commit::Change;
1150 repo.commit_manifest(
1151 &mut manifest,
1152 "add Woody details",
1153 "test",
1154 vec![Change::set(
1155 "properties.toy_type",
1156 None,
1157 "plush".to_string(),
1158 )],
1159 )
1160 .unwrap();
1161
1162 let resolver = Resolver::with_vcs_factory(
1163 tmp.path(),
1164 || Box::new(MockBackend::new()),
1165 ResolveConfig {
1166 default_branch: Some("main".to_string()),
1167 },
1168 );
1169 (tmp, resolver)
1170 }
1171
1172 #[test]
1173 fn test_resolve_full_manifest() {
1174 let (_tmp, resolver) = setup();
1175 let result = resolver
1176 .resolve("nap://toystory/character/woody", &Default::default())
1177 .unwrap();
1178 match result {
1179 ResolveResult::Full(m) => {
1180 assert_eq!(m.name, "Woody");
1181 assert_eq!(m.entity_type.as_str(), "character");
1182 }
1183 _ => panic!("expected full manifest"),
1184 }
1185 }
1186
1187 fn write_mock_metadata(repo_path: &Path, metadata: BTreeMap<String, BTreeMap<String, String>>) {
1188 std::fs::write(
1189 repo_path.join(".mock_file_metadata.json"),
1190 serde_json::to_string(&metadata).unwrap(),
1191 )
1192 .unwrap();
1193 }
1194
1195 fn write_mock_blobs(repo_path: &Path, blobs: BTreeMap<String, String>) {
1196 std::fs::write(
1197 repo_path.join(".mock_provenance_blobs.json"),
1198 serde_json::to_string(&blobs).unwrap(),
1199 )
1200 .unwrap();
1201 }
1202
1203 fn resolve_with_provenance(resolver: &Resolver) -> ResolveEnvelope {
1204 let result = resolver
1205 .resolve(
1206 "nap://toystory/character/woody",
1207 &ResolveOptions {
1208 provenance: Some(true),
1209 ..Default::default()
1210 },
1211 )
1212 .unwrap();
1213 match result {
1214 ResolveResult::Provenance(envelope) => *envelope,
1215 _ => panic!("expected provenance envelope"),
1216 }
1217 }
1218
1219 #[test]
1220 fn test_resolve_with_provenance_returns_manifest_and_direct_file_entries() {
1221 let (tmp, resolver) = setup();
1222 let repo_path = tmp.path().join("toystory");
1223 write_mock_metadata(
1224 &repo_path,
1225 BTreeMap::from([
1226 (
1227 "character/woody.yaml".to_string(),
1228 BTreeMap::from([
1229 ("nap.provenance.kind".to_string(), "edit".to_string()),
1230 ("nap.provenance.model".to_string(), "gpt-5".to_string()),
1231 (
1232 "nap.provenance.long".to_string(),
1233 "x".repeat(MAX_CONDENSED_METADATA_VALUE_BYTES + 1),
1234 ),
1235 ]),
1236 ),
1237 (
1238 "character/woody/face_image.png".to_string(),
1239 BTreeMap::from([("nap.provenance.kind".to_string(), "generation".to_string())]),
1240 ),
1241 ]),
1242 );
1243
1244 let envelope = resolve_with_provenance(&resolver);
1245 assert_eq!(envelope.manifest.name, "Woody");
1246 assert_eq!(envelope.provenance.files.len(), 2);
1247
1248 let manifest_file = &envelope.provenance.files[0];
1249 assert_eq!(manifest_file.role, "manifest");
1250 assert_eq!(manifest_file.path.as_deref(), Some("character/woody.yaml"));
1251 assert_eq!(manifest_file.provenance["nap.provenance.kind"], "edit");
1252 assert!(
1253 manifest_file
1254 .provenance
1255 .get("nap.provenance.long")
1256 .is_none()
1257 );
1258
1259 let representation_file = &envelope.provenance.files[1];
1260 assert_eq!(representation_file.role, "representation");
1261 assert_eq!(representation_file.name.as_deref(), Some("face_image"));
1262 assert_eq!(
1263 representation_file.path.as_deref(),
1264 Some("character/woody/face_image.png")
1265 );
1266 assert_eq!(representation_file.uri.as_deref(), Some("face_image.png"));
1267 assert_eq!(representation_file.format.as_deref(), Some("png"));
1268 }
1269
1270 #[test]
1271 fn test_resolve_with_provenance_records_path_and_revision_metadata_lookups() {
1272 let (tmp, resolver) = setup();
1273 let repo_path = tmp.path().join("toystory");
1274 let envelope = resolve_with_provenance(&resolver);
1275
1276 let requests: Vec<BTreeMap<String, String>> = serde_json::from_str(
1277 &std::fs::read_to_string(repo_path.join(".mock_metadata_requests.json")).unwrap(),
1278 )
1279 .unwrap();
1280 assert_eq!(requests.len(), 2);
1281 assert_eq!(requests[0].get("path").unwrap(), "character/woody.yaml");
1282 assert_eq!(
1283 requests[0].get("revision").unwrap(),
1284 &envelope.provenance.revision
1285 );
1286 assert_eq!(
1287 requests[1].get("path").unwrap(),
1288 "character/woody/face_image.png"
1289 );
1290 assert_eq!(
1291 requests[1].get("revision").unwrap(),
1292 &envelope.provenance.revision
1293 );
1294 assert!(!requests.iter().any(|request| {
1295 request
1296 .get("path")
1297 .is_some_and(|path| path.starts_with("blake3:"))
1298 }));
1299 }
1300
1301 #[test]
1302 fn test_resolve_with_provenance_uses_none_for_missing_metadata() {
1303 let (_tmp, resolver) = setup();
1304 let envelope = resolve_with_provenance(&resolver);
1305 assert_eq!(envelope.provenance.files[0].provenance, "none");
1306 assert_eq!(envelope.provenance.files[1].provenance, "none");
1307 }
1308
1309 #[test]
1310 fn test_resolve_with_include_blobs_hydrates_known_readable_artifacts() {
1311 let (tmp, resolver) = setup();
1312 let repo_path = tmp.path().join("toystory");
1313 write_mock_metadata(
1314 &repo_path,
1315 BTreeMap::from([(
1316 "character/woody.yaml".to_string(),
1317 BTreeMap::from([
1318 (
1319 "nap.provenance.prompt.address".to_string(),
1320 "lore:prompt:1".to_string(),
1321 ),
1322 (
1323 "unrelated.artifact.address".to_string(),
1324 "lore:binary:1".to_string(),
1325 ),
1326 ]),
1327 )]),
1328 );
1329 write_mock_blobs(
1330 &repo_path,
1331 BTreeMap::from([("lore:prompt:1".to_string(), "Describe Woody".to_string())]),
1332 );
1333
1334 let result = resolver
1335 .resolve(
1336 "nap://toystory/character/woody",
1337 &ResolveOptions {
1338 provenance: Some(true),
1339 include_blobs: Some(true),
1340 ..Default::default()
1341 },
1342 )
1343 .unwrap();
1344 let ResolveResult::Provenance(envelope) = result else {
1345 panic!("expected provenance envelope");
1346 };
1347
1348 let blobs = &envelope.provenance.files[0].blobs;
1349 assert_eq!(blobs.len(), 1);
1350 assert_eq!(blobs["prompt"].content, "Describe Woody");
1351 assert!(!blobs["prompt"].truncated);
1352 }
1353
1354 #[test]
1355 fn test_include_blobs_implies_provenance_envelope() {
1356 let (_tmp, resolver) = setup();
1357 let result = resolver
1358 .resolve(
1359 "nap://toystory/character/woody",
1360 &ResolveOptions {
1361 include_blobs: Some(true),
1362 ..Default::default()
1363 },
1364 )
1365 .unwrap();
1366 assert!(matches!(result, ResolveResult::Provenance(_)));
1367 }
1368
1369 #[test]
1370 fn test_resolve_with_include_blobs_truncates_readable_artifacts() {
1371 let (tmp, resolver) = setup();
1372 let repo_path = tmp.path().join("toystory");
1373 write_mock_metadata(
1374 &repo_path,
1375 BTreeMap::from([(
1376 "character/woody.yaml".to_string(),
1377 BTreeMap::from([(
1378 "nap.provenance.prompt.address".to_string(),
1379 "lore:prompt:large".to_string(),
1380 )]),
1381 )]),
1382 );
1383 write_mock_blobs(
1384 &repo_path,
1385 BTreeMap::from([(
1386 "lore:prompt:large".to_string(),
1387 "x".repeat(MAX_HYDRATED_BLOB_BYTES + 10),
1388 )]),
1389 );
1390
1391 let result = resolver
1392 .resolve(
1393 "nap://toystory/character/woody",
1394 &ResolveOptions {
1395 provenance: Some(true),
1396 include_blobs: Some(true),
1397 ..Default::default()
1398 },
1399 )
1400 .unwrap();
1401 let ResolveResult::Provenance(envelope) = result else {
1402 panic!("expected provenance envelope");
1403 };
1404 let blob = &envelope.provenance.files[0].blobs["prompt"];
1405 assert!(blob.truncated);
1406 assert_eq!(blob.original_bytes, MAX_HYDRATED_BLOB_BYTES + 10);
1407 assert_eq!(blob.included_bytes, MAX_HYDRATED_BLOB_BYTES);
1408 assert_eq!(blob.content.len(), MAX_HYDRATED_BLOB_BYTES);
1409 }
1410
1411 #[test]
1412 fn test_provenance_rejects_unsafe_representation_paths() {
1413 let tmp = TempDir::new().unwrap();
1414 let repo_path = tmp.path().join("toystory");
1415 let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1416 let (mut manifest, _) = repo
1417 .create_entity(&EntityType::new("character"), "jessie", "Jessie", "test")
1418 .unwrap();
1419 manifest.set_representation(
1420 "unsafe",
1421 Representation {
1422 hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1423 .to_string(),
1424 format: "png".to_string(),
1425 uri: Some("../secret.png".to_string()),
1426 tier: None,
1427 },
1428 );
1429 use crate::commit::Change;
1430 repo.commit_manifest(
1431 &mut manifest,
1432 "add unsafe representation",
1433 "test",
1434 vec![Change::set(
1435 "representations.unsafe",
1436 None,
1437 "unsafe".to_string(),
1438 )],
1439 )
1440 .unwrap();
1441
1442 let resolver = Resolver::with_vcs_factory(
1443 tmp.path(),
1444 || Box::new(MockBackend::new()),
1445 ResolveConfig {
1446 default_branch: Some("main".to_string()),
1447 },
1448 );
1449 let err = resolver
1450 .resolve(
1451 "nap://toystory/character/jessie",
1452 &ResolveOptions {
1453 provenance: Some(true),
1454 ..Default::default()
1455 },
1456 )
1457 .unwrap_err();
1458 assert!(err.to_string().contains("unsafe representation URI"));
1459 }
1460
1461 #[test]
1462 fn test_resolve_with_fragment() {
1463 let (_tmp, resolver) = setup();
1464 let result = resolver
1465 .resolve(
1466 "nap://toystory/character/woody#properties.toy_type",
1467 &Default::default(),
1468 )
1469 .unwrap();
1470 match result {
1471 ResolveResult::Subtree(v) => {
1472 assert_eq!(v.as_str(), Some("plush"));
1473 }
1474 _ => panic!("expected subtree"),
1475 }
1476 }
1477
1478 #[test]
1479 fn test_resolve_with_options_path() {
1480 let (_tmp, resolver) = setup();
1481 let result = resolver
1482 .resolve(
1483 "nap://toystory/character/woody",
1484 &ResolveOptions {
1485 path: Some("properties.homeworld".to_string()),
1486 ..Default::default()
1487 },
1488 )
1489 .unwrap();
1490 match result {
1491 ResolveResult::Subtree(v) => {
1492 assert_eq!(v.as_str(), Some("nap://toystory/location/andys-room"));
1493 }
1494 _ => panic!("expected subtree"),
1495 }
1496 }
1497
1498 #[test]
1499 fn test_query_convenience() {
1500 let (_tmp, resolver) = setup();
1501 let result = resolver
1502 .query("nap://toystory/character/woody", "properties.toy_type")
1503 .unwrap();
1504 assert_eq!(result.as_str(), Some("plush"));
1505 }
1506
1507 #[test]
1508 fn test_list_repositories() {
1509 let (_tmp, resolver) = setup();
1510 let repositories = resolver.list_repositories().unwrap();
1511 assert!(repositories.contains(&"toystory".to_string()));
1512 }
1513
1514 #[test]
1515 fn test_resolve_not_found() {
1516 let (_tmp, resolver) = setup();
1517 let result = resolver.resolve("nap://toystory/character/nonexistent", &Default::default());
1518 assert!(result.is_err());
1519 }
1520
1521 #[test]
1522 fn test_resolve_without_scheme() {
1523 let (_tmp, resolver) = setup();
1524 let result = resolver
1525 .resolve("toystory/character/woody", &Default::default())
1526 .unwrap();
1527 match result {
1528 ResolveResult::Full(m) => {
1529 assert_eq!(m.name, "Woody");
1530 assert_eq!(m.entity_type.as_str(), "character");
1531 }
1532 _ => panic!("expected full manifest"),
1533 }
1534 }
1535
1536 #[test]
1537 fn test_resolve_without_scheme_with_fragment() {
1538 let (_tmp, resolver) = setup();
1539 let result = resolver
1540 .resolve(
1541 "toystory/character/woody#properties.toy_type",
1542 &Default::default(),
1543 )
1544 .unwrap();
1545 match result {
1546 ResolveResult::Subtree(v) => {
1547 assert_eq!(v.as_str(), Some("plush"));
1548 }
1549 _ => panic!("expected subtree"),
1550 }
1551 }
1552
1553 #[test]
1554 fn test_resolve_without_leading_slash() {
1555 let (_tmp, resolver) = setup();
1556 let result = resolver
1557 .resolve("toystory/character/woody", &Default::default())
1558 .unwrap();
1559 match result {
1560 ResolveResult::Full(m) => {
1561 assert_eq!(m.name, "Woody");
1562 }
1563 _ => panic!("expected full manifest"),
1564 }
1565 }
1566
1567 #[test]
1568 fn test_resolve_with_leading_slash_without_scheme() {
1569 let (_tmp, resolver) = setup();
1570 let result = resolver
1571 .resolve("/toystory/character/woody", &Default::default())
1572 .unwrap();
1573 match result {
1574 ResolveResult::Full(m) => {
1575 assert_eq!(m.name, "Woody");
1576 }
1577 _ => panic!("expected full manifest"),
1578 }
1579 }
1580
1581 #[test]
1582 fn presign_debug_output_redacts_secrets() {
1583 let options = PresignOptions {
1584 bearer_token: Some("secret-token".to_string()),
1585 ..Default::default()
1586 };
1587 let rendered = format!("{options:?}");
1588 assert!(rendered.contains("<redacted>"));
1589 assert!(!rendered.contains("secret-token"));
1590
1591 let result = PresignedRepresentation {
1592 url: "https://example.test/v1/presigned/x?token=secret".to_string(),
1593 expires_at: 1,
1594 revision: "revision".to_string(),
1595 repository_id: "repository".to_string(),
1596 address: "address".to_string(),
1597 representation: "face_image".to_string(),
1598 format: "png".to_string(),
1599 };
1600 assert!(!format!("{result:?}").contains("token=secret"));
1601 }
1602
1603 #[test]
1604 fn presigned_url_validation_rejects_cross_origin_and_extra_query_data() {
1605 let base = reqwest::Url::parse("https://lore.example.test").unwrap();
1606 let path = "/v1/presigned/repository/address";
1607 assert!(validate_presigned_url(&base, "//evil.test/x?token=x", path).is_err());
1608 assert!(
1609 validate_presigned_url(
1610 &base,
1611 "/v1/presigned/repository/address?token=x&redirect=https://evil.test",
1612 path,
1613 )
1614 .is_err()
1615 );
1616 assert!(
1617 validate_presigned_url(&base, "/v1/presigned/repository/address?token=opaque", path,)
1618 .is_ok()
1619 );
1620 }
1621
1622 #[tokio::test]
1623 async fn presign_uses_repository_id_and_file_context_separately() {
1624 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1625
1626 let (_tmp, resolver) = setup();
1627 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1628 let address = listener.local_addr().unwrap();
1629 let server = tokio::spawn(async move {
1630 let (mut stream, _) = listener.accept().await.unwrap();
1631 let mut request = vec![0_u8; 8192];
1632 let read = stream.read(&mut request).await.unwrap();
1633 let request = String::from_utf8_lossy(&request[..read]);
1634 assert!(request.contains(
1635 "POST /v1/repository/0123456789abcdef0123456789abcdef/content/9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-fedcba9876543210fedcba9876543210/presign"
1636 ));
1637 assert!(request.contains("authorization: Bearer test-token"));
1638 assert!(request.contains("\"ttl_seconds\":90"));
1639 let body = concat!(
1640 "{\"url_suffix\":\"/v1/presigned/0123456789abcdef0123456789abcdef/",
1641 "9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-",
1642 "fedcba9876543210fedcba9876543210?token=opaque\",\"expires_at\":12345}"
1643 );
1644 let response = format!(
1645 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1646 body.len(),
1647 body
1648 );
1649 stream.write_all(response.as_bytes()).await.unwrap();
1650 });
1651
1652 let result = resolver
1653 .presign_representation(
1654 "toystory/character/woody",
1655 "face_image",
1656 &PresignOptions {
1657 ttl_seconds: Some(90),
1658 lore_http_url: Some(format!("http://{address}")),
1659 bearer_token: Some("test-token".to_string()),
1660 ..Default::default()
1661 },
1662 )
1663 .await
1664 .unwrap();
1665 server.await.unwrap();
1666 assert_eq!(result.expires_at, 12345);
1667 assert_eq!(result.repository_id, "0123456789abcdef0123456789abcdef");
1668 assert!(result.url.ends_with("?token=opaque"));
1669 }
1670
1671 #[test]
1672 fn representation_paths_use_the_entity_asset_directory() {
1673 for (entity, asset, expected) in [
1674 (
1675 "nap://25th-chapter/character/nathan-gunn",
1676 "item.jpg",
1677 "character/nathan-gunn/item.jpg",
1678 ),
1679 (
1680 "nap://25th-chapter/world/25th-chapter",
1681 "images/map.png",
1682 "world/25th-chapter/images/map.png",
1683 ),
1684 ] {
1685 let uri: NapUri = entity.parse().unwrap();
1686 assert_eq!(
1687 Resolver::resolve_representation_path(&uri, asset).unwrap(),
1688 Some(expected.to_string()),
1689 );
1690 }
1691 }
1692
1693 #[tokio::test]
1694 async fn presign_rejects_fragment_and_conflicting_revision_selectors() {
1695 let (_tmp, resolver) = setup();
1696 assert!(
1697 resolver
1698 .presign_representation(
1699 "nap://toystory/character/woody#properties",
1700 "face_image",
1701 &PresignOptions::default(),
1702 )
1703 .await
1704 .is_err()
1705 );
1706 assert!(
1707 resolver
1708 .presign_representation(
1709 "nap://toystory/character/woody",
1710 "face_image",
1711 &PresignOptions {
1712 branch: Some("main".to_string()),
1713 commit: Some("abc".to_string()),
1714 ..Default::default()
1715 },
1716 )
1717 .await
1718 .unwrap_err()
1719 .to_string()
1720 .contains("either branch or commit")
1721 );
1722 }
1723}
1724
1725#[cfg(all(test, feature = "lore-integration"))]
1726mod lore_tests {
1727 use super::*;
1728 use crate::types::EntityType;
1729 use crate::vcs_lore::LoreBackend;
1730 use std::time::{SystemTime, UNIX_EPOCH};
1731 use tempfile::TempDir;
1732
1733 fn unique_suffix() -> u64 {
1734 SystemTime::now()
1735 .duration_since(UNIX_EPOCH)
1736 .unwrap()
1737 .as_nanos() as u64
1738 }
1739
1740 fn setup_lore() -> (TempDir, Resolver, String) {
1741 let repository = format!("lr-{}", unique_suffix());
1742 let tmp = TempDir::new().unwrap();
1743 let repo_path = tmp.path().join(&repository);
1744 let repo =
1745 Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
1746
1747 let (mut manifest, _) = repo
1749 .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1750 .unwrap();
1751
1752 manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1754 use crate::commit::Change;
1755 repo.commit_manifest(
1756 &mut manifest,
1757 "add Woody details",
1758 "test",
1759 vec![Change::set(
1760 "properties.toy_type",
1761 None,
1762 "plush".to_string(),
1763 )],
1764 )
1765 .unwrap();
1766
1767 let resolver = Resolver::with_vcs_factory(
1768 tmp.path(),
1769 || Box::new(LoreBackend::from_env()),
1770 ResolveConfig {
1771 default_branch: Some("main".to_string()),
1772 },
1773 );
1774 (tmp, resolver, repository)
1775 }
1776
1777 #[test]
1778 fn test_resolve_lore_full_manifest() {
1779 let (_tmp, resolver, repository) = setup_lore();
1780 let uri = format!("nap://{}/character/woody", repository);
1781 let result = resolver.resolve(&uri, &Default::default()).unwrap();
1782 match result {
1783 ResolveResult::Full(m) => {
1784 assert_eq!(m.name, "Woody");
1785 }
1786 _ => panic!("expected full manifest"),
1787 }
1788 }
1789}