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.manifest_path(), 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(&manifest_path, 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 manifest_path: &str,
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 manifest_dir = Path::new(manifest_path).parent().unwrap_or(Path::new(""));
946 Ok(Some(Self::path_to_lore_path(&manifest_dir.join(clean))))
947 }
948
949 fn path_to_lore_path(path: &Path) -> String {
950 path.components()
951 .filter_map(|component| match component {
952 Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
953 _ => None,
954 })
955 .collect::<Vec<_>>()
956 .join("/")
957 }
958
959 fn resolve_uri_recursive(
961 &self,
962 uri: &NapUri,
963 options: &ResolveOptions,
964 depth: usize,
965 visited: &mut std::collections::HashSet<String>,
966 ) -> Result<ResolveResult, NapError> {
967 let max_depth = options.max_depth.unwrap_or(10);
969 if depth >= max_depth {
970 debug!(depth, max_depth, "reached maximum recursion depth");
971 return self.resolve_uri_single(uri, options);
972 }
973
974 let uri_str = uri.to_string();
976 if visited.contains(&uri_str) {
977 debug!(uri = %uri_str, "detected circular reference, stopping recursion");
978 return self.resolve_uri_single(uri, options);
979 }
980 visited.insert(uri_str.clone());
981
982 debug!(uri = %uri_str, depth, "recursively resolving URI");
983
984 let result = self.resolve_uri_single(uri, options)?;
986
987 match result {
989 ResolveResult::Full(manifest) => {
990 let nested_uris = self.extract_nested_uris(&manifest);
991 if nested_uris.is_empty() {
992 debug!(uri = %uri_str, "no nested URIs found, returning manifest");
993 return Ok(ResolveResult::Full(manifest));
994 }
995
996 debug!(uri = %uri_str, count = nested_uris.len(), "found nested URIs, resolving recursively");
997
998 let mut resolved_manifest = (*manifest).clone();
1000 for nested_uri in nested_uris {
1001 let nested_uri_parsed: NapUri = nested_uri.parse()?;
1002
1003 let nested_result = self
1004 .resolve_uri_recursive(&nested_uri_parsed, options, depth + 1, visited)
1005 .map_err(|e| {
1006 NapError::Other(format!(
1007 "failed to resolve nested URI '{}' while resolving '{}': {}",
1008 nested_uri, uri_str, e
1009 ))
1010 })?;
1011
1012 if let ResolveResult::Full(nested_manifest) = nested_result {
1013 for (key, value) in nested_manifest.properties {
1016 resolved_manifest.properties.insert(key, value);
1017 }
1018 }
1019 }
1020
1021 Ok(ResolveResult::Full(Box::new(resolved_manifest)))
1022 }
1023 ResolveResult::Subtree(value) => {
1024 debug!("subtree query, skipping recursive resolution");
1026 Ok(ResolveResult::Subtree(value))
1027 }
1028 ResolveResult::Provenance(envelope) => Ok(ResolveResult::Provenance(envelope)),
1029 }
1030 }
1031
1032 fn extract_nested_uris(&self, manifest: &Manifest) -> Vec<String> {
1034 let mut uris = Vec::new();
1035
1036 for value in manifest.properties.values() {
1038 self.extract_uris_from_yaml_value(value, &mut uris);
1039 }
1040
1041 for value in manifest.references.values() {
1043 self.extract_uris_from_yaml_value(value, &mut uris);
1044 }
1045
1046 uris.sort();
1048 uris.dedup();
1049 uris
1050 }
1051
1052 fn extract_uris_from_yaml_value(&self, value: &serde_yaml::Value, uris: &mut Vec<String>) {
1054 match value {
1055 serde_yaml::Value::String(s) if s.starts_with("nap://") => {
1056 uris.push(s.clone());
1057 }
1058 serde_yaml::Value::Sequence(seq) => {
1059 for item in seq {
1060 self.extract_uris_from_yaml_value(item, uris);
1061 }
1062 }
1063 serde_yaml::Value::Mapping(map) => {
1064 for (_, v) in map {
1065 self.extract_uris_from_yaml_value(v, uris);
1066 }
1067 }
1068 _ => {}
1069 }
1070 }
1071
1072 pub fn query(&self, uri_str: &str, path: &str) -> Result<serde_json::Value, NapError> {
1074 let options = ResolveOptions {
1075 path: Some(path.to_string()),
1076 ..Default::default()
1077 };
1078 match self.resolve(uri_str, &options)? {
1079 ResolveResult::Subtree(v) => Ok(v),
1080 ResolveResult::Full(m) => m.to_json_value(),
1081 ResolveResult::Provenance(envelope) => serde_json::to_value(envelope).map_err(|e| {
1082 NapError::Other(format!("failed to serialize provenance envelope: {e}"))
1083 }),
1084 }
1085 }
1086
1087 pub fn list_repositories(&self) -> Result<Vec<String>, NapError> {
1089 let mut repositories = Vec::new();
1090 for entry in std::fs::read_dir(&self.base_path)? {
1091 let entry = entry?;
1092 let path = entry.path();
1093 if path.is_dir()
1095 && (path.join("repository.yaml").exists() || path.join("repository.yaml").exists())
1096 && let Some(name) = path.file_name().and_then(|n| n.to_str())
1097 {
1098 repositories.push(name.to_string());
1099 }
1100 }
1101 repositories.sort();
1102 Ok(repositories)
1103 }
1104}
1105
1106#[cfg(test)]
1107mod unit_tests {
1108 use super::*;
1109 use crate::manifest::Representation;
1110 use crate::test_utils::MockBackend;
1111 use crate::types::EntityType;
1112 use tempfile::TempDir;
1113
1114 fn setup() -> (TempDir, Resolver) {
1115 let tmp = TempDir::new().unwrap();
1116 let repo_path = tmp.path().join("toystory");
1117 let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1118
1119 let (mut manifest, _) = repo
1121 .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1122 .unwrap();
1123
1124 manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1126 manifest.set_property(
1127 "homeworld",
1128 serde_yaml::Value::String("nap://toystory/location/andys-room".to_string()),
1129 );
1130 manifest.add_reference(
1131 "appears_in",
1132 serde_yaml::Value::Sequence(vec![serde_yaml::Value::String(
1133 "nap://toystory/scene/pizza-planet".to_string(),
1134 )]),
1135 );
1136 manifest.set_representation(
1137 "face_image",
1138 Representation {
1139 hash: "blake3:9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a"
1140 .to_string(),
1141 format: "png".to_string(),
1142 uri: Some("face_image.png".to_string()),
1143 tier: None,
1144 },
1145 );
1146
1147 use crate::commit::Change;
1148 repo.commit_manifest(
1149 &mut manifest,
1150 "add Woody details",
1151 "test",
1152 vec![Change::set(
1153 "properties.toy_type",
1154 None,
1155 "plush".to_string(),
1156 )],
1157 )
1158 .unwrap();
1159
1160 let resolver = Resolver::with_vcs_factory(
1161 tmp.path(),
1162 || Box::new(MockBackend::new()),
1163 ResolveConfig {
1164 default_branch: Some("main".to_string()),
1165 },
1166 );
1167 (tmp, resolver)
1168 }
1169
1170 #[test]
1171 fn test_resolve_full_manifest() {
1172 let (_tmp, resolver) = setup();
1173 let result = resolver
1174 .resolve("nap://toystory/character/woody", &Default::default())
1175 .unwrap();
1176 match result {
1177 ResolveResult::Full(m) => {
1178 assert_eq!(m.name, "Woody");
1179 assert_eq!(m.entity_type.as_str(), "character");
1180 }
1181 _ => panic!("expected full manifest"),
1182 }
1183 }
1184
1185 fn write_mock_metadata(repo_path: &Path, metadata: BTreeMap<String, BTreeMap<String, String>>) {
1186 std::fs::write(
1187 repo_path.join(".mock_file_metadata.json"),
1188 serde_json::to_string(&metadata).unwrap(),
1189 )
1190 .unwrap();
1191 }
1192
1193 fn write_mock_blobs(repo_path: &Path, blobs: BTreeMap<String, String>) {
1194 std::fs::write(
1195 repo_path.join(".mock_provenance_blobs.json"),
1196 serde_json::to_string(&blobs).unwrap(),
1197 )
1198 .unwrap();
1199 }
1200
1201 fn resolve_with_provenance(resolver: &Resolver) -> ResolveEnvelope {
1202 let result = resolver
1203 .resolve(
1204 "nap://toystory/character/woody",
1205 &ResolveOptions {
1206 provenance: Some(true),
1207 ..Default::default()
1208 },
1209 )
1210 .unwrap();
1211 match result {
1212 ResolveResult::Provenance(envelope) => *envelope,
1213 _ => panic!("expected provenance envelope"),
1214 }
1215 }
1216
1217 #[test]
1218 fn test_resolve_with_provenance_returns_manifest_and_direct_file_entries() {
1219 let (tmp, resolver) = setup();
1220 let repo_path = tmp.path().join("toystory");
1221 write_mock_metadata(
1222 &repo_path,
1223 BTreeMap::from([
1224 (
1225 "character/woody.yaml".to_string(),
1226 BTreeMap::from([
1227 ("nap.provenance.kind".to_string(), "edit".to_string()),
1228 ("nap.provenance.model".to_string(), "gpt-5".to_string()),
1229 (
1230 "nap.provenance.long".to_string(),
1231 "x".repeat(MAX_CONDENSED_METADATA_VALUE_BYTES + 1),
1232 ),
1233 ]),
1234 ),
1235 (
1236 "character/face_image.png".to_string(),
1237 BTreeMap::from([("nap.provenance.kind".to_string(), "generation".to_string())]),
1238 ),
1239 ]),
1240 );
1241
1242 let envelope = resolve_with_provenance(&resolver);
1243 assert_eq!(envelope.manifest.name, "Woody");
1244 assert_eq!(envelope.provenance.files.len(), 2);
1245
1246 let manifest_file = &envelope.provenance.files[0];
1247 assert_eq!(manifest_file.role, "manifest");
1248 assert_eq!(manifest_file.path.as_deref(), Some("character/woody.yaml"));
1249 assert_eq!(manifest_file.provenance["nap.provenance.kind"], "edit");
1250 assert!(
1251 manifest_file
1252 .provenance
1253 .get("nap.provenance.long")
1254 .is_none()
1255 );
1256
1257 let representation_file = &envelope.provenance.files[1];
1258 assert_eq!(representation_file.role, "representation");
1259 assert_eq!(representation_file.name.as_deref(), Some("face_image"));
1260 assert_eq!(
1261 representation_file.path.as_deref(),
1262 Some("character/face_image.png")
1263 );
1264 assert_eq!(representation_file.uri.as_deref(), Some("face_image.png"));
1265 assert_eq!(representation_file.format.as_deref(), Some("png"));
1266 }
1267
1268 #[test]
1269 fn test_resolve_with_provenance_records_path_and_revision_metadata_lookups() {
1270 let (tmp, resolver) = setup();
1271 let repo_path = tmp.path().join("toystory");
1272 let envelope = resolve_with_provenance(&resolver);
1273
1274 let requests: Vec<BTreeMap<String, String>> = serde_json::from_str(
1275 &std::fs::read_to_string(repo_path.join(".mock_metadata_requests.json")).unwrap(),
1276 )
1277 .unwrap();
1278 assert_eq!(requests.len(), 2);
1279 assert_eq!(requests[0].get("path").unwrap(), "character/woody.yaml");
1280 assert_eq!(
1281 requests[0].get("revision").unwrap(),
1282 &envelope.provenance.revision
1283 );
1284 assert_eq!(requests[1].get("path").unwrap(), "character/face_image.png");
1285 assert_eq!(
1286 requests[1].get("revision").unwrap(),
1287 &envelope.provenance.revision
1288 );
1289 assert!(!requests.iter().any(|request| {
1290 request
1291 .get("path")
1292 .is_some_and(|path| path.starts_with("blake3:"))
1293 }));
1294 }
1295
1296 #[test]
1297 fn test_resolve_with_provenance_uses_none_for_missing_metadata() {
1298 let (_tmp, resolver) = setup();
1299 let envelope = resolve_with_provenance(&resolver);
1300 assert_eq!(envelope.provenance.files[0].provenance, "none");
1301 assert_eq!(envelope.provenance.files[1].provenance, "none");
1302 }
1303
1304 #[test]
1305 fn test_resolve_with_include_blobs_hydrates_known_readable_artifacts() {
1306 let (tmp, resolver) = setup();
1307 let repo_path = tmp.path().join("toystory");
1308 write_mock_metadata(
1309 &repo_path,
1310 BTreeMap::from([(
1311 "character/woody.yaml".to_string(),
1312 BTreeMap::from([
1313 (
1314 "nap.provenance.prompt.address".to_string(),
1315 "lore:prompt:1".to_string(),
1316 ),
1317 (
1318 "unrelated.artifact.address".to_string(),
1319 "lore:binary:1".to_string(),
1320 ),
1321 ]),
1322 )]),
1323 );
1324 write_mock_blobs(
1325 &repo_path,
1326 BTreeMap::from([("lore:prompt:1".to_string(), "Describe Woody".to_string())]),
1327 );
1328
1329 let result = resolver
1330 .resolve(
1331 "nap://toystory/character/woody",
1332 &ResolveOptions {
1333 provenance: Some(true),
1334 include_blobs: Some(true),
1335 ..Default::default()
1336 },
1337 )
1338 .unwrap();
1339 let ResolveResult::Provenance(envelope) = result else {
1340 panic!("expected provenance envelope");
1341 };
1342
1343 let blobs = &envelope.provenance.files[0].blobs;
1344 assert_eq!(blobs.len(), 1);
1345 assert_eq!(blobs["prompt"].content, "Describe Woody");
1346 assert!(!blobs["prompt"].truncated);
1347 }
1348
1349 #[test]
1350 fn test_include_blobs_implies_provenance_envelope() {
1351 let (_tmp, resolver) = setup();
1352 let result = resolver
1353 .resolve(
1354 "nap://toystory/character/woody",
1355 &ResolveOptions {
1356 include_blobs: Some(true),
1357 ..Default::default()
1358 },
1359 )
1360 .unwrap();
1361 assert!(matches!(result, ResolveResult::Provenance(_)));
1362 }
1363
1364 #[test]
1365 fn test_resolve_with_include_blobs_truncates_readable_artifacts() {
1366 let (tmp, resolver) = setup();
1367 let repo_path = tmp.path().join("toystory");
1368 write_mock_metadata(
1369 &repo_path,
1370 BTreeMap::from([(
1371 "character/woody.yaml".to_string(),
1372 BTreeMap::from([(
1373 "nap.provenance.prompt.address".to_string(),
1374 "lore:prompt:large".to_string(),
1375 )]),
1376 )]),
1377 );
1378 write_mock_blobs(
1379 &repo_path,
1380 BTreeMap::from([(
1381 "lore:prompt:large".to_string(),
1382 "x".repeat(MAX_HYDRATED_BLOB_BYTES + 10),
1383 )]),
1384 );
1385
1386 let result = resolver
1387 .resolve(
1388 "nap://toystory/character/woody",
1389 &ResolveOptions {
1390 provenance: Some(true),
1391 include_blobs: Some(true),
1392 ..Default::default()
1393 },
1394 )
1395 .unwrap();
1396 let ResolveResult::Provenance(envelope) = result else {
1397 panic!("expected provenance envelope");
1398 };
1399 let blob = &envelope.provenance.files[0].blobs["prompt"];
1400 assert!(blob.truncated);
1401 assert_eq!(blob.original_bytes, MAX_HYDRATED_BLOB_BYTES + 10);
1402 assert_eq!(blob.included_bytes, MAX_HYDRATED_BLOB_BYTES);
1403 assert_eq!(blob.content.len(), MAX_HYDRATED_BLOB_BYTES);
1404 }
1405
1406 #[test]
1407 fn test_provenance_rejects_unsafe_representation_paths() {
1408 let tmp = TempDir::new().unwrap();
1409 let repo_path = tmp.path().join("toystory");
1410 let repo = Repository::init(&repo_path, "toystory", Box::new(MockBackend::new())).unwrap();
1411 let (mut manifest, _) = repo
1412 .create_entity(&EntityType::new("character"), "jessie", "Jessie", "test")
1413 .unwrap();
1414 manifest.set_representation(
1415 "unsafe",
1416 Representation {
1417 hash: "blake3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
1418 .to_string(),
1419 format: "png".to_string(),
1420 uri: Some("../secret.png".to_string()),
1421 tier: None,
1422 },
1423 );
1424 use crate::commit::Change;
1425 repo.commit_manifest(
1426 &mut manifest,
1427 "add unsafe representation",
1428 "test",
1429 vec![Change::set(
1430 "representations.unsafe",
1431 None,
1432 "unsafe".to_string(),
1433 )],
1434 )
1435 .unwrap();
1436
1437 let resolver = Resolver::with_vcs_factory(
1438 tmp.path(),
1439 || Box::new(MockBackend::new()),
1440 ResolveConfig {
1441 default_branch: Some("main".to_string()),
1442 },
1443 );
1444 let err = resolver
1445 .resolve(
1446 "nap://toystory/character/jessie",
1447 &ResolveOptions {
1448 provenance: Some(true),
1449 ..Default::default()
1450 },
1451 )
1452 .unwrap_err();
1453 assert!(err.to_string().contains("unsafe representation URI"));
1454 }
1455
1456 #[test]
1457 fn test_resolve_with_fragment() {
1458 let (_tmp, resolver) = setup();
1459 let result = resolver
1460 .resolve(
1461 "nap://toystory/character/woody#properties.toy_type",
1462 &Default::default(),
1463 )
1464 .unwrap();
1465 match result {
1466 ResolveResult::Subtree(v) => {
1467 assert_eq!(v.as_str(), Some("plush"));
1468 }
1469 _ => panic!("expected subtree"),
1470 }
1471 }
1472
1473 #[test]
1474 fn test_resolve_with_options_path() {
1475 let (_tmp, resolver) = setup();
1476 let result = resolver
1477 .resolve(
1478 "nap://toystory/character/woody",
1479 &ResolveOptions {
1480 path: Some("properties.homeworld".to_string()),
1481 ..Default::default()
1482 },
1483 )
1484 .unwrap();
1485 match result {
1486 ResolveResult::Subtree(v) => {
1487 assert_eq!(v.as_str(), Some("nap://toystory/location/andys-room"));
1488 }
1489 _ => panic!("expected subtree"),
1490 }
1491 }
1492
1493 #[test]
1494 fn test_query_convenience() {
1495 let (_tmp, resolver) = setup();
1496 let result = resolver
1497 .query("nap://toystory/character/woody", "properties.toy_type")
1498 .unwrap();
1499 assert_eq!(result.as_str(), Some("plush"));
1500 }
1501
1502 #[test]
1503 fn test_list_repositories() {
1504 let (_tmp, resolver) = setup();
1505 let repositories = resolver.list_repositories().unwrap();
1506 assert!(repositories.contains(&"toystory".to_string()));
1507 }
1508
1509 #[test]
1510 fn test_resolve_not_found() {
1511 let (_tmp, resolver) = setup();
1512 let result = resolver.resolve("nap://toystory/character/nonexistent", &Default::default());
1513 assert!(result.is_err());
1514 }
1515
1516 #[test]
1517 fn test_resolve_without_scheme() {
1518 let (_tmp, resolver) = setup();
1519 let result = resolver
1520 .resolve("toystory/character/woody", &Default::default())
1521 .unwrap();
1522 match result {
1523 ResolveResult::Full(m) => {
1524 assert_eq!(m.name, "Woody");
1525 assert_eq!(m.entity_type.as_str(), "character");
1526 }
1527 _ => panic!("expected full manifest"),
1528 }
1529 }
1530
1531 #[test]
1532 fn test_resolve_without_scheme_with_fragment() {
1533 let (_tmp, resolver) = setup();
1534 let result = resolver
1535 .resolve(
1536 "toystory/character/woody#properties.toy_type",
1537 &Default::default(),
1538 )
1539 .unwrap();
1540 match result {
1541 ResolveResult::Subtree(v) => {
1542 assert_eq!(v.as_str(), Some("plush"));
1543 }
1544 _ => panic!("expected subtree"),
1545 }
1546 }
1547
1548 #[test]
1549 fn test_resolve_without_leading_slash() {
1550 let (_tmp, resolver) = setup();
1551 let result = resolver
1552 .resolve("toystory/character/woody", &Default::default())
1553 .unwrap();
1554 match result {
1555 ResolveResult::Full(m) => {
1556 assert_eq!(m.name, "Woody");
1557 }
1558 _ => panic!("expected full manifest"),
1559 }
1560 }
1561
1562 #[test]
1563 fn test_resolve_with_leading_slash_without_scheme() {
1564 let (_tmp, resolver) = setup();
1565 let result = resolver
1566 .resolve("/toystory/character/woody", &Default::default())
1567 .unwrap();
1568 match result {
1569 ResolveResult::Full(m) => {
1570 assert_eq!(m.name, "Woody");
1571 }
1572 _ => panic!("expected full manifest"),
1573 }
1574 }
1575
1576 #[test]
1577 fn presign_debug_output_redacts_secrets() {
1578 let options = PresignOptions {
1579 bearer_token: Some("secret-token".to_string()),
1580 ..Default::default()
1581 };
1582 let rendered = format!("{options:?}");
1583 assert!(rendered.contains("<redacted>"));
1584 assert!(!rendered.contains("secret-token"));
1585
1586 let result = PresignedRepresentation {
1587 url: "https://example.test/v1/presigned/x?token=secret".to_string(),
1588 expires_at: 1,
1589 revision: "revision".to_string(),
1590 repository_id: "repository".to_string(),
1591 address: "address".to_string(),
1592 representation: "face_image".to_string(),
1593 format: "png".to_string(),
1594 };
1595 assert!(!format!("{result:?}").contains("token=secret"));
1596 }
1597
1598 #[test]
1599 fn presigned_url_validation_rejects_cross_origin_and_extra_query_data() {
1600 let base = reqwest::Url::parse("https://lore.example.test").unwrap();
1601 let path = "/v1/presigned/repository/address";
1602 assert!(validate_presigned_url(&base, "//evil.test/x?token=x", path).is_err());
1603 assert!(
1604 validate_presigned_url(
1605 &base,
1606 "/v1/presigned/repository/address?token=x&redirect=https://evil.test",
1607 path,
1608 )
1609 .is_err()
1610 );
1611 assert!(
1612 validate_presigned_url(&base, "/v1/presigned/repository/address?token=opaque", path,)
1613 .is_ok()
1614 );
1615 }
1616
1617 #[tokio::test]
1618 async fn presign_uses_repository_id_and_file_context_separately() {
1619 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1620
1621 let (_tmp, resolver) = setup();
1622 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1623 let address = listener.local_addr().unwrap();
1624 let server = tokio::spawn(async move {
1625 let (mut stream, _) = listener.accept().await.unwrap();
1626 let mut request = vec![0_u8; 8192];
1627 let read = stream.read(&mut request).await.unwrap();
1628 let request = String::from_utf8_lossy(&request[..read]);
1629 assert!(request.contains(
1630 "POST /v1/repository/0123456789abcdef0123456789abcdef/content/9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-fedcba9876543210fedcba9876543210/presign"
1631 ));
1632 assert!(request.contains("authorization: Bearer test-token"));
1633 assert!(request.contains("\"ttl_seconds\":90"));
1634 let body = concat!(
1635 "{\"url_suffix\":\"/v1/presigned/0123456789abcdef0123456789abcdef/",
1636 "9753abf79e5aef60bd95ab76c1e5a14d01239beb37ff9897b6af8e040eb2413a-",
1637 "fedcba9876543210fedcba9876543210?token=opaque\",\"expires_at\":12345}"
1638 );
1639 let response = format!(
1640 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1641 body.len(),
1642 body
1643 );
1644 stream.write_all(response.as_bytes()).await.unwrap();
1645 });
1646
1647 let result = resolver
1648 .presign_representation(
1649 "nap://toystory/character/woody",
1650 "face_image",
1651 &PresignOptions {
1652 ttl_seconds: Some(90),
1653 lore_http_url: Some(format!("http://{address}")),
1654 bearer_token: Some("test-token".to_string()),
1655 ..Default::default()
1656 },
1657 )
1658 .await
1659 .unwrap();
1660 server.await.unwrap();
1661 assert_eq!(result.expires_at, 12345);
1662 assert_eq!(result.repository_id, "0123456789abcdef0123456789abcdef");
1663 assert!(result.url.ends_with("?token=opaque"));
1664 }
1665
1666 #[tokio::test]
1667 async fn presign_rejects_fragment_and_conflicting_revision_selectors() {
1668 let (_tmp, resolver) = setup();
1669 assert!(
1670 resolver
1671 .presign_representation(
1672 "nap://toystory/character/woody#properties",
1673 "face_image",
1674 &PresignOptions::default(),
1675 )
1676 .await
1677 .is_err()
1678 );
1679 assert!(
1680 resolver
1681 .presign_representation(
1682 "nap://toystory/character/woody",
1683 "face_image",
1684 &PresignOptions {
1685 branch: Some("main".to_string()),
1686 commit: Some("abc".to_string()),
1687 ..Default::default()
1688 },
1689 )
1690 .await
1691 .unwrap_err()
1692 .to_string()
1693 .contains("either branch or commit")
1694 );
1695 }
1696}
1697
1698#[cfg(all(test, feature = "lore-integration"))]
1699mod lore_tests {
1700 use super::*;
1701 use crate::types::EntityType;
1702 use crate::vcs_lore::LoreBackend;
1703 use std::time::{SystemTime, UNIX_EPOCH};
1704 use tempfile::TempDir;
1705
1706 fn unique_suffix() -> u64 {
1707 SystemTime::now()
1708 .duration_since(UNIX_EPOCH)
1709 .unwrap()
1710 .as_nanos() as u64
1711 }
1712
1713 fn setup_lore() -> (TempDir, Resolver, String) {
1714 let repository = format!("lr-{}", unique_suffix());
1715 let tmp = TempDir::new().unwrap();
1716 let repo_path = tmp.path().join(&repository);
1717 let repo =
1718 Repository::init(&repo_path, &repository, Box::new(LoreBackend::from_env())).unwrap();
1719
1720 let (mut manifest, _) = repo
1722 .create_entity(&EntityType::new("character"), "woody", "Woody", "test")
1723 .unwrap();
1724
1725 manifest.set_property("toy_type", serde_yaml::Value::String("plush".to_string()));
1727 use crate::commit::Change;
1728 repo.commit_manifest(
1729 &mut manifest,
1730 "add Woody details",
1731 "test",
1732 vec![Change::set(
1733 "properties.toy_type",
1734 None,
1735 "plush".to_string(),
1736 )],
1737 )
1738 .unwrap();
1739
1740 let resolver = Resolver::with_vcs_factory(
1741 tmp.path(),
1742 || Box::new(LoreBackend::from_env()),
1743 ResolveConfig {
1744 default_branch: Some("main".to_string()),
1745 },
1746 );
1747 (tmp, resolver, repository)
1748 }
1749
1750 #[test]
1751 fn test_resolve_lore_full_manifest() {
1752 let (_tmp, resolver, repository) = setup_lore();
1753 let uri = format!("nap://{}/character/woody", repository);
1754 let result = resolver.resolve(&uri, &Default::default()).unwrap();
1755 match result {
1756 ResolveResult::Full(m) => {
1757 assert_eq!(m.name, "Woody");
1758 }
1759 _ => panic!("expected full manifest"),
1760 }
1761 }
1762}