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#[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 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 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 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 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 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 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 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 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 {
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 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 if let Some(parent) = options_path.parent() {
294 fs::create_dir_all(parent).await?;
295 }
296
297 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 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 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 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 pub fn dsl_ctx(&self) -> DslContext {
427 DslContext {
428 selected: Arc::new(RwLock::new(None)),
429 manager: self.clone(),
430 }
431 }
432
433 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 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 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 let mut hasher = Sha256::new();
486
487 hasher.update(&archive);
488 let signature = hasher.finalize(); file.write_all(MAGIC).await?;
500
501 let meta_len_le = (serialized_metadata.len() as u16).to_le_bytes();
503 file.write_all(&meta_len_le).await?;
504
505 file.write_all(&serialized_metadata).await?;
507
508 let archive_len_le = (archive_len as u32).to_le_bytes();
510 file.write_all(&archive_len_le).await?;
511
512 file.write_all(&archive).await?;
514
515 file.write_all(&signature).await?;
517
518 Ok(())
519 }
520
521 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 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; Ok(())
557 }
558
559 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 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; result
601 }
602
603 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 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 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 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; if metadata_length > MAX_METADATA {
664 return Err(anyhow::Error::msg("Metadata too large"));
665 }
666
667 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 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; if archive_length > MAX_ARCHIVE {
686 return Err(anyhow::Error::msg("Archive too large"));
687 }
688
689 let mut archive_buf = vec![0u8; archive_length];
691 file.read_exact(&mut archive_buf).await?;
692
693 let mut signature = [0u8; 32];
695 file.read_exact(&mut signature).await?;
696
697 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 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 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 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 self.refresh().await?;
743 self.configure(&metadata.name).await?;
744 Ok(())
745 }
746
747 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(®istries).await;
761 shurikens
762 }
763
764 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, ®istries).await
776 }
777
778 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}