1use std::io::{self, ErrorKind, Read};
2use std::sync::Arc;
3
4use crate::bundle::{verify_manifest_signature, BundleManifest, ManifestVerification};
5use calimero_primitives::application::{
6 Application, ApplicationBlob, ApplicationId, ApplicationSource,
7};
8use calimero_primitives::blobs::BlobId;
9use calimero_primitives::hash::Hash;
10use calimero_store::{key, types};
11use camino::{Utf8Path, Utf8PathBuf};
12use eyre::bail;
13use flate2::read::GzDecoder;
14use futures_util::{io::Cursor, TryStreamExt};
15use reqwest::Url;
16use semver::Version;
17use serde_json;
18use sha2::{Digest, Sha256};
19use std::fs;
20use tar::Archive;
21use tokio::fs::File;
22use tokio_util::compat::TokioAsyncReadCompatExt;
23use tracing::{debug, trace, warn};
24
25use super::NodeClient;
26
27impl NodeClient {
28 pub fn get_application(
29 &self,
30 application_id: &ApplicationId,
31 ) -> eyre::Result<Option<Application>> {
32 let handle = self.datastore.handle();
33
34 let key = key::ApplicationMeta::new(*application_id);
35
36 let Some(application) = handle.get(&key)? else {
37 return Ok(None);
38 };
39
40 let application = Application::new(
41 *application_id,
42 ApplicationBlob {
43 bytecode: application.bytecode.blob_id(),
44 compiled: application.compiled.blob_id(),
45 },
46 application.size,
47 application.source.parse()?,
48 application.metadata.into_vec(),
49 );
50
51 Ok(Some(application))
52 }
53
54 pub async fn get_application_bytes(
55 &self,
56 application_id: &ApplicationId,
57 ) -> eyre::Result<Option<Arc<[u8]>>> {
58 let handle = self.datastore.handle();
59
60 let key = key::ApplicationMeta::new(*application_id);
61
62 let Some(application) = handle.get(&key)? else {
63 return Ok(None);
64 };
65
66 let is_bundle =
70 application.package.as_ref() != "unknown" && application.version.as_ref() != "0.0.0";
71
72 let Some(blob_bytes) = self
74 .get_blob_bytes(&application.bytecode.blob_id(), None)
75 .await?
76 else {
77 bail!("fatal: application points to dangling blob");
78 };
79
80 if is_bundle {
81 let blob_bytes_clone = Arc::clone(&blob_bytes);
85 let (_, manifest) = tokio::task::spawn_blocking(move || {
86 Self::verify_and_extract_manifest(&blob_bytes_clone)
87 })
88 .await??;
89 let package = &manifest.package;
90 let version = &manifest.app_version;
91
92 let blobstore_root = self.blobstore.root_path();
94 let node_root = blobstore_root
95 .parent()
96 .ok_or_else(|| eyre::eyre!("blobstore root has no parent"))?
97 .to_path_buf();
98 let extract_dir = node_root
99 .join("applications")
100 .join(package)
101 .join(version)
102 .join("extracted");
103
104 let wasm_relative_path = manifest
106 .wasm
107 .as_ref()
108 .map(|w| w.path.as_str())
109 .unwrap_or("app.wasm");
110
111 if wasm_relative_path.contains("..") {
114 bail!(
115 "WASM path traversal detected: {} contains '..' component",
116 wasm_relative_path
117 );
118 }
119
120 let wasm_path = extract_dir.join(wasm_relative_path);
121
122 if wasm_path.exists() {
125 let canonical_wasm = wasm_path.canonicalize_utf8()?;
126
127 let canonical_extract = if extract_dir.exists() {
130 extract_dir.canonicalize_utf8()?
131 } else {
132 let wasm_parent = wasm_path
135 .parent()
136 .ok_or_else(|| eyre::eyre!("WASM path has no parent directory"))?;
137 let wasm_parent_canonical = wasm_parent.canonicalize_utf8()?;
138
139 let relative_depth = wasm_relative_path
141 .split('/')
142 .filter(|s| !s.is_empty())
143 .count()
144 .saturating_sub(1); let mut canonical_extract_candidate = wasm_parent_canonical.clone();
148 for _ in 0..relative_depth {
149 if let Some(parent) = canonical_extract_candidate.parent() {
150 canonical_extract_candidate = parent.to_path_buf();
151 } else {
152 bail!("Cannot reconstruct extract_dir from WASM path");
153 }
154 }
155
156 canonical_extract_candidate.try_into().map_err(|_| {
157 eyre::eyre!("Failed to convert extract_dir path to Utf8PathBuf")
158 })?
159 };
160
161 if !canonical_wasm.starts_with(&canonical_extract) {
163 bail!(
164 "WASM path traversal detected: {} escapes extraction directory {}",
165 wasm_relative_path,
166 extract_dir
167 );
168 }
169 }
170
171 if wasm_path.exists() {
172 let wasm_bytes = tokio::fs::read(&wasm_path).await?;
173 return Ok(Some(wasm_bytes.into()));
174 } else {
175 warn!(
177 wasm_path = %wasm_path,
178 "extracted WASM not found, attempting to re-extract from bundle and persist to disk"
179 );
180
181 let marker_file_path = extract_dir.join(".extracted");
183 if marker_file_path.exists() {
184 let _ = tokio::fs::remove_file(&marker_file_path).await;
185 }
186
187 let blob_bytes_clone = Arc::clone(&blob_bytes);
191 let manifest_clone = manifest.clone();
192 let extract_dir_clone = extract_dir.to_path_buf();
193 let node_root_clone = node_root.to_path_buf();
194 let package_clone = package.to_string();
195 let version_clone = version.to_string();
196 tokio::task::spawn_blocking(move || {
197 Self::extract_bundle_artifacts(
198 &blob_bytes_clone,
199 &manifest_clone,
200 &extract_dir_clone,
201 &node_root_clone,
202 &package_clone,
203 &version_clone,
204 )
205 })
206 .await??;
207
208 if wasm_path.exists() {
210 let wasm_bytes = tokio::fs::read(&wasm_path).await?;
211 return Ok(Some(wasm_bytes.into()));
212 }
213
214 bail!("WASM file not found in bundle after extraction");
215 }
216 }
217
218 Ok(Some(blob_bytes))
221 }
222
223 pub fn has_application(&self, application_id: &ApplicationId) -> eyre::Result<bool> {
224 let handle = self.datastore.handle();
225
226 let key = key::ApplicationMeta::new(*application_id);
227
228 if let Some(application) = handle.get(&key)? {
229 return self.has_blob(&application.bytecode.blob_id());
230 }
231
232 Ok(false)
233 }
234
235 pub fn install_application(
236 &self,
237 blob_id: &BlobId,
238 size: u64,
239 source: &ApplicationSource,
240 metadata: Vec<u8>,
241 package: &str,
242 version: &str,
243 signer_id: Option<&str>,
244 is_bundle: bool,
245 ) -> eyre::Result<ApplicationId> {
246 let signer_id_str = signer_id.unwrap_or("");
253
254 let application = types::ApplicationMeta::new(
255 key::BlobMeta::new(*blob_id),
256 size,
257 source.to_string().into_boxed_str(),
258 metadata.into_boxed_slice(),
259 key::BlobMeta::new(BlobId::from([0; 32])),
260 package.to_owned().into_boxed_str(),
261 version.to_owned().into_boxed_str(),
262 signer_id_str.to_owned().into_boxed_str(),
263 );
264
265 let application_id = if is_bundle {
266 let components = (&application.package, &application.signer_id);
270 ApplicationId::from(*Hash::hash_borsh(&components)?)
271 } else {
272 let components = (
275 application.bytecode,
276 application.size,
277 &application.source,
278 &application.metadata,
279 );
280 ApplicationId::from(*Hash::hash_borsh(&components)?)
281 };
282
283 let mut handle = self.datastore.handle();
284
285 let key = key::ApplicationMeta::new(application_id);
286
287 handle.put(&key, &application)?;
288
289 Ok(application_id)
290 }
291
292 fn is_bundle_archive(path: &Utf8Path) -> bool {
294 path.extension().map(|ext| ext == "mpk").unwrap_or(false)
295 }
296
297 pub async fn install_application_from_path(
298 &self,
299 path: Utf8PathBuf,
300 metadata: Vec<u8>,
301 package: Option<String>,
302 version: Option<String>,
303 ) -> eyre::Result<ApplicationId> {
304 let metadata_len = metadata.len();
305 debug!(
306 path = %path,
307 metadata_len,
308 "install_application_from_path started"
309 );
310
311 let path = match path.canonicalize_utf8() {
312 Ok(canonicalized) => canonicalized,
313 Err(err) if err.kind() == ErrorKind::NotFound => {
314 bail!("application file not found at {}", path);
315 }
316 Err(err) => return Err(err.into()),
317 };
318 trace!(path = %path, "application path canonicalized");
319
320 if Self::is_bundle_archive(&path) {
322 return self.install_bundle_from_path(path, metadata).await;
323 }
324
325 let package = package.as_deref().unwrap_or("unknown");
327 let version = version.as_deref().unwrap_or("0.0.0");
328
329 let file = match File::open(&path).await {
331 Ok(file) => file,
332 Err(err) if err.kind() == ErrorKind::NotFound => {
333 bail!("application file not found at {}", path);
334 }
335 Err(err) => return Err(err.into()),
336 };
337 trace!(path = %path, "application file opened");
338
339 let expected_size = file.metadata().await?.len();
340 debug!(
341 path = %path,
342 expected_size,
343 "install_application_from_path discovered file size"
344 );
345
346 let (blob_id, size) = self
347 .add_blob(file.compat(), Some(expected_size), None)
348 .await?;
349 debug!(
350 %blob_id,
351 expected_size,
352 stored_size = size,
353 "application blob added via add_blob"
354 );
355
356 let Ok(uri) = Url::from_file_path(path) else {
357 bail!("non-absolute path")
358 };
359
360 self.install_application(
361 &blob_id,
362 size,
363 &uri.as_str().parse()?,
364 metadata,
365 package,
366 version,
367 None, false, )
370 }
371
372 pub async fn install_application_from_url(
373 &self,
374 url: Url,
375 metadata: Vec<u8>,
376 expected_hash: Option<&Hash>,
377 ) -> eyre::Result<ApplicationId> {
378 let uri = url.as_str().parse()?;
379
380 let response = reqwest::Client::new().get(url.clone()).send().await?;
381
382 let expected_size = response.content_length();
383
384 let is_bundle = url.path().ends_with(".mpk");
386
387 if is_bundle {
388 let bundle_data = Arc::new(response.bytes().await?.to_vec());
390
391 let cursor = Cursor::new(bundle_data.as_slice());
393 let (bundle_blob_id, stored_size) = self
394 .add_blob(cursor, Some(bundle_data.len() as u64), expected_hash)
395 .await?;
396
397 debug!(
398 %bundle_blob_id,
399 bundle_size = bundle_data.len(),
400 stored_size,
401 "bundle downloaded and stored as blob"
402 );
403
404 let bundle_data_clone = Arc::clone(&bundle_data);
407 let (verification, manifest) = tokio::task::spawn_blocking(move || {
408 Self::verify_and_extract_manifest(&bundle_data_clone)
409 })
410 .await??;
411
412 let signer_id = verification.signer_id;
413
414 let package = &manifest.package;
416 let version = &manifest.app_version;
417
418 let blobstore_root = self.blobstore.root_path();
422 let node_root = blobstore_root
423 .parent()
424 .ok_or_else(|| eyre::eyre!("blobstore root has no parent"))?
425 .to_path_buf();
426 let extract_dir = node_root
428 .join("applications")
429 .join(package)
430 .join(version)
431 .join("extracted");
432
433 let bundle_data_clone = Arc::clone(&bundle_data);
435 let manifest_clone = manifest.clone();
436 let extract_dir_clone = extract_dir.clone();
437 let node_root_clone = node_root.clone();
438 let package_clone = package.to_string();
439 let version_clone = version.to_string();
440 tokio::task::spawn_blocking(move || {
441 Self::extract_bundle_artifacts(
442 &bundle_data_clone,
443 &manifest_clone,
444 &extract_dir_clone,
445 &node_root_clone,
446 &package_clone,
447 &version_clone,
448 )
449 })
450 .await??;
451
452 let bundle_metadata = {
455 let mut metadata_obj = serde_json::Map::new();
456 metadata_obj.insert(
457 "package".to_string(),
458 serde_json::Value::String(package.clone()),
459 );
460 metadata_obj.insert(
461 "version".to_string(),
462 serde_json::Value::String(version.clone()),
463 );
464
465 if let Some(ref metadata) = manifest.metadata {
466 metadata_obj.insert(
467 "name".to_string(),
468 serde_json::Value::String(metadata.name.clone()),
469 );
470 if let Some(ref description) = metadata.description {
471 metadata_obj.insert(
472 "description".to_string(),
473 serde_json::Value::String(description.clone()),
474 );
475 }
476 if let Some(ref icon) = metadata.icon {
477 metadata_obj
478 .insert("icon".to_string(), serde_json::Value::String(icon.clone()));
479 }
480 if !metadata.tags.is_empty() {
481 metadata_obj.insert(
482 "tags".to_string(),
483 serde_json::Value::Array(
484 metadata
485 .tags
486 .iter()
487 .map(|t| serde_json::Value::String(t.clone()))
488 .collect(),
489 ),
490 );
491 }
492 if let Some(ref license) = metadata.license {
493 metadata_obj.insert(
494 "license".to_string(),
495 serde_json::Value::String(license.clone()),
496 );
497 }
498 }
499
500 if let Some(ref links) = manifest.links {
501 let mut links_obj = serde_json::Map::new();
502 if let Some(ref frontend) = links.frontend {
503 links_obj.insert(
504 "frontend".to_string(),
505 serde_json::Value::String(frontend.clone()),
506 );
507 }
508 if let Some(ref github) = links.github {
509 links_obj.insert(
510 "github".to_string(),
511 serde_json::Value::String(github.clone()),
512 );
513 }
514 if let Some(ref docs) = links.docs {
515 links_obj
516 .insert("docs".to_string(), serde_json::Value::String(docs.clone()));
517 }
518 if !links_obj.is_empty() {
519 metadata_obj
520 .insert("links".to_string(), serde_json::Value::Object(links_obj));
521 }
522 }
523
524 serde_json::to_vec(&serde_json::Value::Object(metadata_obj))?
526 };
527
528 return self.install_application(
530 &bundle_blob_id,
531 stored_size,
532 &uri,
533 bundle_metadata, package,
535 version,
536 Some(&signer_id), true, );
539 }
540
541 let package = "unknown";
544 let version = "0.0.0";
545
546 let (blob_id, size) = self
547 .add_blob(
548 response
549 .bytes_stream()
550 .map_err(io::Error::other)
551 .into_async_read(),
552 expected_size,
553 expected_hash,
554 )
555 .await?;
556
557 self.install_application(
558 &blob_id, size, &uri, metadata, package, version,
559 None, false, )
562 }
563
564 async fn install_bundle_from_path(
567 &self,
568 path: Utf8PathBuf,
569 _metadata: Vec<u8>,
570 ) -> eyre::Result<ApplicationId> {
571 debug!(
572 path = %path,
573 "install_bundle_from_path started"
574 );
575
576 let bundle_path = path.clone();
578
579 let bundle_data = Arc::new(tokio::fs::read(&path).await?);
581 let bundle_size = bundle_data.len() as u64;
582
583 let cursor = Cursor::new(bundle_data.as_slice());
585 let (bundle_blob_id, stored_size) = self.add_blob(cursor, Some(bundle_size), None).await?;
586
587 debug!(
588 %bundle_blob_id,
589 bundle_size,
590 stored_size,
591 "bundle stored as blob"
592 );
593
594 let bundle_data_clone = Arc::clone(&bundle_data);
597 let (verification, manifest) = tokio::task::spawn_blocking(move || {
598 Self::verify_and_extract_manifest(&bundle_data_clone)
599 })
600 .await??;
601
602 let signer_id = verification.signer_id;
603
604 let package = &manifest.package;
606 let version = &manifest.app_version;
607
608 let blobstore_root = self.blobstore.root_path();
612 let node_root = blobstore_root
613 .parent()
614 .ok_or_else(|| eyre::eyre!("blobstore root has no parent"))?
615 .to_path_buf();
616 let extract_dir = node_root
618 .join("applications")
619 .join(package)
620 .join(version)
621 .join("extracted");
622
623 let bundle_data_clone = Arc::clone(&bundle_data);
625 let manifest_clone = manifest.clone();
626 let extract_dir_clone = extract_dir.clone();
627 let node_root_clone = node_root.clone();
628 let package_clone = package.to_string();
629 let version_clone = version.to_string();
630 tokio::task::spawn_blocking(move || {
631 Self::extract_bundle_artifacts(
632 &bundle_data_clone,
633 &manifest_clone,
634 &extract_dir_clone,
635 &node_root_clone,
636 &package_clone,
637 &version_clone,
638 )
639 })
640 .await??;
641
642 let Ok(uri) = Url::from_file_path(path) else {
643 bail!("non-absolute path")
644 };
645
646 let bundle_metadata = {
648 let mut metadata_obj = serde_json::Map::new();
649 metadata_obj.insert(
650 "package".to_string(),
651 serde_json::Value::String(package.clone()),
652 );
653 metadata_obj.insert(
654 "version".to_string(),
655 serde_json::Value::String(version.clone()),
656 );
657
658 if let Some(ref metadata) = manifest.metadata {
659 metadata_obj.insert(
660 "name".to_string(),
661 serde_json::Value::String(metadata.name.clone()),
662 );
663 if let Some(ref description) = metadata.description {
664 metadata_obj.insert(
665 "description".to_string(),
666 serde_json::Value::String(description.clone()),
667 );
668 }
669 if let Some(ref icon) = metadata.icon {
670 metadata_obj
671 .insert("icon".to_string(), serde_json::Value::String(icon.clone()));
672 }
673 if !metadata.tags.is_empty() {
674 metadata_obj.insert(
675 "tags".to_string(),
676 serde_json::Value::Array(
677 metadata
678 .tags
679 .iter()
680 .map(|t| serde_json::Value::String(t.clone()))
681 .collect(),
682 ),
683 );
684 }
685 if let Some(ref license) = metadata.license {
686 metadata_obj.insert(
687 "license".to_string(),
688 serde_json::Value::String(license.clone()),
689 );
690 }
691 }
692
693 if let Some(ref links) = manifest.links {
694 let mut links_obj = serde_json::Map::new();
695 if let Some(ref frontend) = links.frontend {
696 links_obj.insert(
697 "frontend".to_string(),
698 serde_json::Value::String(frontend.clone()),
699 );
700 }
701 if let Some(ref github) = links.github {
702 links_obj.insert(
703 "github".to_string(),
704 serde_json::Value::String(github.clone()),
705 );
706 }
707 if let Some(ref docs) = links.docs {
708 links_obj.insert("docs".to_string(), serde_json::Value::String(docs.clone()));
709 }
710 if !links_obj.is_empty() {
711 metadata_obj.insert("links".to_string(), serde_json::Value::Object(links_obj));
712 }
713 }
714
715 serde_json::to_vec(&serde_json::Value::Object(metadata_obj))?
717 };
718
719 let application_id = self.install_application(
721 &bundle_blob_id,
722 stored_size,
723 &uri.as_str().parse()?,
724 bundle_metadata, package,
726 version,
727 Some(&signer_id), true, )?;
730
731 if let Err(e) = tokio::fs::remove_file(&bundle_path).await {
733 warn!(
734 path = %bundle_path,
735 error = %e,
736 "Failed to delete bundle file after installation"
737 );
738 } else {
740 debug!(
741 path = %bundle_path,
742 "Deleted bundle file after successful installation"
743 );
744 }
745
746 Ok(application_id)
747 }
748
749 fn validate_path_component(value: &str, field_name: &str) -> eyre::Result<()> {
755 if value.contains("..") {
757 bail!("{} contains path traversal sequence '..'", field_name);
758 }
759 if value.contains('/') || value.contains('\\') {
761 bail!("{} contains directory separator", field_name);
762 }
763 if value.contains('\0') {
765 bail!("{} contains null byte", field_name);
766 }
767 if value.len() >= 2 && value.as_bytes().get(1) == Some(&b':') {
769 bail!("{} appears to be an absolute path", field_name);
770 }
771 Ok(())
772 }
773
774 fn validate_artifact_path(value: &str, field_name: &str) -> eyre::Result<()> {
781 if value.is_empty() {
782 bail!("{} is empty", field_name);
783 }
784 if value.contains('\0') {
785 bail!("{} contains null byte", field_name);
786 }
787 if value.contains('\\') {
788 bail!("{} contains backslash (use forward slashes)", field_name);
789 }
790 if value.starts_with('/') {
792 bail!("{} is an absolute path", field_name);
793 }
794 if value.as_bytes().get(1) == Some(&b':') {
795 bail!("{} appears to be an absolute Windows path", field_name);
796 }
797 if value.split('/').any(|c| c == "..") {
799 bail!("{} contains path traversal component '..'", field_name);
800 }
801 Ok(())
802 }
803
804 fn verify_and_extract_manifest(
807 bundle_data: &[u8],
808 ) -> eyre::Result<(ManifestVerification, BundleManifest)> {
809 let (manifest_json, manifest) = Self::extract_bundle_manifest(bundle_data)?;
810 let verification = verify_manifest_signature(&manifest_json)?;
811 debug!(
812 signer_id = %verification.signer_id,
813 bundle_hash = %hex::encode(verification.bundle_hash),
814 "bundle manifest signature verified"
815 );
816 Ok((verification, manifest))
817 }
818
819 fn extract_bundle_manifest(
822 bundle_data: &[u8],
823 ) -> eyre::Result<(serde_json::Value, BundleManifest)> {
824 let tar = GzDecoder::new(bundle_data);
825 let mut archive = Archive::new(tar);
826
827 for entry in archive.entries()? {
828 let mut entry = entry?;
829 let path = entry.path()?;
830
831 if path.file_name().and_then(|n| n.to_str()) == Some("manifest.json") {
832 let mut manifest_str = String::new();
833 entry.read_to_string(&mut manifest_str)?;
834
835 let manifest_json: serde_json::Value = serde_json::from_str(&manifest_str)
838 .map_err(|e| eyre::eyre!("failed to parse manifest.json as JSON: {}", e))?;
839
840 let manifest: BundleManifest = serde_json::from_value(manifest_json.clone())
845 .map_err(|e| eyre::eyre!("failed to parse manifest.json: {}", e))?;
846
847 if manifest.package.is_empty() {
849 bail!("bundle manifest 'package' field is empty");
850 }
851 if manifest.app_version.is_empty() {
852 bail!("bundle manifest 'appVersion' field is empty");
853 }
854
855 Self::validate_path_component(&manifest.package, "package")?;
859 Self::validate_path_component(&manifest.app_version, "appVersion")?;
860
861 if let Some(ref wasm) = manifest.wasm {
864 Self::validate_artifact_path(&wasm.path, "wasm.path")?;
865 }
866 if let Some(ref abi) = manifest.abi {
867 Self::validate_artifact_path(&abi.path, "abi.path")?;
868 }
869 for (i, migration) in manifest.migrations.iter().enumerate() {
870 Self::validate_artifact_path(
871 &migration.path,
872 &format!("migrations[{}].path", i),
873 )?;
874 }
875
876 let current_runtime_version = Version::parse(env!("CALIMERO_RELEASE_VERSION"))
878 .map_err(|e| eyre::eyre!("failed to parse current runtime version: {}", e))?;
879 let min_runtime_version =
880 Version::parse(&manifest.min_runtime_version).map_err(|e| {
881 eyre::eyre!(
882 "invalid minRuntimeVersion '{}': {}",
883 manifest.min_runtime_version,
884 e
885 )
886 })?;
887
888 if min_runtime_version > current_runtime_version {
889 bail!(
890 "bundle requires runtime version {} but current runtime is {}",
891 min_runtime_version,
892 current_runtime_version
893 );
894 }
895
896 return Ok((manifest_json, manifest));
897 }
898 }
899
900 bail!("manifest.json not found in bundle")
901 }
902
903 pub fn is_bundle_blob(blob_bytes: &[u8]) -> bool {
909 let tar = GzDecoder::new(blob_bytes);
911 let mut archive = Archive::new(tar);
912
913 let entries = match archive.entries() {
915 Ok(entries) => entries,
916 Err(e) => {
917 warn!(
918 "Failed to read tar archive entries (possible corruption): {}",
919 e
920 );
921 return false;
922 }
923 };
924
925 for (i, entry_result) in entries.enumerate() {
926 if i >= 10 {
927 break; }
929 match entry_result {
930 Ok(entry) => {
931 match entry.path() {
932 Ok(path) => {
933 if path.file_name().and_then(|n| n.to_str()) == Some("manifest.json") {
934 return true;
935 }
936 }
937 Err(e) => {
938 warn!("Failed to read entry path in tar archive (possible corruption): {}", e);
939 }
941 }
942 }
943 Err(e) => {
944 warn!(
945 "Failed to read tar archive entry (possible corruption): {}",
946 e
947 );
948 }
950 }
951 }
952 false
953 }
954
955 pub async fn install_application_from_bundle_blob(
959 &self,
960 blob_id: &BlobId,
961 source: &ApplicationSource,
962 ) -> eyre::Result<ApplicationId> {
963 debug!(
964 %blob_id,
965 "install_application_from_bundle_blob started"
966 );
967
968 let Some(bundle_bytes) = self.get_blob_bytes(blob_id, None).await? else {
970 bail!("bundle blob not found");
971 };
972
973 let bundle_bytes_clone = Arc::clone(&bundle_bytes);
977 let (verification, manifest) = tokio::task::spawn_blocking(move || {
978 Self::verify_and_extract_manifest(&bundle_bytes_clone)
979 })
980 .await??;
981
982 let signer_id = verification.signer_id;
983
984 let package = &manifest.package;
985 let version = &manifest.app_version;
986
987 let blobstore_root = self.blobstore.root_path();
990 let node_root = blobstore_root
991 .parent()
992 .ok_or_else(|| eyre::eyre!("blobstore root has no parent"))?
993 .to_path_buf();
994 let extract_dir = node_root
995 .join("applications")
996 .join(package)
997 .join(version)
998 .join("extracted");
999
1000 let bundle_bytes_clone = Arc::clone(&bundle_bytes);
1002 let manifest_clone = manifest.clone();
1003 let extract_dir_clone = extract_dir.clone();
1004 let node_root_clone = node_root.clone();
1005 let package_clone = package.to_string();
1006 let version_clone = version.to_string();
1007 tokio::task::spawn_blocking(move || {
1008 Self::extract_bundle_artifacts(
1009 &bundle_bytes_clone,
1010 &manifest_clone,
1011 &extract_dir_clone,
1012 &node_root_clone,
1013 &package_clone,
1014 &version_clone,
1015 )
1016 })
1017 .await??;
1018 let size = bundle_bytes.len() as u64;
1019
1020 debug!(
1021 %blob_id,
1022 package,
1023 version,
1024 size,
1025 "bundle extracted and ready for installation"
1026 );
1027
1028 let bundle_metadata = {
1030 let mut metadata_obj = serde_json::Map::new();
1031 metadata_obj.insert(
1032 "package".to_string(),
1033 serde_json::Value::String(package.clone()),
1034 );
1035 metadata_obj.insert(
1036 "version".to_string(),
1037 serde_json::Value::String(version.clone()),
1038 );
1039
1040 if let Some(ref metadata) = manifest.metadata {
1041 metadata_obj.insert(
1042 "name".to_string(),
1043 serde_json::Value::String(metadata.name.clone()),
1044 );
1045 if let Some(ref description) = metadata.description {
1046 metadata_obj.insert(
1047 "description".to_string(),
1048 serde_json::Value::String(description.clone()),
1049 );
1050 }
1051 if let Some(ref icon) = metadata.icon {
1052 metadata_obj
1053 .insert("icon".to_string(), serde_json::Value::String(icon.clone()));
1054 }
1055 if !metadata.tags.is_empty() {
1056 metadata_obj.insert(
1057 "tags".to_string(),
1058 serde_json::Value::Array(
1059 metadata
1060 .tags
1061 .iter()
1062 .map(|t| serde_json::Value::String(t.clone()))
1063 .collect(),
1064 ),
1065 );
1066 }
1067 if let Some(ref license) = metadata.license {
1068 metadata_obj.insert(
1069 "license".to_string(),
1070 serde_json::Value::String(license.clone()),
1071 );
1072 }
1073 }
1074
1075 if let Some(ref links) = manifest.links {
1076 let mut links_obj = serde_json::Map::new();
1077 if let Some(ref frontend) = links.frontend {
1078 links_obj.insert(
1079 "frontend".to_string(),
1080 serde_json::Value::String(frontend.clone()),
1081 );
1082 }
1083 if let Some(ref github) = links.github {
1084 links_obj.insert(
1085 "github".to_string(),
1086 serde_json::Value::String(github.clone()),
1087 );
1088 }
1089 if let Some(ref docs) = links.docs {
1090 links_obj.insert("docs".to_string(), serde_json::Value::String(docs.clone()));
1091 }
1092 if !links_obj.is_empty() {
1093 metadata_obj.insert("links".to_string(), serde_json::Value::Object(links_obj));
1094 }
1095 }
1096
1097 serde_json::to_vec(&serde_json::Value::Object(metadata_obj))?
1099 };
1100
1101 self.install_application(
1103 blob_id,
1104 size,
1105 source,
1106 bundle_metadata,
1107 package,
1108 version,
1109 Some(&signer_id), true, )
1112 }
1113
1114 fn find_duplicate_artifact(
1118 node_root: &Utf8Path,
1119 package: &str,
1120 current_version: &str,
1121 hash: &[u8; 32],
1122 relative_path: &str,
1123 ) -> Option<Utf8PathBuf> {
1124 let package_dir = node_root.join("applications").join(package);
1126
1127 if let Ok(entries) = fs::read_dir(package_dir.as_std_path()) {
1128 for entry in entries.flatten() {
1129 if let Ok(version_name) = entry.file_name().into_string() {
1130 if version_name == current_version {
1131 continue; }
1133
1134 let extracted_dir = package_dir.join(&version_name).join("extracted");
1136 let candidate_path = extracted_dir.join(relative_path);
1137
1138 if candidate_path.exists() {
1139 if let Ok(candidate_content) = fs::read(candidate_path.as_std_path()) {
1141 let candidate_hash = Sha256::digest(&candidate_content);
1142 let candidate_array: [u8; 32] = candidate_hash.into();
1143 if candidate_array == *hash {
1144 return Some(candidate_path);
1145 }
1146 }
1147 }
1148 }
1149 }
1150 }
1151
1152 None
1153 }
1154
1155 fn extract_bundle_artifacts(
1160 bundle_data: &[u8],
1161 _manifest: &BundleManifest,
1162 extract_dir: &Utf8Path,
1163 node_root: &Utf8Path,
1164 package: &str,
1165 current_version: &str,
1166 ) -> eyre::Result<()> {
1167 fs::create_dir_all(extract_dir)?;
1169
1170 let lock_file_path = extract_dir.join(".extracting.lock");
1173 let marker_file_path = extract_dir.join(".extracted");
1174
1175 if marker_file_path.exists() {
1179 let wasm_relative_path = _manifest
1182 .wasm
1183 .as_ref()
1184 .map(|w| w.path.as_str())
1185 .unwrap_or("app.wasm");
1186
1187 if wasm_relative_path.contains("..") {
1189 bail!(
1190 "WASM path traversal detected in manifest: {} contains '..' component",
1191 wasm_relative_path
1192 );
1193 }
1194
1195 let wasm_path = extract_dir.join(wasm_relative_path);
1196
1197 if wasm_path.exists() {
1199 let canonical_wasm = wasm_path.canonicalize_utf8()?;
1201
1202 let canonical_extract = if extract_dir.exists() {
1205 extract_dir.canonicalize_utf8()?
1206 } else {
1207 let wasm_parent = wasm_path
1210 .parent()
1211 .ok_or_else(|| eyre::eyre!("WASM path has no parent directory"))?;
1212 let wasm_parent_canonical = wasm_parent.canonicalize_utf8()?;
1213
1214 let relative_depth = wasm_relative_path
1216 .split('/')
1217 .filter(|s| !s.is_empty())
1218 .count()
1219 .saturating_sub(1); let mut canonical_extract_candidate = wasm_parent_canonical.clone();
1223 for _ in 0..relative_depth {
1224 if let Some(parent) = canonical_extract_candidate.parent() {
1225 canonical_extract_candidate = parent.to_path_buf();
1226 } else {
1227 bail!("Cannot reconstruct extract_dir from WASM path");
1228 }
1229 }
1230
1231 canonical_extract_candidate.try_into().map_err(|_| {
1232 eyre::eyre!("Failed to convert extract_dir path to Utf8PathBuf")
1233 })?
1234 };
1235
1236 if !canonical_wasm.starts_with(&canonical_extract) {
1237 bail!(
1238 "WASM path traversal detected: {} escapes extraction directory {}",
1239 wasm_relative_path,
1240 extract_dir
1241 );
1242 }
1243
1244 debug!(
1245 package,
1246 version = current_version,
1247 "Bundle already extracted (marker file and WASM exist), skipping"
1248 );
1249 return Ok(());
1250 } else {
1251 debug!(
1253 package,
1254 version = current_version,
1255 "Marker file exists but WASM not found, removing stale marker"
1256 );
1257 let _ = fs::remove_file(&marker_file_path);
1258 }
1259 }
1260
1261 let lock_acquired = match std::fs::OpenOptions::new()
1264 .create_new(true)
1265 .write(true)
1266 .open(lock_file_path.as_std_path())
1267 {
1268 Ok(_) => {
1269 true
1271 }
1272 Err(_) => {
1273 for _ in 0..20 {
1276 std::thread::sleep(std::time::Duration::from_millis(100));
1277 if marker_file_path.exists() {
1278 debug!(
1279 package,
1280 version = current_version,
1281 "Bundle extraction completed by another process"
1282 );
1283 return Ok(());
1284 }
1285 }
1286 warn!(
1289 package,
1290 version = current_version,
1291 "Lock file exists but extraction not complete, proceeding anyway"
1292 );
1293 false
1294 }
1295 };
1296
1297 let mut lock_created_by_us = lock_acquired;
1299
1300 if !lock_acquired {
1303 let _ = fs::remove_file(&lock_file_path);
1305 if marker_file_path.exists() {
1307 return Ok(());
1308 }
1309 match std::fs::OpenOptions::new()
1312 .create_new(true)
1313 .write(true)
1314 .open(lock_file_path.as_std_path())
1315 {
1316 Ok(_) => {
1317 lock_created_by_us = true;
1319 }
1320 Err(_) => {
1321 std::thread::sleep(std::time::Duration::from_millis(100));
1324 if marker_file_path.exists() {
1325 debug!(
1326 package,
1327 version = current_version,
1328 "Bundle extraction completed by another process after lock retry"
1329 );
1330 return Ok(());
1331 }
1332 bail!(
1335 "Failed to acquire extraction lock after retry - another process is extracting"
1336 );
1337 }
1338 }
1339 }
1340
1341 struct LockGuard {
1344 path: Utf8PathBuf,
1345 should_remove: std::cell::Cell<bool>,
1346 }
1347
1348 impl Drop for LockGuard {
1349 fn drop(&mut self) {
1350 if self.should_remove.get() {
1351 let _ = fs::remove_file(&self.path);
1352 }
1353 }
1354 }
1355
1356 let lock_guard = LockGuard {
1357 path: lock_file_path.clone(),
1358 should_remove: std::cell::Cell::new(lock_created_by_us),
1359 };
1360
1361 let tar = GzDecoder::new(bundle_data);
1362 let mut archive = Archive::new(tar);
1363
1364 for entry_result in archive.entries()? {
1366 let mut entry = entry_result?;
1367
1368 let path_bytes_owned = {
1370 let header = entry.header();
1371 header.path_bytes().into_owned()
1372 };
1373
1374 let relative_path = {
1375 let path_str = std::str::from_utf8(&path_bytes_owned)
1376 .map_err(|_| eyre::eyre!("invalid UTF-8 in file path"))?;
1377 path_str.to_string()
1378 };
1379
1380 if let Some(file_name) = std::path::Path::new(&relative_path)
1383 .file_name()
1384 .and_then(|n| n.to_str())
1385 {
1386 if file_name.starts_with("._") {
1387 continue;
1388 }
1389 }
1390
1391 let mut content = Vec::new();
1393 std::io::copy(&mut entry, &mut content)?;
1394
1395 let dest_path = extract_dir.join(&relative_path);
1397
1398 if relative_path.contains("..") {
1401 bail!(
1402 "Path traversal detected: {} contains '..' component",
1403 relative_path
1404 );
1405 }
1406
1407 let canonical_extract = extract_dir.canonicalize_utf8()?;
1411
1412 let expected_dest = canonical_extract.join(&relative_path);
1416
1417 if !expected_dest.starts_with(&canonical_extract) {
1421 bail!(
1422 "Path traversal detected: {} would escape extraction directory {}",
1423 relative_path,
1424 extract_dir
1425 );
1426 }
1427
1428 if dest_path.exists() {
1430 let canonical_dest = dest_path.canonicalize_utf8()?;
1431 if !canonical_dest.starts_with(&canonical_extract) {
1432 bail!(
1433 "Path traversal detected: {} escapes extraction directory {}",
1434 relative_path,
1435 extract_dir
1436 );
1437 }
1438 }
1439
1440 if let Some(parent) = dest_path.parent() {
1442 fs::create_dir_all(parent)?;
1443 }
1444
1445 let hash = Sha256::digest(&content);
1447 let hash_array: [u8; 32] = hash.into();
1448
1449 if let Some(duplicate_path) = Self::find_duplicate_artifact(
1451 node_root,
1452 package,
1453 current_version,
1454 &hash_array,
1455 &relative_path,
1456 ) {
1457 if let Err(e) = fs::hard_link(duplicate_path.as_std_path(), dest_path.as_std_path())
1459 {
1460 warn!(
1462 file = %relative_path,
1463 duplicate = %duplicate_path,
1464 error = %e,
1465 "hardlink failed, copying instead"
1466 );
1467 fs::write(&dest_path, &content)?;
1468 } else {
1469 debug!(
1470 file = %relative_path,
1471 hash = hex::encode(hash),
1472 duplicate = %duplicate_path,
1473 "deduplicated artifact via hardlink"
1474 );
1475 }
1476 } else {
1477 fs::write(&dest_path, &content)?;
1479 debug!(
1480 file = %relative_path,
1481 hash = hex::encode(hash),
1482 "extracted artifact"
1483 );
1484 }
1485 }
1486
1487 fs::write(&marker_file_path, b"extracted")?;
1489
1490 if lock_guard.should_remove.get() {
1492 let _ = fs::remove_file(&lock_file_path);
1493 lock_guard.should_remove.set(false); }
1495
1496 Ok(())
1497 }
1498
1499 pub fn uninstall_application(&self, application_id: &ApplicationId) -> eyre::Result<()> {
1500 let mut handle = self.datastore.handle();
1501
1502 let key = key::ApplicationMeta::new(*application_id);
1503
1504 let application_meta = handle.get(&key)?;
1506
1507 handle.delete(&key)?;
1509
1510 if let Some(application) = application_meta {
1512 let is_bundle = application.package.as_ref() != "unknown"
1515 && application.version.as_ref() != "0.0.0";
1516
1517 if is_bundle {
1518 let blobstore_root = self.blobstore.root_path();
1520 let node_root = blobstore_root
1521 .parent()
1522 .ok_or_else(|| eyre::eyre!("blobstore root has no parent"))?;
1523 let bundle_dir = node_root
1524 .join("applications")
1525 .join(application.package.as_ref())
1526 .join(application.version.as_ref());
1527
1528 if bundle_dir.exists() {
1530 debug!(
1531 package = %application.package,
1532 version = %application.version,
1533 path = %bundle_dir,
1534 "Removing extracted bundle directory"
1535 );
1536 if let Err(e) = fs::remove_dir_all(bundle_dir.as_std_path()) {
1537 warn!(
1538 package = %application.package,
1539 version = %application.version,
1540 path = %bundle_dir,
1541 error = %e,
1542 "Failed to remove extracted bundle directory"
1543 );
1544 } else {
1546 debug!(
1547 package = %application.package,
1548 version = %application.version,
1549 "Successfully removed extracted bundle directory"
1550 );
1551 }
1552
1553 let package_dir = node_root
1555 .join("applications")
1556 .join(application.package.as_ref());
1557 if package_dir.exists() {
1558 if let Ok(mut entries) = fs::read_dir(package_dir.as_std_path()) {
1560 if entries.next().is_none() {
1561 if let Err(e) = fs::remove_dir(package_dir.as_std_path()) {
1563 debug!(
1564 package = %application.package,
1565 error = %e,
1566 "Failed to remove empty package directory (non-fatal)"
1567 );
1568 }
1569 }
1570 }
1571 }
1572 }
1573 }
1574 }
1575
1576 Ok(())
1577 }
1578
1579 pub fn list_applications(&self) -> eyre::Result<Vec<Application>> {
1580 let handle = self.datastore.handle();
1581
1582 let mut iter = handle.iter::<key::ApplicationMeta>()?;
1583
1584 let mut applications = vec![];
1585
1586 for (id, app) in iter.entries() {
1587 let (id, app) = (id?, app?);
1588 applications.push(Application::new(
1589 id.application_id(),
1590 ApplicationBlob {
1591 bytecode: app.bytecode.blob_id(),
1592 compiled: app.compiled.blob_id(),
1593 },
1594 app.size,
1595 app.source.parse()?,
1596 app.metadata.to_vec(),
1597 ));
1598 }
1599
1600 Ok(applications)
1601 }
1602
1603 pub fn update_compiled_app(
1604 &self,
1605 application_id: &ApplicationId,
1606 compiled_blob_id: &BlobId,
1607 ) -> eyre::Result<()> {
1608 let mut handle = self.datastore.handle();
1609
1610 let key = key::ApplicationMeta::new(*application_id);
1611
1612 let Some(mut application) = handle.get(&key)? else {
1613 bail!("application not found");
1614 };
1615
1616 application.compiled = key::BlobMeta::new(*compiled_blob_id);
1617
1618 handle.put(&key, &application)?;
1619
1620 Ok(())
1621 }
1622
1623 pub fn list_packages(&self) -> eyre::Result<Vec<String>> {
1625 let handle = self.datastore.handle();
1626 let mut iter = handle.iter::<key::ApplicationMeta>()?;
1627 let mut packages = std::collections::HashSet::new();
1628
1629 for (id, app) in iter.entries() {
1630 let (_, app) = (id?, app?);
1631 let _ = packages.insert(app.package.to_string());
1632 }
1633
1634 Ok(packages.into_iter().collect())
1635 }
1636
1637 pub fn list_versions(&self, package: &str) -> eyre::Result<Vec<String>> {
1639 let handle = self.datastore.handle();
1640 let mut iter = handle.iter::<key::ApplicationMeta>()?;
1641 let mut versions = Vec::new();
1642
1643 for (id, app) in iter.entries() {
1644 let (_, app) = (id?, app?);
1645 if app.package.as_ref() == package {
1646 versions.push(app.version.to_string());
1647 }
1648 }
1649
1650 Ok(versions)
1651 }
1652
1653 pub fn get_latest_version(
1655 &self,
1656 package: &str,
1657 ) -> eyre::Result<Option<(String, ApplicationId)>> {
1658 let handle = self.datastore.handle();
1659 let mut iter = handle.iter::<key::ApplicationMeta>()?;
1660 let mut latest_version: Option<(String, ApplicationId)> = None;
1661
1662 for (id, app) in iter.entries() {
1663 let (id, app) = (id?, app?);
1664 if app.package.as_ref() == package {
1665 let version_str = app.version.to_string();
1666 match &latest_version {
1667 None => latest_version = Some((version_str, id.application_id())),
1668 Some((current_version_str, _)) => {
1669 let is_newer = match (
1671 Version::parse(&version_str),
1672 Version::parse(current_version_str),
1673 ) {
1674 (Ok(new_version), Ok(current_version)) => {
1675 new_version > current_version
1677 }
1678 (Ok(_), Err(_)) => {
1679 true
1681 }
1682 (Err(_), Ok(_)) => {
1683 false
1685 }
1686 (Err(_), Err(_)) => {
1687 version_str > *current_version_str
1689 }
1690 };
1691
1692 if is_newer {
1693 latest_version = Some((version_str, id.application_id()));
1694 }
1695 }
1696 }
1697 }
1698 }
1699
1700 Ok(latest_version)
1701 }
1702
1703 pub async fn install_by_package_version(
1705 &self,
1706 _package: &str,
1707 _version: &str,
1708 source: &ApplicationSource,
1709 metadata: Vec<u8>,
1710 ) -> eyre::Result<ApplicationId> {
1711 let url = source.to_string().parse()?;
1714 self.install_application_from_url(url, metadata, None).await
1715 }
1716}
1717
1718#[cfg(test)]
1719mod tests {
1720 use super::*;
1721
1722 #[test]
1723 fn test_validate_path_component_valid() {
1724 let valid_paths = vec!["com.example.app", "my-app", "my_app_v2", "app123"];
1725 for path in valid_paths {
1726 assert!(
1727 NodeClient::validate_path_component(path, "test").is_ok(),
1728 "Valid path '{}' should pass validation",
1729 path
1730 );
1731 }
1732 }
1733
1734 #[test]
1735 fn test_validate_path_component_path_traversal() {
1736 let invalid_paths = vec!["../etc", "..", "foo/../bar", "package..name"];
1737 for path in invalid_paths {
1738 assert!(
1739 NodeClient::validate_path_component(path, "test").is_err(),
1740 "Path traversal '{}' should be rejected",
1741 path
1742 );
1743 }
1744 }
1745
1746 #[test]
1747 fn test_validate_path_component_directory_separators() {
1748 let invalid_paths = vec!["foo/bar", "foo\\bar", "/absolute", "\\windows"];
1749 for path in invalid_paths {
1750 assert!(
1751 NodeClient::validate_path_component(path, "test").is_err(),
1752 "Path with separator '{}' should be rejected",
1753 path
1754 );
1755 }
1756 }
1757
1758 #[test]
1759 fn test_validate_path_component_null_byte() {
1760 let invalid_path = "package\0name";
1761 assert!(
1762 NodeClient::validate_path_component(invalid_path, "test").is_err(),
1763 "Path with null byte should be rejected"
1764 );
1765 }
1766
1767 #[test]
1768 fn test_validate_path_component_windows_drive() {
1769 let invalid_paths = vec!["C:malicious", "D:path"];
1770 for path in invalid_paths {
1771 assert!(
1772 NodeClient::validate_path_component(path, "test").is_err(),
1773 "Windows drive path '{}' should be rejected",
1774 path
1775 );
1776 }
1777 }
1778
1779 #[test]
1780 fn test_validate_path_component_unicode_separator() {
1781 let invalid_path = "package/name";
1783 }
1786
1787 #[test]
1788 fn test_validate_artifact_path_valid() {
1789 let valid_paths = vec!["app.wasm", "src/main.wasm", "migrations/001_init.sql"];
1790 for path in valid_paths {
1791 assert!(
1792 NodeClient::validate_artifact_path(path, "test").is_ok(),
1793 "Valid artifact path '{}' should pass validation",
1794 path
1795 );
1796 }
1797 }
1798
1799 #[test]
1800 fn test_validate_artifact_path_empty() {
1801 assert!(
1802 NodeClient::validate_artifact_path("", "test").is_err(),
1803 "Empty path should be rejected"
1804 );
1805 }
1806
1807 #[test]
1808 fn test_validate_artifact_path_null_byte() {
1809 let invalid_path = "app\0.wasm";
1810 assert!(
1811 NodeClient::validate_artifact_path(invalid_path, "test").is_err(),
1812 "Path with null byte should be rejected"
1813 );
1814 }
1815
1816 #[test]
1817 fn test_validate_artifact_path_backslash() {
1818 let invalid_path = "app\\main.wasm";
1819 assert!(
1820 NodeClient::validate_artifact_path(invalid_path, "test").is_err(),
1821 "Path with backslash should be rejected"
1822 );
1823 }
1824
1825 #[test]
1826 fn test_validate_artifact_path_absolute_unix() {
1827 let invalid_path = "/etc/passwd";
1828 assert!(
1829 NodeClient::validate_artifact_path(invalid_path, "test").is_err(),
1830 "Absolute Unix path should be rejected"
1831 );
1832 }
1833
1834 #[test]
1835 fn test_validate_artifact_path_absolute_windows() {
1836 let invalid_paths = vec!["C:malicious", "D:path\\file.wasm"];
1837 for path in invalid_paths {
1838 assert!(
1839 NodeClient::validate_artifact_path(path, "test").is_err(),
1840 "Windows absolute path '{}' should be rejected",
1841 path
1842 );
1843 }
1844 }
1845
1846 #[test]
1847 fn test_validate_artifact_path_traversal() {
1848 let invalid_paths = vec!["../etc/passwd", "foo/../bar", "..", "migrations/../../etc"];
1849 for path in invalid_paths {
1850 assert!(
1851 NodeClient::validate_artifact_path(path, "test").is_err(),
1852 "Path traversal '{}' should be rejected",
1853 path
1854 );
1855 }
1856 }
1857
1858 #[test]
1859 fn test_validate_artifact_path_url_encoded() {
1860 let invalid_path = "..%2Fetc";
1862 }
1866
1867 #[test]
1868 fn test_validate_artifact_path_very_long() {
1869 let long_path = "a".repeat(10000);
1872 assert!(
1873 NodeClient::validate_artifact_path(&long_path, "test").is_ok(),
1874 "Very long path currently passes validation (no length check implemented)"
1875 );
1876 }
1877}