Skip to main content

calimero_node_primitives/client/
application.rs

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        // Determine if this is a bundle by checking package/version
67        // Bundles have real package/version values, non-bundles use "unknown"/"0.0.0"
68        // This avoids repeated decompression on every get_application_bytes call
69        let is_bundle =
70            application.package.as_ref() != "unknown" && application.version.as_ref() != "0.0.0";
71
72        // Get blob bytes
73        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            // This is a bundle, extract WASM from extracted directory or bundle
82            // Extract manifest and verify signature (blocking I/O wrapped in spawn_blocking)
83            // Signature verification ensures blob integrity even when re-extracting
84            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            // Resolve relative path against node root (must be done before spawn_blocking)
93            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            // Get WASM path from manifest (fallback to "app.wasm" for backward compatibility)
105            let wasm_relative_path = manifest
106                .wasm
107                .as_ref()
108                .map(|w| w.path.as_str())
109                .unwrap_or("app.wasm");
110
111            // Validate WASM path to prevent path traversal attacks
112            // Check that the relative path doesn't contain ".." components that would escape
113            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            // Additional validation: ensure the resolved path stays within extract_dir
123            // Use canonicalize if path exists, otherwise validate components
124            if wasm_path.exists() {
125                let canonical_wasm = wasm_path.canonicalize_utf8()?;
126
127                // extract_dir might not exist if wasm_relative_path contains subdirectories
128                // Reconstruct canonical extract_dir from wasm_path by removing relative path components
129                let canonical_extract = if extract_dir.exists() {
130                    extract_dir.canonicalize_utf8()?
131                } else {
132                    // Reconstruct extract_dir from wasm_path by removing wasm_relative_path components
133                    // Since we validated wasm_relative_path doesn't contain "..", this is safe
134                    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                    // Count depth of wasm_relative_path (number of path components)
140                    let relative_depth = wasm_relative_path
141                        .split('/')
142                        .filter(|s| !s.is_empty())
143                        .count()
144                        .saturating_sub(1); // Subtract 1 for the filename itself
145
146                    // Go up relative_depth levels from wasm_parent to get extract_dir
147                    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                // Ensure canonical_wasm is within canonical_extract
162                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                // Fallback: re-extract from bundle blob if extracted files missing
176                warn!(
177                    wasm_path = %wasm_path,
178                    "extracted WASM not found, attempting to re-extract from bundle and persist to disk"
179                );
180
181                // Remove marker file if it exists (files were deleted, marker is stale)
182                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                // Re-extract entire bundle to disk (not just WASM) so future calls don't need to re-extract
188                // This handles the case where sync_context_config installed the app before blob arrived
189                // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
190                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                // Now read the WASM file that was just extracted
209                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        // Single WASM installation (existing behavior)
219        // Reuse blob_bytes that were already fetched for bundle detection
220        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        // For bundles: signer_id is required
247        // For non-bundles: signer_id is optional (backwards compatibility)
248        // Note: Empty string is used as a sentinel value for non-bundle applications.
249        // This distinguishes 'no signer' (non-bundle) from 'has signer' (bundle) cases.
250        // Non-bundle installations cannot be upgraded to signed bundle installations
251        // without re-installation.
252        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            // For bundles: use package and signer_id for deterministic ApplicationId
267            // This creates a stable application identity based on who signed the bundle,
268            // allowing version upgrades while maintaining the same ApplicationId
269            let components = (&application.package, &application.signer_id);
270            ApplicationId::from(*Hash::hash_borsh(&components)?)
271        } else {
272            // For single WASM: use current logic (blob_id, size, source, metadata)
273            // Maintains backward compatibility for non-bundle installations
274            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    /// Check if a path points to a bundle archive (.mpk - Mero Package Kit)
293    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        // Detect bundle vs single WASM
321        if Self::is_bundle_archive(&path) {
322            return self.install_bundle_from_path(path, metadata).await;
323        }
324
325        // For non-bundle installations, use provided package/version or defaults
326        let package = package.as_deref().unwrap_or("unknown");
327        let version = version.as_deref().unwrap_or("0.0.0");
328
329        // Existing single WASM installation path
330        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,  // signer_id: None for non-bundle installations
368            false, // is_bundle: false for single WASM
369        )
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        // Check if URL indicates a bundle archive (.mpk - Mero Package Kit)
385        let is_bundle = url.path().ends_with(".mpk");
386
387        if is_bundle {
388            // Download entire bundle into memory
389            let bundle_data = Arc::new(response.bytes().await?.to_vec());
390
391            // Store entire bundle as a single blob
392            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            // Extract bundle manifest and verify signature
405            // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
406            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            // Extract package and version from manifest
415            let package = &manifest.package;
416            let version = &manifest.app_version;
417
418            // Extract artifacts with deduplication
419            // Use node root (parent of blobstore) instead of blobstore root
420            // Must be done before spawn_blocking
421            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            // Extract directory is derived from package and version
427            let extract_dir = node_root
428                .join("applications")
429                .join(package)
430                .join(version)
431                .join("extracted");
432
433            // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
434            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            // Extract metadata from bundle manifest and serialize it
453            // Bundle manifest contains metadata (name, description, author, links, etc.)
454            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                // Serialize metadata to JSON bytes
525                serde_json::to_vec(&serde_json::Value::Object(metadata_obj))?
526            };
527
528            // Install application with bundle blob_id and extracted metadata
529            return self.install_application(
530                &bundle_blob_id,
531                stored_size,
532                &uri,
533                bundle_metadata, // Use metadata extracted from bundle manifest
534                package,
535                version,
536                Some(&signer_id), // signer_id from manifest verification
537                true,             // is_bundle: true for bundles
538            );
539        }
540
541        // Single WASM installation (existing behavior)
542        // For non-bundle installations, use defaults (package/version are not part of ApplicationId)
543        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,  // signer_id: None for non-bundle installations
560            false, // is_bundle: false for single WASM
561        )
562    }
563
564    /// Install a bundle archive (.mpk - Mero Package Kit) containing WASM, ABI, and migrations
565    /// Note: metadata parameter is ignored for bundles - metadata is always extracted from manifest
566    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        // Clone path for deletion after installation
577        let bundle_path = path.clone();
578
579        // Read bundle file
580        let bundle_data = Arc::new(tokio::fs::read(&path).await?);
581        let bundle_size = bundle_data.len() as u64;
582
583        // Store entire bundle as a single blob
584        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        // Extract bundle manifest and verify signature
595        // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
596        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        // Extract package and version from manifest (ignore provided values)
605        let package = &manifest.package;
606        let version = &manifest.app_version;
607
608        // Extract artifacts with deduplication
609        // Use node root (parent of blobstore) instead of blobstore root
610        // Must be done before spawn_blocking
611        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        // Extract directory is derived from package and version
617        let extract_dir = node_root
618            .join("applications")
619            .join(package)
620            .join(version)
621            .join("extracted");
622
623        // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
624        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        // Extract metadata from bundle manifest and serialize it
647        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            // Serialize metadata to JSON bytes
716            serde_json::to_vec(&serde_json::Value::Object(metadata_obj))?
717        };
718
719        // Install application with bundle blob_id and extracted metadata
720        let application_id = self.install_application(
721            &bundle_blob_id,
722            stored_size,
723            &uri.as_str().parse()?,
724            bundle_metadata, // Use metadata extracted from bundle manifest
725            package,
726            version,
727            Some(&signer_id), // signer_id from manifest verification
728            true,             // is_bundle: true for bundles
729        )?;
730
731        // Delete bundle file after successful installation (it's now stored as a blob)
732        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            // Don't fail installation if deletion fails - bundle is already installed
739        } else {
740            debug!(
741                path = %bundle_path,
742                "Deleted bundle file after successful installation"
743            );
744        }
745
746        Ok(application_id)
747    }
748
749    /// Validates that a string is safe for use as a filesystem path component.
750    /// Returns an error if the string contains path traversal or other unsafe characters.
751    ///
752    /// This prevents malicious bundle manifests from writing files outside the intended
753    /// `applications` directory by using path traversal sequences in package or version fields.
754    fn validate_path_component(value: &str, field_name: &str) -> eyre::Result<()> {
755        // Check for parent directory traversal
756        if value.contains("..") {
757            bail!("{} contains path traversal sequence '..'", field_name);
758        }
759        // Check for directory separators (Unix and Windows)
760        if value.contains('/') || value.contains('\\') {
761            bail!("{} contains directory separator", field_name);
762        }
763        // Check for null bytes
764        if value.contains('\0') {
765            bail!("{} contains null byte", field_name);
766        }
767        // Check for absolute path indicators (Windows drive letters like "C:")
768        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    /// Validates that an artifact path is safe for use as a relative filesystem path.
775    /// Unlike `validate_path_component`, this allows subdirectories (forward slashes)
776    /// but still prevents path traversal attacks.
777    ///
778    /// This prevents malicious bundle manifests from specifying artifact paths like
779    /// `../../../etc/passwd` that could escape the extraction directory.
780    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        // Reject absolute paths: Unix-style `/` prefix or Windows drive letter (e.g., "C:")
791        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        // Reject path traversal via ".." components
798        if value.split('/').any(|c| c == "..") {
799            bail!("{} contains path traversal component '..'", field_name);
800        }
801        Ok(())
802    }
803
804    /// Extracts bundle manifest, verifies signature, and returns both verification result and typed manifest.
805    /// This helper ensures all bundle installation paths go through the same verified flow.
806    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    /// Extract and parse bundle manifest from bundle archive data.
820    /// Returns both the raw JSON value (for signature verification) and the typed manifest.
821    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                // Parse as raw JSON value first (needed for signature verification)
836                // We need the raw JSON structure for canonicalization during signature verification
837                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                // Convert from already-parsed Value to typed manifest
841                // Note: from_value takes ownership, so we clone the Value here to preserve
842                // it for signature verification. This is necessary because canonicalization
843                // requires the exact JSON structure as parsed.
844                let manifest: BundleManifest = serde_json::from_value(manifest_json.clone())
845                    .map_err(|e| eyre::eyre!("failed to parse manifest.json: {}", e))?;
846
847                // Validate required fields
848                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                // Validate fields are safe for use in filesystem paths
856                // This prevents path traversal attacks where malicious manifests could
857                // write files outside the intended applications directory
858                Self::validate_path_component(&manifest.package, "package")?;
859                Self::validate_path_component(&manifest.app_version, "appVersion")?;
860
861                // Validate artifact paths to prevent path traversal attacks
862                // These paths are used to locate files within the extracted bundle
863                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                // Validate runtime version compatibility
877                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    /// Check if a blob contains a bundle archive by peeking at the first few entries.
904    /// This is a lightweight check that only reads the archive structure, not the full content.
905    ///
906    /// Returns true if manifest.json is found, false otherwise.
907    /// Logs warnings for parsing errors to help diagnose corrupted bundles.
908    pub fn is_bundle_blob(blob_bytes: &[u8]) -> bool {
909        // Quick check: try to parse as gzip/tar and look for manifest.json
910        let tar = GzDecoder::new(blob_bytes);
911        let mut archive = Archive::new(tar);
912
913        // Only check first 10 entries to avoid reading entire archive
914        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; // Give up after 10 entries
928            }
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                            // Continue checking other entries
940                        }
941                    }
942                }
943                Err(e) => {
944                    warn!(
945                        "Failed to read tar archive entry (possible corruption): {}",
946                        e
947                    );
948                    // Continue checking other entries
949                }
950            }
951        }
952        false
953    }
954
955    /// Install an application from a bundle blob that's already in the blobstore.
956    /// This is used when a bundle blob is received via blob sharing or discovery.
957    /// No metadata needed - bundle detection happens via is_bundle_blob()
958    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        // Get bundle bytes from blobstore
969        let Some(bundle_bytes) = self.get_blob_bytes(blob_id, None).await? else {
970            bail!("bundle blob not found");
971        };
972
973        // Extract manifest and verify signature
974        // No metadata needed - bundle detection happens via is_bundle_blob()
975        // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
976        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        // Extract artifacts with deduplication
988        // Must be done before spawn_blocking
989        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        // Wrap blocking I/O in spawn_blocking to avoid blocking async runtime
1001        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        // Extract metadata from bundle manifest and serialize it
1029        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            // Serialize metadata to JSON bytes
1098            serde_json::to_vec(&serde_json::Value::Object(metadata_obj))?
1099        };
1100
1101        // Install application with extracted metadata
1102        self.install_application(
1103            blob_id,
1104            size,
1105            source,
1106            bundle_metadata,
1107            package,
1108            version,
1109            Some(&signer_id), // signer_id from manifest verification
1110            true,             // is_bundle: true for bundles
1111        )
1112    }
1113
1114    /// Find duplicate artifact in other versions by hash and relative path
1115    /// Only matches files with the same relative path within the bundle to avoid
1116    /// collisions between files with the same name in different directories
1117    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        // Check other versions for the same hash at the same relative path
1125        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; // Skip current version
1132                    }
1133
1134                    // Check extracted directory in this version at the same relative path
1135                    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                        // Compute hash of candidate file
1140                        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    /// Extract bundle artifacts with deduplication
1156    ///
1157    /// This function is synchronized per package-version to prevent race conditions
1158    /// when multiple concurrent calls try to extract the same bundle.
1159    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        // Create extraction directory
1168        fs::create_dir_all(extract_dir)?;
1169
1170        // Use a lock file to prevent concurrent extraction of the same bundle version
1171        // Lock file path: extract_dir/.extracting.lock
1172        let lock_file_path = extract_dir.join(".extracting.lock");
1173        let marker_file_path = extract_dir.join(".extracted");
1174
1175        // Check if extraction is already complete
1176        // Only skip if marker exists AND the expected WASM file exists
1177        // This handles the case where files were deleted but marker remains
1178        if marker_file_path.exists() {
1179            // Check if WASM file exists (using manifest to determine path)
1180            // If marker exists but WASM doesn't, marker is stale - remove it and re-extract
1181            let wasm_relative_path = _manifest
1182                .wasm
1183                .as_ref()
1184                .map(|w| w.path.as_str())
1185                .unwrap_or("app.wasm");
1186
1187            // Validate WASM path to prevent path traversal attacks before checking existence
1188            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            // Additional validation: ensure the resolved path stays within extract_dir
1198            if wasm_path.exists() {
1199                // Validate path traversal even if file exists
1200                let canonical_wasm = wasm_path.canonicalize_utf8()?;
1201
1202                // extract_dir might not exist if wasm_relative_path contains subdirectories
1203                // Reconstruct canonical extract_dir from wasm_path by removing relative path components
1204                let canonical_extract = if extract_dir.exists() {
1205                    extract_dir.canonicalize_utf8()?
1206                } else {
1207                    // Reconstruct extract_dir from wasm_path by removing wasm_relative_path components
1208                    // Since we validated wasm_relative_path doesn't contain "..", this is safe
1209                    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                    // Count depth of wasm_relative_path (number of path components)
1215                    let relative_depth = wasm_relative_path
1216                        .split('/')
1217                        .filter(|s| !s.is_empty())
1218                        .count()
1219                        .saturating_sub(1); // Subtract 1 for the filename itself
1220
1221                    // Go up relative_depth levels from wasm_parent to get extract_dir
1222                    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                // Marker exists but WASM doesn't - remove stale marker and re-extract
1252                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        // Try to acquire exclusive lock by creating lock file atomically
1262        // create_new() is atomic - fails if file exists (works on Unix and Windows)
1263        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                // Lock file created - we're the first to extract
1270                true
1271            }
1272            Err(_) => {
1273                // Lock file already exists - another extraction is in progress
1274                // Wait and check if extraction completes
1275                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                // If marker still doesn't exist after waiting, proceed anyway
1287                // (lock file might be stale from crashed process)
1288                warn!(
1289                    package,
1290                    version = current_version,
1291                    "Lock file exists but extraction not complete, proceeding anyway"
1292                );
1293                false
1294            }
1295        };
1296
1297        // Track if we created a lock file that needs cleanup
1298        let mut lock_created_by_us = lock_acquired;
1299
1300        // Only proceed with extraction if we acquired the lock
1301        // (or if lock is stale and we're proceeding anyway)
1302        if !lock_acquired {
1303            // Try to remove stale lock and retry
1304            let _ = fs::remove_file(&lock_file_path);
1305            // Check marker one more time
1306            if marker_file_path.exists() {
1307                return Ok(());
1308            }
1309            // Create lock file again - handle race condition where another thread
1310            // might have created it between removal and this creation
1311            match std::fs::OpenOptions::new()
1312                .create_new(true)
1313                .write(true)
1314                .open(lock_file_path.as_std_path())
1315            {
1316                Ok(_) => {
1317                    // Successfully acquired lock, proceed with extraction
1318                    lock_created_by_us = true;
1319                }
1320                Err(_) => {
1321                    // Another thread created the lock between removal and creation
1322                    // Wait briefly and check if extraction completed
1323                    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                    // If marker still doesn't exist, the other thread is still extracting
1333                    // Return error to avoid concurrent extraction
1334                    bail!(
1335                        "Failed to acquire extraction lock after retry - another process is extracting"
1336                    );
1337                }
1338            }
1339        }
1340
1341        // Ensure lock file is cleaned up even if extraction fails
1342        // Use a guard to clean up on early return or error
1343        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        // Extract all files from bundle
1365        for entry_result in archive.entries()? {
1366            let mut entry = entry_result?;
1367
1368            // Extract path_bytes first, converting to owned to drop borrow
1369            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            // Skip macOS resource fork files (._* files)
1381            // Check filename component, not full path, to catch files in subdirectories
1382            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            // Read content (header borrow is dropped)
1392            let mut content = Vec::new();
1393            std::io::copy(&mut entry, &mut content)?;
1394
1395            // Preserve directory structure from bundle
1396            let dest_path = extract_dir.join(&relative_path);
1397
1398            // Validate path to prevent path traversal attacks
1399            // Check that the relative path doesn't contain ".." components that would escape
1400            if relative_path.contains("..") {
1401                bail!(
1402                    "Path traversal detected: {} contains '..' component",
1403                    relative_path
1404                );
1405            }
1406
1407            // Additional validation: ensure the resolved path stays within extract_dir
1408            // Always validate by constructing expected path, regardless of whether it exists
1409            // This prevents path traversal even when parent directories don't exist yet
1410            let canonical_extract = extract_dir.canonicalize_utf8()?;
1411
1412            // Construct what the canonical dest_path should be
1413            // Since we already checked relative_path doesn't contain "..",
1414            // joining extract_dir with relative_path is safe
1415            let expected_dest = canonical_extract.join(&relative_path);
1416
1417            // Verify the expected path stays within extract_dir
1418            // This works even if the path doesn't exist yet because we're constructing
1419            // it from the canonical extract_dir and a validated relative_path
1420            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, also verify the actual canonicalized path matches expected
1429            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            // Create parent directories if needed
1441            if let Some(parent) = dest_path.parent() {
1442                fs::create_dir_all(parent)?;
1443            }
1444
1445            // Compute hash
1446            let hash = Sha256::digest(&content);
1447            let hash_array: [u8; 32] = hash.into();
1448
1449            // Check for duplicates in other versions at the same relative path
1450            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                // Create hardlink to duplicate file
1458                if let Err(e) = fs::hard_link(duplicate_path.as_std_path(), dest_path.as_std_path())
1459                {
1460                    // If hardlink fails (e.g., cross-filesystem), fall back to copying
1461                    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                // No duplicate found, write new file
1478                fs::write(&dest_path, &content)?;
1479                debug!(
1480                    file = %relative_path,
1481                    hash = hex::encode(hash),
1482                    "extracted artifact"
1483                );
1484            }
1485        }
1486
1487        // Write marker file to indicate extraction is complete
1488        fs::write(&marker_file_path, b"extracted")?;
1489
1490        // Remove lock file explicitly on success (guard will skip removal if we already did it)
1491        if lock_guard.should_remove.get() {
1492            let _ = fs::remove_file(&lock_file_path);
1493            lock_guard.should_remove.set(false); // Prevent guard from removing it again
1494        }
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        // Get application metadata before deleting to check if it's a bundle
1505        let application_meta = handle.get(&key)?;
1506
1507        // Delete the ApplicationMeta entry
1508        handle.delete(&key)?;
1509
1510        // Clean up extracted bundle files if this is a bundle
1511        if let Some(application) = application_meta {
1512            // Check if this is a bundle by checking package/version
1513            // Bundles have meaningful package/version (not "unknown"/"0.0.0")
1514            let is_bundle = application.package.as_ref() != "unknown"
1515                && application.version.as_ref() != "0.0.0";
1516
1517            if is_bundle {
1518                // Construct path to extracted bundle directory
1519                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                // Delete the entire version directory (includes extracted/ subdirectory)
1529                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                        // Don't fail uninstallation if cleanup fails - metadata is already deleted
1545                    } else {
1546                        debug!(
1547                            package = %application.package,
1548                            version = %application.version,
1549                            "Successfully removed extracted bundle directory"
1550                        );
1551                    }
1552
1553                    // Also try to remove parent package directory if it's empty
1554                    let package_dir = node_root
1555                        .join("applications")
1556                        .join(application.package.as_ref());
1557                    if package_dir.exists() {
1558                        // Check if package directory is empty
1559                        if let Ok(mut entries) = fs::read_dir(package_dir.as_std_path()) {
1560                            if entries.next().is_none() {
1561                                // Directory is empty, remove it
1562                                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    /// List all packages
1624    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    /// List all versions of a package
1638    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    /// Get the latest version of a package (version string and application id)
1654    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                        // Try semantic version comparison first
1670                        let is_newer = match (
1671                            Version::parse(&version_str),
1672                            Version::parse(current_version_str),
1673                        ) {
1674                            (Ok(new_version), Ok(current_version)) => {
1675                                // Both are valid semantic versions - use proper comparison
1676                                new_version > current_version
1677                            }
1678                            (Ok(_), Err(_)) => {
1679                                // New version is valid semver, current is not - prefer semver
1680                                true
1681                            }
1682                            (Err(_), Ok(_)) => {
1683                                // Current version is valid semver, new is not - keep current
1684                                false
1685                            }
1686                            (Err(_), Err(_)) => {
1687                                // Neither is valid semver - fall back to lexicographic comparison
1688                                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    /// Install application by package and version
1704    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        // For now, we'll use the source URL to download the application
1712        // In a real implementation, you might want to resolve the package/version to a URL
1713        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        // Test Unicode path separator (full-width slash)
1782        let invalid_path = "package/name";
1783        // Note: This might pass current validation, but documents the limitation
1784        // The current implementation checks for ASCII '/' and '\' only
1785    }
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        // Test URL-encoded path traversal attempts
1861        let invalid_path = "..%2Fetc";
1862        // Note: Current implementation doesn't decode URL encoding
1863        // This test documents that URL-encoded sequences would need to be decoded first
1864        // The path "..%2Fetc" would be treated as a literal string and might pass
1865    }
1866
1867    #[test]
1868    fn test_validate_artifact_path_very_long() {
1869        // Test with a very long path (potential DoS)
1870        // Current implementation doesn't check length, so very long paths pass validation
1871        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}