Skip to main content

ninja/
manager.rs

1use crate::{
2    common::{
3        config::{NinjaConfig, ShurikenReference},
4        registry::{
5            ArmoryItem, download_shuriken, get_shuriken_from_registries,
6            get_shurikens_from_registries,
7        },
8        types::{ArmoryMetadata, FieldValue, ShurikenState},
9    },
10    scripting::{NinjaEngine, dsl::DslContext},
11    shuriken::{Shuriken, ShurikenConfig},
12    utils::{create_tar_gz_bytes, load_shurikens, normalize_shuriken_name},
13};
14use futures_util::future::join_all;
15use anyhow::{Context, Error, Result};
16use dirs_next as dirs;
17use either::Either::{self, Left, Right};
18use log::{debug, info, warn};
19use serde_cbor::to_vec;
20use sha2::{Digest, Sha256};
21use std::{
22    collections::HashMap,
23    env, io,
24    path::{Path, PathBuf},
25    str,
26    sync::Arc,
27};
28use tokio::{
29    fs::{self, File},
30    io::{AsyncReadExt, AsyncWriteExt},
31    sync::Mutex,
32    sync::RwLock,
33};
34
35const MAGIC: &[u8; 6] = b"HSRZEG";
36
37/// A thin wrapper around a spawned process. We keep it simple: the
38/// ManagedProcess owns a `tokio::process::Child` and provides async helpers.
39
40/// The main orchestrator for managing Shurikens and their lifecycle.
41///
42/// `ShurikenManager` handles all operations related to Shuriken services,
43/// including startup, configuration, installation, and lifecycle management.
44/// It maintains the scripting engine, configuration, and in-memory state.
45///
46/// # Fields
47/// - `root_path`: Base directory where Ninja stores data (~/.ninja)
48/// - `engine`: Lua scripting engine for executing Shuriken scripts
49/// - `shurikens`: Cached map of loaded Shurikens by name
50/// - `config`: Global Ninja configuration including registries
51#[derive(Clone, Debug)]
52pub struct ShurikenManager {
53    pub root_path: PathBuf,
54    pub engine: Arc<Mutex<NinjaEngine>>,
55    pub shurikens: Arc<RwLock<HashMap<String, Shuriken>>>,
56    pub config: Arc<RwLock<crate::common::config::NinjaConfig>>,
57}
58
59impl ShurikenManager {
60    /// Creates a new `ShurikenManager` instance.
61    ///
62    /// Initializes the Ninja directory structure (~/.ninja), loads existing Shurikens,
63    /// creates a Lua scripting engine, and loads or generates the global configuration.
64    ///
65    /// # Returns
66    /// - `Ok(ShurikenManager)` on success
67    /// - `Err` if home directory cannot be found or initialization fails
68    ///
69    /// # Panics
70    /// None - all errors are returned as Results
71    pub async fn new() -> Result<Self> {
72        let exe_dir = dirs::home_dir()
73            .ok_or_else(|| Error::msg("Could not find home directory"))?
74            .join(".ninja");
75
76        fs::create_dir_all(&exe_dir).await?;
77
78        let shurikens_dir = exe_dir.join("shurikens");
79        let projects_dir = exe_dir.join("projects");
80
81        // Create shurikens directory if it doesn't exist
82        if !shurikens_dir.exists() {
83            fs::create_dir(&shurikens_dir).await?;
84        }
85
86        if !projects_dir.exists() {
87            fs::create_dir(&projects_dir).await?;
88        }
89
90        let shurikens = load_shurikens(&exe_dir).await?;
91
92        let engine = NinjaEngine::new()
93            .await
94            .map_err(|e| Error::msg(e.to_string()))?;
95
96        let config = if exe_dir.join("config.toml").exists() {
97            let content = fs::read_to_string(exe_dir.join("config.toml")).await?;
98            let config: NinjaConfig = toml::from_str(content.as_str())
99                .map_err(|e| Error::msg(format!("Failed to parse config.toml: {}", e)))?;
100            Arc::new(RwLock::new(config))
101        } else {
102            let config = NinjaConfig::new();
103            config.generate_default_config(&exe_dir).await?;
104            Arc::new(RwLock::new(config))
105        };
106
107        Ok(Self {
108            root_path: exe_dir,
109            engine: Arc::new(Mutex::new(engine)),
110            shurikens: Arc::new(RwLock::new(shurikens)),
111            config,
112        })
113    }
114
115    /// Updates the state of a Shuriken (internal helper).
116    ///
117    /// # Arguments
118    /// - `shuriken`: The Shuriken instance to update
119    /// - `new_state`: The new state to set
120    async fn update_state(&self, shuriken: Shuriken, new_state: ShurikenState) {
121        let mut state_lock = shuriken.state.lock().await;
122        *state_lock = new_state;
123    }
124
125    /// Starts a Shuriken by name.
126    ///
127    /// Executes the Shuriken's startup script and begins running the service.
128    /// Updates the Shuriken's state to `Running` on success.
129    ///
130    /// # Arguments
131    /// - `name`: The name of the Shuriken to start
132    ///
133    /// # Returns
134    /// - `Ok(())` if startup completed successfully
135    /// - `Err` if Shuriken not found, script execution fails, or startup errors occur
136    pub async fn start(&self, name: &str) -> Result<()> {
137        let normalized_name = normalize_shuriken_name(name);
138        info!("Starting shuriken: {}", name);
139        
140        let shurikens = self.shurikens.read().await;
141        let shuriken = shurikens
142            .get(&normalized_name)
143            .ok_or_else(|| {
144                warn!("Shuriken not found: {}", name);
145                anyhow::Error::msg(format!("No such shuriken: {}", name))
146            })?
147            .clone();
148        drop(shurikens);
149
150        let shuriken_dir = self.root_path.join("shurikens").join(&normalized_name);
151        if !shuriken_dir.exists() {
152            warn!("Shuriken directory not found: {}", shuriken_dir.display());
153            return Err(anyhow::Error::msg(format!(
154                "Shuriken directory not found: {}",
155                shuriken_dir.display()
156            )));
157        }
158
159        debug!("Starting process for shuriken: {}", normalized_name);
160        if let Err(e) = shuriken
161            .start(
162                &*self.engine.lock().await,
163                &shuriken_dir,
164                Some(self.clone()),
165            )
166            .await
167        {
168            warn!("Failed to start shuriken '{}': {}", name, e);
169            return Err(anyhow::Error::msg(format!(
170                "Failed to start shuriken '{}': {}",
171                name, e
172            )));
173        }
174
175        self.update_state(shuriken, ShurikenState::Running).await;
176        info!("Successfully started shuriken: {}", name);
177        Ok(())
178    }
179
180    /// Reloads all Shurikens from disk.
181    ///
182    /// Rescans the ~/.ninja/shurikens directory and updates the in-memory cache.
183    /// Useful after manual file changes or to get latest state from disk.
184    ///
185    /// # Returns
186    /// - `Ok(())` on success
187    /// - `Err` if file system operations fail
188    pub async fn refresh(&self) -> Result<()> {
189        info!("Refreshing shurikens from disk");
190        let new_shurikens = load_shurikens(&self.root_path).await?;
191        let count = new_shurikens.len();
192        *self.shurikens.write().await = new_shurikens;
193        info!("Shuriken manager refreshed. Found {} shurikens.", count);
194        Ok(())
195    }
196
197    /// Configures a Shuriken using its configuration script.
198    ///
199    /// Executes the Shuriken's `post_config` function to apply configuration settings.
200    /// Configuration values are templated and written to the Shuriken's config file.
201    ///
202    /// # Arguments
203    /// - `name`: The name of the Shuriken to configure
204    ///
205    /// # Returns
206    /// - `Ok(())` if configuration completed successfully
207    /// - `Err` if Shuriken not found or configuration fails
208    pub async fn configure(&self, name: &str) -> Result<()> {
209        info!("Configuring shuriken: {}", name);
210        let normalized_name = normalize_shuriken_name(name);
211        let partial_shuriken = &self.shurikens.write().await;
212        let shuriken = partial_shuriken.get(&normalized_name);
213
214        if let Some(shuriken) = shuriken {
215            let path = &self.root_path;
216            shuriken
217                .configure(path, &*self.engine.lock().await, Some(self.clone()))
218                .await?
219        } else {
220            warn!("Shuriken not found for configuration: {}", name);
221        }
222        Ok(())
223    }
224
225    /// Removes the lock file for a Shuriken.
226    ///
227    /// Forces the Shuriken to be considered "not running" by removing its lock file.
228    /// Useful for recovering from crashed processes.
229    ///
230    /// # Arguments
231    /// - `name`: The name of the Shuriken
232    ///
233    /// # Returns
234    /// - `Ok(())` if lock file successfully removed or didn't exist
235    /// - `Err` if operation fails
236    pub async fn lockpick(&self, name: &str) -> Result<()> {
237        info!("Lockpicking shuriken: {}", name);
238        let normalized_name = normalize_shuriken_name(name);
239        let partial_shuriken = &self.shurikens.write().await;
240        let shuriken = partial_shuriken.get(&normalized_name);
241
242        if let Some(shuriken) = shuriken {
243            let path = &self.root_path;
244            shuriken.lockpick(path).await?
245        } else {
246            warn!("Shuriken not found for lockpick: {}", name);
247        }
248        Ok(())
249    }
250
251    /// Saves configuration options for a Shuriken.
252    ///
253    /// Persists configuration to disk as TOML and updates the in-memory cache.
254    /// Creates necessary directories if they don't exist.
255    ///
256    /// # Arguments
257    /// - `name`: The name of the Shuriken
258    /// - `data`: Configuration key-value pairs to save
259    ///
260    /// # Returns
261    /// - `Ok(())` if configuration saved successfully
262    /// - `Err` if file operations fail
263    pub async fn save_config(&self, name: &str, data: HashMap<String, FieldValue>) -> Result<()> {
264        info!("Saving config for shuriken: {}", name);
265        debug!("Config data: {:#?}", data);
266        let normalized_name = normalize_shuriken_name(name);
267
268        // Update in-memory config
269        {
270            let mut shurikens = self.shurikens.write().await;
271            if let Some(shuriken) = shurikens.get_mut(&normalized_name) {
272                if let Some(config) = &mut shuriken.config {
273                    config.options = Some(data.clone());
274                } else {
275                    shuriken.config = Some(ShurikenConfig {
276                        config_path: PathBuf::from("options.toml"),
277                        options: Some(data.clone()),
278                    });
279                }
280            }
281        }
282
283        // Write to disk
284        let serialized_data = toml::ser::to_string_pretty(&data)?;
285        let options_path = self
286            .root_path
287            .join("shurikens")
288            .join(&normalized_name)
289            .join(".ninja")
290            .join("options.toml");
291
292        // Ensure the parent directory exists
293        if let Some(parent) = options_path.parent() {
294            fs::create_dir_all(parent).await?;
295        }
296
297        // Remove old file if it exists
298        if options_path.exists() {
299            fs::remove_file(&options_path).await?;
300        }
301
302        fs::write(&options_path, serialized_data).await?;
303
304        Ok(())
305    }
306
307    /// Stops a running Shuriken.
308    ///
309    /// Executes the Shuriken's stop script and halts the service.
310    /// Updates the Shuriken's state to `Idle` on success.
311    ///
312    /// # Arguments
313    /// - `name`: The name of the Shuriken to stop
314    ///
315    /// # Returns
316    /// - `Ok(())` if stop completed successfully
317    /// - `Err` if Shuriken not found or stop script fails
318    pub async fn stop(&self, name: &str) -> Result<()> {
319        let normalized_name = normalize_shuriken_name(name);
320        let shurikens = self.shurikens.read().await;
321        let mut shuriken = shurikens
322            .get(&normalized_name)
323            .ok_or_else(|| anyhow::Error::msg(format!("No such shuriken: {}", name)))?
324            .clone();
325        drop(shurikens);
326
327        let shuriken_dir = self.root_path.join("shurikens").join(&normalized_name);
328        if !shuriken_dir.exists() {
329            return Err(anyhow::Error::msg(format!(
330                "Shuriken directory not found: {}",
331                shuriken_dir.display()
332            )));
333        }
334
335        if let Err(e) = shuriken
336            .stop(
337                &*self.engine.lock().await,
338                &shuriken_dir,
339                Some(self.clone()),
340            )
341            .await
342        {
343            return Err(anyhow::Error::msg(format!(
344                "Failed to stop shuriken '{}': {}",
345                name, e
346            )));
347        }
348
349        self.update_state(shuriken, ShurikenState::Idle).await;
350        Ok(())
351    }
352
353    /// Retrieves a Shuriken by name.
354    ///
355    /// # Arguments
356    /// - `name`: The name of the Shuriken to retrieve
357    ///
358    /// # Returns
359    /// - `Ok(Shuriken)` if found
360    /// - `Err` if Shuriken not found
361    pub async fn get(&self, name: String) -> Result<Shuriken> {
362        debug!("Getting shuriken: {}", name);
363        let partial_shuriken = &self.shurikens.read().await;
364        let maybe_shuriken = partial_shuriken.get(&name);
365        info!(
366            "Getting shuriken '{}' from shurikens: {}",
367            name,
368            partial_shuriken
369                .keys()
370                .cloned()
371                .collect::<Vec<String>>()
372                .join(", ")
373        );
374        if let Some(shuriken) = maybe_shuriken {
375            debug!("Shuriken metadata: {:?}", shuriken.metadata);
376            debug!("Shuriken config: {:?}", shuriken.config);
377            Ok(shuriken.clone())
378        } else {
379            warn!("Shuriken '{}' not found", name);
380            Err(anyhow::Error::msg(format!(
381                "No shuriken of name {} found",
382                name
383            )))
384        }
385    }
386
387    /// Lists all available Shurikens.
388    ///
389    /// # Arguments
390    /// - `state`: If `true`, returns names with their current state; if `false`, returns only names
391    ///
392    /// # Returns
393    /// - `Ok(Left(vec))` with state information if `state` is true
394    /// - `Ok(Right(vec))` with just names if `state` is false
395    /// - `Err` if operation fails
396    pub async fn list(
397        &self,
398        state: bool,
399    ) -> Result<Either<Vec<(String, ShurikenState)>, Vec<String>>> {
400        if state {
401            let shurikens = self.shurikens.read().await;
402            let futures = shurikens
403                .iter()
404                .map(async |(name, shuriken)| {
405                    let state = shuriken.state.lock().await;
406                    (name.clone(), state.clone())
407                })
408                .collect::<Vec<_>>();
409
410            let values = join_all(futures).await;
411
412            debug!("Listing shurikens with state: {:?}", values);
413            Ok(Left(values))
414        } else {
415            let shurikens = self.shurikens.read().await;
416            let keys: Vec<String> = shurikens.keys().cloned().collect();
417            debug!("Listing shuriken names: {:?}", keys);
418            Ok(Right(keys))
419        }
420    }
421
422    /// Creates a new DSL context for script execution.
423    ///
424    /// # Returns
425    /// A `DslContext` that can be used to interpret Ninja DSL commands
426    pub fn dsl_ctx(&self) -> DslContext {
427        DslContext {
428            selected: Arc::new(RwLock::new(None)),
429            manager: self.clone(),
430        }
431    }
432
433    /// Packages a Shuriken into a distributable `.shuriken` file.
434    ///
435    /// Creates a signed archive containing metadata, the Shuriken directory, and SHA256 checksum.
436    /// Format: MAGIC + metadata_length + metadata + archive_length + archive + signature
437    ///
438    /// # Arguments
439    /// - `meta`: Metadata for the packaged Shuriken
440    /// - `path`: Path to the Shuriken directory to package
441    /// - `output`: Optional output directory (defaults to ~/.ninja/blacksmith)
442    ///
443    /// # Returns
444    /// - `Ok(())` if packaging succeeded
445    /// - `Err` if metadata is too large, archive creation fails, or I/O fails
446    pub async fn forge(
447        &self,
448        meta: ArmoryMetadata,
449        path: PathBuf,
450        output: Option<PathBuf>,
451    ) -> Result<()> {
452        let output = output.unwrap_or_else(|| self.root_path.join("blacksmith"));
453        if !output.exists() {
454            fs::create_dir_all(&output).await?;
455        }
456        let path = &self.root_path.join("shurikens").join(path);
457
458        let shuriken_path = output.join(format!("{}-{}.shuriken", meta.id, meta.platform));
459        let mut file = File::create(shuriken_path).await?;
460
461        // ---- 1) Serialize metadata ----
462
463        let serialized_metadata = to_vec(&meta)?;
464
465        if serialized_metadata.len() > u16::MAX as usize {
466            return Err(anyhow::Error::msg(
467                "Metadata too large to fit in u16 length field",
468            ));
469        }
470
471        // ---- 2) Build archive bytes (tar.gz) in a blocking thread ----
472        let archive = {
473            let path_clone = path.clone();
474            tokio::task::spawn_blocking(move || create_tar_gz_bytes(&path_clone)).await??
475        };
476        let archive_len = archive.len();
477
478        if archive_len > u32::MAX as usize {
479            return Err(anyhow::Error::msg(
480                "Archive too large to fit in u32 length field",
481            ));
482        }
483
484        // ---- 3) Compute signature = SHA256(archive) ----
485        let mut hasher = Sha256::new();
486
487        hasher.update(&archive);
488        let signature = hasher.finalize(); // 32 bytes
489
490        // ---- 4) Write in correct order ----
491        // [MAGIC]                 // 4 bytes
492        // [metadata_length]       // u16 LE
493        // [metadata]              // CBOR
494        // [archive_length]        // u32 LE
495        // [archive]               // tar.gz
496        // [signature]             // 32 bytes SHA-256(archive)
497
498        // MAGIC
499        file.write_all(MAGIC).await?;
500
501        // metadata_length (u16 LE)
502        let meta_len_le = (serialized_metadata.len() as u16).to_le_bytes();
503        file.write_all(&meta_len_le).await?;
504
505        // metadata
506        file.write_all(&serialized_metadata).await?;
507
508        // archive_length (u32 LE)
509        let archive_len_le = (archive_len as u32).to_le_bytes();
510        file.write_all(&archive_len_le).await?;
511
512        // archive
513        file.write_all(&archive).await?;
514
515        // signature
516        file.write_all(&signature).await?;
517
518        Ok(())
519    }
520
521    /// Removes a Shuriken from the system.
522    ///
523    /// Deletes the Shuriken directory and removes it from the cache.
524    ///
525    /// # Arguments
526    /// - `name`: The name of the Shuriken to remove
527    ///
528    /// # Returns
529    /// - `Ok(())` if removal succeeded
530    /// - `Err` if Shuriken not found or deletion fails
531    pub async fn remove(&self, name: &str) -> Result<()> {
532        info!("Removing shuriken: {}", name);
533        let normalized_name = normalize_shuriken_name(name);
534        warn!("Deleting {}.", name);
535        fs::remove_dir_all(format!("shurikens/{}", normalized_name)).await?;
536        let _ = &self.shurikens.write().await.remove(&normalized_name);
537        info!("Successfully deleted shuriken {}, refreshing.", name);
538        #[cfg(debug_assertions)]
539        dbg!("{:#?}", &self.shurikens);
540        Ok(())
541    }
542
543    /// Resets and reinitializes the Lua scripting engine.
544    ///
545    /// Useful when you need to clear engine state between operations.
546    /// Creates a new engine instance with all modules.
547    ///
548    /// # Returns
549    /// - `Ok(())` on success
550    /// - `Err` if engine initialization fails
551    pub async fn reset_engine(&self) -> Result<()> {
552        let new_engine = NinjaEngine::new()
553            .await
554            .map_err(|e| Error::msg(e.to_string()))?;
555        *self.engine.lock().await = new_engine; // don't ask i need to reset the engine everytime i run scripts in gui.
556        Ok(())
557    }
558
559    // -------------------- Installation functions --------------------
560
561    /// Installs a Shuriken from various sources.
562    ///
563    /// Automatically detects the source type and installs accordingly:
564    /// - Registry reference (e.g., "registry:shuriken")
565    /// - Direct URL
566    /// - Local file path
567    ///
568    /// # Arguments
569    /// - `name`: The Shuriken source (reference, URL, or file path)
570    ///
571    /// # Returns
572    /// - `Ok(())` if installation completed
573    /// - `Err` if source is invalid or installation fails
574    pub async fn install(&self, name: &str) -> Result<()> {
575        if ShurikenReference::parse(&name).is_ok() {
576            let reference = ShurikenReference::parse(&name)?;
577            self.install_from_registry(&reference).await
578        } else if url::Url::parse(&name).is_ok() {
579            self.install_url(&name).await
580        } else {
581            self.install_file(&PathBuf::from(name)).await
582        }
583    }
584
585    /// Installs a Shuriken from a direct URL.
586    ///
587    /// Downloads the .shuriken file and installs it.
588    ///
589    /// # Arguments
590    /// - `url`: The download URL for the .shuriken file
591    ///
592    /// # Returns
593    /// - `Ok(())` if installation succeeded
594    /// - `Err` if download or installation fails
595    pub async fn install_url(&self, url: &str) -> Result<()> {
596        let temp_path = self.root_path.join("temp_shuriken.shuriken");
597        download_shuriken(&temp_path, url).await?;
598        let result = self.install_file(&temp_path).await;
599        let _ = fs::remove_file(temp_path).await; // clean up temp file
600        result
601    }
602
603    /// Install a shuriken from a registry reference (e.g., "my-registry:my-shuriken")
604    pub async fn install_from_registry(
605        &self,
606        reference: &crate::common::config::ShurikenReference,
607    ) -> Result<()> {
608        let registries = &self.config.read().await.registries;
609        let download_url =
610            crate::common::config::resolve_download_url(registries, reference).await?;
611        info!(
612            "Installing shuriken {} from {}",
613            reference.shuriken, download_url
614        );
615        self.install_url(&download_url).await
616    }
617
618    /// Installs a Shuriken from a local file.
619    ///
620    /// Validates the .shuriken file format (magic bytes, metadata, checksum),
621    /// extracts the archive, verifies platform compatibility, and runs postinstall hooks.
622    ///
623    /// # Arguments
624    /// - `path`: Path to the .shuriken file
625    ///
626    /// # Returns
627    /// - `Ok(())` if installation succeeded
628    /// - `Err` if file is invalid, corrupted, incompatible, or extraction fails
629    ///
630    /// # File Format
631    /// - MAGIC (6 bytes): "HSRZEG"
632    /// - metadata_length (u16 LE)
633    /// - metadata (CBOR encoded)
634    /// - archive_length (u32 LE)  
635    /// - archive (tar.gz)
636    /// - signature (32 bytes SHA256)
637    pub async fn install_file(&self, path: &Path) -> Result<(), anyhow::Error> {
638        use sha2::{Digest, Sha256};
639        use std::io::Cursor;
640
641        info!("Starting installation");
642        if !path.exists() {
643            return Err(anyhow::Error::msg("Path does not exist"));
644        }
645
646        let mut file = tokio::fs::File::open(&path)
647            .await
648            .map_err(|e| io::Error::other(format!("Failed to open shuriken file: {e}")))?;
649
650        // 1) MAGIC (6 bytes)
651        let mut magic_buf = [0u8; 6];
652        file.read_exact(&mut magic_buf).await?;
653        if &magic_buf != MAGIC {
654            return Err(anyhow::Error::msg("Invalid shuriken file (bad MAGIC)."));
655        }
656        // this comment is just a small change
657        // 2) metadata_length (u16 LE)
658        let mut meta_len_buf = [0u8; 2];
659        file.read_exact(&mut meta_len_buf).await?;
660        let metadata_length = u16::from_le_bytes(meta_len_buf) as usize;
661
662        const MAX_METADATA: usize = 64 * 1024; // 64 KB
663        if metadata_length > MAX_METADATA {
664            return Err(anyhow::Error::msg("Metadata too large"));
665        }
666
667        // 3) metadata (CBOR)
668        let mut metadata_buf = vec![0u8; metadata_length];
669        file.read_exact(&mut metadata_buf).await?;
670        let metadata: ArmoryMetadata =
671            serde_cbor::from_slice(&metadata_buf).context("Failed to parse metadata CBOR")?;
672
673        info!("Metadata parsing complete");
674
675        debug!("MAGIC:     {:?}", magic_buf);
676        debug!("meta_len:  {}", metadata_length);
677        debug!("metadata:  {:#?}", metadata);
678
679        // 4) archive_length (u32 LE)
680        let mut archive_len_buf = [0u8; 4];
681        file.read_exact(&mut archive_len_buf).await?;
682        let archive_length = u32::from_le_bytes(archive_len_buf) as usize;
683
684        const MAX_ARCHIVE: usize = 1024 * 1024 * 1024; // 1 GB
685        if archive_length > MAX_ARCHIVE {
686            return Err(anyhow::Error::msg("Archive too large"));
687        }
688
689        // 5) archive (exactly archive_length bytes)
690        let mut archive_buf = vec![0u8; archive_length];
691        file.read_exact(&mut archive_buf).await?;
692
693        // 6) signature (rest of the file)
694        let mut signature = [0u8; 32];
695        file.read_exact(&mut signature).await?;
696
697        // Verify checksum = SHA256(MAGIC + metadata_len + metadata + archive_len + archive)
698        let mut hasher = Sha256::new();
699        hasher.update(&archive_buf);
700        let digest = hasher.finalize();
701
702        if digest.as_slice() != signature {
703            return Err(anyhow::Error::msg(
704                "Shuriken file signature mismatch (archive corrupted or tampered).",
705            ));
706        }
707
708        // Platform check
709        if !metadata.platform.contains(env::consts::OS)
710            && !metadata.platform.contains(env::consts::ARCH)
711        {
712            return Err(anyhow::Error::msg(
713                "Unsupported platform. Current platform is not the same as the shuriken's destined platform.",
714            ));
715        }
716
717        // Unpack archive in blocking task
718        let archive_cursor = Cursor::new(archive_buf);
719        let archive_name = normalize_shuriken_name(&metadata.name);
720        let unpack_path = self.root_path.clone().join("shurikens").join(&archive_name);
721        let root_path = self.root_path.clone().join("shurikens").join(&archive_name);
722
723        tokio::task::spawn_blocking(move || -> Result<(), anyhow::Error> {
724            let gz_decoder = flate2::read::GzDecoder::new(archive_cursor);
725            let mut archive = tar::Archive::new(gz_decoder);
726            archive.unpack(&unpack_path)?;
727            Ok(())
728        })
729        .await??;
730
731        // Run postinstall script if present
732        if let Some(pi_script) = &metadata.postinstall {
733            info!("Running postinstall script");
734            let path = root_path.join(pi_script);
735            let engine = &self.engine.lock().await;
736            engine
737                .execute_file(&path, Some(&root_path), Some(self.clone()))
738                .await?;
739        }
740
741        // save config so the paths are correct when we launch.
742        self.refresh().await?;
743        self.configure(&metadata.name).await?;
744        Ok(())
745    }
746
747    /// Fetches all available Shurikens from all configured registries.
748    ///
749    /// # Returns
750    /// A vector of `ArmoryItem` entries from all registries
751    pub async fn registry_get_all_shurikens(&self) -> Vec<ArmoryItem> {
752        let registries: Vec<String> = self
753            .config
754            .read()
755            .await
756            .registries
757            .values()
758            .cloned()
759            .collect::<Vec<_>>();
760        let shurikens = get_shurikens_from_registries(&registries).await;
761        shurikens
762    }
763
764    /// Fetches a specific Shuriken from any configured registry.
765    ///
766    /// # Arguments
767    /// - `name`: The name of the Shuriken to fetch
768    ///
769    /// # Returns
770    /// - `Some(ArmoryItem)` if found in a registry
771    /// - `None` if not found
772    pub async fn registry_get_shuriken(&self, name: String) -> Option<ArmoryItem> {
773        let partial_registries = &self.config.read().await.registries;
774        let registries: Vec<String> = partial_registries.values().cloned().collect::<Vec<_>>();
775        get_shuriken_from_registries(name, &registries).await
776    }
777
778    // -------------------- Project management API --------------------
779
780    /// Lists all projects in the projects directory.
781    ///
782    /// # Returns
783    /// - `Ok(Vec<String>)` with project names
784    /// - `Err` if directory access fails
785    pub async fn get_projects(&self) -> Result<Vec<String>> {
786        let path = &self.root_path.join("projects");
787        let mut entries: Vec<String> = Vec::new();
788        let mut fs_entries = fs::read_dir(path).await?;
789        while let Some(entry) = fs_entries.next_entry().await? {
790            let path = entry.path();
791
792            if path.is_dir()
793                && let Some(name) = path.file_name().and_then(|n| n.to_str())
794            {
795                if name == "pma" || name == "fancy-index" {
796                    continue;
797                }
798
799                entries.push(name.to_string());
800            }
801        }
802        Ok(entries)
803    }
804}