Skip to main content

tandem_server/
pack_manager.rs

1use std::collections::HashMap;
2use std::fs::{self, File};
3use std::io::{copy, Read};
4use std::path::{Component, Path, PathBuf};
5use std::sync::Arc;
6
7use anyhow::{anyhow, Context};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use sha2::{Digest, Sha256};
11use tokio::sync::Mutex;
12use uuid::Uuid;
13use zip::{write::SimpleFileOptions, CompressionMethod, ZipArchive, ZipWriter};
14
15const MARKER_FILE: &str = "tandempack.yaml";
16const INDEX_FILE: &str = "index.json";
17const CURRENT_FILE: &str = "current";
18const STAGING_DIR: &str = ".staging";
19const EXPORTS_DIR: &str = "exports";
20const MAX_ARCHIVE_BYTES: u64 = 512 * 1024 * 1024;
21const MAX_EXTRACTED_BYTES: u64 = 512 * 1024 * 1024;
22const MAX_FILES: usize = 5_000;
23const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
24const MAX_PATH_DEPTH: usize = 24;
25const MAX_ENTRY_COMPRESSION_RATIO: u64 = 200;
26const MAX_ARCHIVE_COMPRESSION_RATIO: u64 = 200;
27const SECRET_SCAN_MAX_FILE_BYTES: u64 = 512 * 1024;
28const SECRET_SCAN_PATTERNS: &[&str] = &["sk-", "sk_live_", "ghp_", "xoxb-", "xoxp-", "AKIA"];
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PackManifest {
32    pub name: String,
33    pub version: String,
34    #[serde(rename = "type")]
35    pub pack_type: String,
36    #[serde(default)]
37    pub manifest_schema_version: Option<String>,
38    #[serde(default)]
39    pub pack_id: Option<String>,
40    #[serde(default)]
41    pub capabilities: Value,
42    #[serde(default)]
43    pub entrypoints: Value,
44    #[serde(default)]
45    pub contents: Value,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PackInstallRecord {
50    pub pack_id: String,
51    pub name: String,
52    pub version: String,
53    pub pack_type: String,
54    pub install_path: String,
55    pub sha256: String,
56    pub installed_at_ms: u64,
57    pub source: Value,
58    #[serde(default)]
59    pub marker_detected: bool,
60    #[serde(default)]
61    pub routines_enabled: bool,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct PackIndex {
66    #[serde(default)]
67    pub packs: Vec<PackInstallRecord>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct PackInspection {
72    pub installed: PackInstallRecord,
73    pub manifest: Value,
74    pub trust: Value,
75    pub risk: Value,
76    pub permission_sheet: Value,
77    pub workflow_extensions: Value,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct PackInstallRequest {
82    #[serde(default)]
83    pub path: Option<String>,
84    #[serde(default)]
85    pub url: Option<String>,
86    #[serde(default)]
87    pub source: Value,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct PackUninstallRequest {
92    #[serde(default)]
93    pub pack_id: Option<String>,
94    #[serde(default)]
95    pub name: Option<String>,
96    #[serde(default)]
97    pub version: Option<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct PackExportRequest {
102    #[serde(default)]
103    pub pack_id: Option<String>,
104    #[serde(default)]
105    pub name: Option<String>,
106    #[serde(default)]
107    pub version: Option<String>,
108    #[serde(default)]
109    pub output_path: Option<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct PackExportResult {
114    pub path: String,
115    pub sha256: String,
116    pub bytes: u64,
117}
118
119#[derive(Clone)]
120pub struct PackManager {
121    root: PathBuf,
122    index_lock: Arc<Mutex<()>>,
123    pack_locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
124}
125
126impl PackManager {
127    pub fn new(root: PathBuf) -> Self {
128        Self {
129            root,
130            index_lock: Arc::new(Mutex::new(())),
131            pack_locks: Arc::new(Mutex::new(HashMap::new())),
132        }
133    }
134
135    pub fn default_root() -> PathBuf {
136        tandem_core::resolve_shared_paths()
137            .map(|paths| paths.canonical_root.join("packs"))
138            .unwrap_or_else(|_| {
139                dirs::home_dir()
140                    .unwrap_or_else(|| PathBuf::from("."))
141                    .join(".tandem")
142                    .join("packs")
143            })
144    }
145
146    pub async fn list(&self) -> anyhow::Result<Vec<PackInstallRecord>> {
147        let index = self.read_index().await?;
148        Ok(index.packs)
149    }
150
151    pub async fn inspect(&self, selector: &str) -> anyhow::Result<PackInspection> {
152        let index = self.read_index().await?;
153        let Some(installed) = select_record(&index, Some(selector), None) else {
154            return Err(anyhow!("pack not found"));
155        };
156        let manifest_path = PathBuf::from(&installed.install_path).join(MARKER_FILE);
157        let manifest_raw = tokio::fs::read_to_string(&manifest_path)
158            .await
159            .with_context(|| format!("read {}", manifest_path.display()))?;
160        let manifest: Value = serde_yaml::from_str(&manifest_raw).context("parse manifest yaml")?;
161        let trust = inspect_trust(&manifest, &installed.install_path);
162        let risk = inspect_risk(&manifest, &installed);
163        let permission_sheet = inspect_permission_sheet(&manifest, &risk);
164        let workflow_extensions = inspect_workflow_extensions(&manifest);
165        Ok(PackInspection {
166            installed,
167            manifest,
168            trust,
169            risk,
170            permission_sheet,
171            workflow_extensions,
172        })
173    }
174
175    pub async fn install(&self, input: PackInstallRequest) -> anyhow::Result<PackInstallRecord> {
176        self.ensure_layout().await?;
177        let source_file = if let Some(path) = input.path.as_deref() {
178            PathBuf::from(path)
179        } else if let Some(url) = input.url.as_deref() {
180            self.download_to_staging(url).await?
181        } else {
182            return Err(anyhow!("install requires either `path` or `url`"));
183        };
184        let source_meta = tokio::fs::metadata(&source_file)
185            .await
186            .with_context(|| format!("stat {}", source_file.display()))?;
187        if source_meta.len() > MAX_ARCHIVE_BYTES {
188            return Err(anyhow!(
189                "archive exceeds max size ({} > {})",
190                source_meta.len(),
191                MAX_ARCHIVE_BYTES
192            ));
193        }
194        if !contains_root_marker(&source_file)? {
195            return Err(anyhow!("zip does not contain root marker tandempack.yaml"));
196        }
197        let manifest = read_manifest_from_zip(&source_file)?;
198        validate_manifest(&manifest)?;
199        let sha256 = sha256_file(&source_file)?;
200        let pack_id = manifest
201            .pack_id
202            .clone()
203            .unwrap_or_else(|| manifest.name.clone());
204        let pack_lock = self.pack_lock(&manifest.name).await;
205        let _pack_guard = pack_lock.lock().await;
206
207        let stage_id = format!("install-{}", Uuid::new_v4());
208        let stage_root = self.root.join(STAGING_DIR).join(stage_id);
209        let stage_unpacked = stage_root.join("unpacked");
210        tokio::fs::create_dir_all(&stage_unpacked).await?;
211        safe_extract_zip(&source_file, &stage_unpacked)?;
212        let secret_hits = scan_embedded_secrets(&stage_unpacked)?;
213        let strict_secret_scan = std::env::var("TANDEM_PACK_SECRET_SCAN_STRICT")
214            .map(|v| {
215                let n = v.to_ascii_lowercase();
216                n == "1" || n == "true" || n == "yes" || n == "on"
217            })
218            .unwrap_or(false);
219        if strict_secret_scan && !secret_hits.is_empty() {
220            let _ = tokio::fs::remove_dir_all(&stage_root).await;
221            return Err(anyhow!(
222                "embedded_secret_detected: {} potential secret(s) found (first: {})",
223                secret_hits.len(),
224                secret_hits[0]
225            ));
226        }
227
228        let install_parent = self.root.join(&manifest.name);
229        let install_target = install_parent.join(&manifest.version);
230        if install_target.exists() {
231            let _ = tokio::fs::remove_dir_all(&stage_root).await;
232            return Err(anyhow!(
233                "pack already installed: {}@{}",
234                manifest.name,
235                manifest.version
236            ));
237        }
238        tokio::fs::create_dir_all(&install_parent).await?;
239        tokio::fs::rename(&stage_unpacked, &install_target)
240            .await
241            .with_context(|| {
242                format!(
243                    "move {} -> {}",
244                    stage_unpacked.display(),
245                    install_target.display()
246                )
247            })?;
248        let _ = tokio::fs::remove_dir_all(&stage_root).await;
249
250        tokio::fs::write(
251            install_parent.join(CURRENT_FILE),
252            format!("{}\n", manifest.version),
253        )
254        .await
255        .ok();
256
257        let record = PackInstallRecord {
258            pack_id,
259            name: manifest.name.clone(),
260            version: manifest.version.clone(),
261            pack_type: manifest.pack_type.clone(),
262            install_path: install_target.to_string_lossy().to_string(),
263            sha256,
264            installed_at_ms: now_ms(),
265            source: if input.source.is_null() {
266                serde_json::json!({
267                    "kind": if input.url.is_some() { "url" } else { "path" },
268                    "path": input.path,
269                    "url": input.url
270                })
271            } else {
272                input.source
273            },
274            marker_detected: true,
275            routines_enabled: false,
276        };
277        self.write_record(record.clone()).await?;
278        Ok(record)
279    }
280
281    pub async fn uninstall(&self, req: PackUninstallRequest) -> anyhow::Result<PackInstallRecord> {
282        let selector = req.pack_id.as_deref().or(req.name.as_deref());
283        let index_snapshot = self.read_index().await?;
284        let Some(snapshot_record) =
285            select_record(&index_snapshot, selector, req.version.as_deref())
286        else {
287            return Err(anyhow!("pack not found"));
288        };
289        let pack_lock = self.pack_lock(&snapshot_record.name).await;
290        let _pack_guard = pack_lock.lock().await;
291
292        let mut index = self.read_index().await?;
293        let Some(record) = select_record(&index, selector, req.version.as_deref()) else {
294            return Err(anyhow!("pack not found"));
295        };
296        let install_path = PathBuf::from(&record.install_path);
297        if install_path.exists() {
298            tokio::fs::remove_dir_all(&install_path).await.ok();
299        }
300        index.packs.retain(|row| {
301            !(row.pack_id == record.pack_id
302                && row.name == record.name
303                && row.version == record.version
304                && row.install_path == record.install_path)
305        });
306        self.write_index(&index).await?;
307        self.repoint_current_if_needed(&record.name).await?;
308        Ok(record)
309    }
310
311    pub async fn export(&self, req: PackExportRequest) -> anyhow::Result<PackExportResult> {
312        let index = self.read_index().await?;
313        let selector = req.pack_id.as_deref().or(req.name.as_deref());
314        let Some(record) = select_record(&index, selector, req.version.as_deref()) else {
315            return Err(anyhow!("pack not found"));
316        };
317        let pack_dir = PathBuf::from(&record.install_path);
318        if !pack_dir.exists() {
319            return Err(anyhow!("installed pack path missing"));
320        }
321        let output = if let Some(path) = req.output_path {
322            PathBuf::from(path)
323        } else {
324            self.root
325                .join(EXPORTS_DIR)
326                .join(format!("{}-{}.zip", record.name, record.version))
327        };
328        if let Some(parent) = output.parent() {
329            tokio::fs::create_dir_all(parent).await?;
330        }
331        zip_directory(&pack_dir, &output)?;
332        let bytes = tokio::fs::metadata(&output).await?.len();
333        Ok(PackExportResult {
334            path: output.to_string_lossy().to_string(),
335            sha256: sha256_file(&output)?,
336            bytes,
337        })
338    }
339
340    pub async fn detect(&self, path: &Path) -> anyhow::Result<bool> {
341        Ok(contains_root_marker(path)?)
342    }
343
344    async fn download_to_staging(&self, url: &str) -> anyhow::Result<PathBuf> {
345        self.ensure_layout().await?;
346        let stage = self
347            .root
348            .join(STAGING_DIR)
349            .join(format!("download-{}.zip", Uuid::new_v4()));
350        let response = reqwest::get(url)
351            .await
352            .with_context(|| format!("download {}", url))?;
353        let bytes = response.bytes().await.context("read body")?;
354        if bytes.len() as u64 > MAX_ARCHIVE_BYTES {
355            return Err(anyhow!(
356                "downloaded archive exceeds max size ({} > {})",
357                bytes.len(),
358                MAX_ARCHIVE_BYTES
359            ));
360        }
361        tokio::fs::write(&stage, &bytes).await?;
362        Ok(stage)
363    }
364
365    async fn ensure_layout(&self) -> anyhow::Result<()> {
366        tokio::fs::create_dir_all(&self.root).await?;
367        tokio::fs::create_dir_all(self.root.join(STAGING_DIR)).await?;
368        tokio::fs::create_dir_all(self.root.join(EXPORTS_DIR)).await?;
369        Ok(())
370    }
371
372    async fn read_index(&self) -> anyhow::Result<PackIndex> {
373        let _index_guard = self.index_lock.lock().await;
374        self.read_index_unlocked().await
375    }
376
377    async fn write_index(&self, index: &PackIndex) -> anyhow::Result<()> {
378        let _index_guard = self.index_lock.lock().await;
379        self.write_index_unlocked(index).await
380    }
381
382    async fn read_index_unlocked(&self) -> anyhow::Result<PackIndex> {
383        let index_path = self.root.join(INDEX_FILE);
384        if !index_path.exists() {
385            return Ok(PackIndex::default());
386        }
387        let raw = tokio::fs::read_to_string(&index_path)
388            .await
389            .with_context(|| format!("read {}", index_path.display()))?;
390        let parsed = serde_json::from_str::<PackIndex>(&raw).unwrap_or_default();
391        Ok(parsed)
392    }
393
394    async fn write_index_unlocked(&self, index: &PackIndex) -> anyhow::Result<()> {
395        self.ensure_layout().await?;
396        let index_path = self.root.join(INDEX_FILE);
397        let tmp = self
398            .root
399            .join(format!("{}.{}.tmp", INDEX_FILE, Uuid::new_v4()));
400        let payload = serde_json::to_string_pretty(index)?;
401        tokio::fs::write(&tmp, format!("{}\n", payload)).await?;
402        tokio::fs::rename(&tmp, &index_path).await?;
403        Ok(())
404    }
405
406    async fn write_record(&self, record: PackInstallRecord) -> anyhow::Result<()> {
407        let _index_guard = self.index_lock.lock().await;
408        let mut index = self.read_index_unlocked().await?;
409        index.packs.retain(|row| {
410            !(row.pack_id == record.pack_id
411                && row.name == record.name
412                && row.version == record.version)
413        });
414        index.packs.push(record);
415        self.write_index_unlocked(&index).await
416    }
417
418    async fn repoint_current_if_needed(&self, pack_name: &str) -> anyhow::Result<()> {
419        let index = self.read_index().await?;
420        let mut versions = index
421            .packs
422            .iter()
423            .filter(|row| row.name == pack_name)
424            .collect::<Vec<_>>();
425        versions.sort_by(|a, b| b.installed_at_ms.cmp(&a.installed_at_ms));
426        let current_path = self.root.join(pack_name).join(CURRENT_FILE);
427        if let Some(latest) = versions.first() {
428            tokio::fs::write(current_path, format!("{}\n", latest.version))
429                .await
430                .ok();
431        } else if current_path.exists() {
432            tokio::fs::remove_file(current_path).await.ok();
433        }
434        Ok(())
435    }
436
437    async fn pack_lock(&self, pack_name: &str) -> Arc<Mutex<()>> {
438        let mut locks = self.pack_locks.lock().await;
439        locks
440            .entry(pack_name.to_string())
441            .or_insert_with(|| Arc::new(Mutex::new(())))
442            .clone()
443    }
444}
445
446fn select_record<'a>(
447    index: &'a PackIndex,
448    selector: Option<&str>,
449    version: Option<&str>,
450) -> Option<PackInstallRecord> {
451    let selector = selector.map(|s| s.trim()).filter(|s| !s.is_empty());
452    let mut matches = index
453        .packs
454        .iter()
455        .filter(|row| match selector {
456            Some(sel) => row.pack_id == sel || row.name == sel,
457            None => true,
458        })
459        .filter(|row| match version {
460            Some(version) => row.version == version,
461            None => true,
462        })
463        .cloned()
464        .collect::<Vec<_>>();
465    matches.sort_by(|a, b| b.installed_at_ms.cmp(&a.installed_at_ms));
466    matches.into_iter().next()
467}
468
469fn contains_root_marker(path: &Path) -> anyhow::Result<bool> {
470    let file = File::open(path).with_context(|| format!("open {}", path.display()))?;
471    let mut archive = ZipArchive::new(file).context("open zip archive")?;
472    for i in 0..archive.len() {
473        let entry = archive.by_index(i).context("read zip entry")?;
474        if entry.name() == MARKER_FILE {
475            return Ok(true);
476        }
477    }
478    Ok(false)
479}
480
481fn read_manifest_from_zip(path: &Path) -> anyhow::Result<PackManifest> {
482    let file = File::open(path).with_context(|| format!("open {}", path.display()))?;
483    let mut archive = ZipArchive::new(file).context("open zip archive")?;
484    let mut manifest_file = archive
485        .by_name(MARKER_FILE)
486        .context("missing root tandempack.yaml")?;
487    let mut text = String::new();
488    manifest_file.read_to_string(&mut text)?;
489    let manifest = serde_yaml::from_str::<PackManifest>(&text).context("parse manifest yaml")?;
490    Ok(manifest)
491}
492
493fn validate_manifest(manifest: &PackManifest) -> anyhow::Result<()> {
494    if manifest.name.trim().is_empty() {
495        return Err(anyhow!("manifest.name is required"));
496    }
497    if manifest.version.trim().is_empty() {
498        return Err(anyhow!("manifest.version is required"));
499    }
500    if manifest.pack_type.trim().is_empty() {
501        return Err(anyhow!("manifest.type is required"));
502    }
503    Ok(())
504}
505
506fn safe_extract_zip(zip_path: &Path, out_dir: &Path) -> anyhow::Result<()> {
507    let file = File::open(zip_path).with_context(|| format!("open {}", zip_path.display()))?;
508    let mut archive = ZipArchive::new(file).context("open zip archive")?;
509    let mut extracted_files = 0usize;
510    let mut extracted_total = 0u64;
511    let mut compressed_total = 0u64;
512    for i in 0..archive.len() {
513        let entry = archive.by_index(i).context("zip entry read")?;
514        let entry_name = entry.name().to_string();
515        if entry_name.ends_with('/') {
516            continue;
517        }
518        validate_zip_entry_name(&entry_name)?;
519        let out_path = out_dir.join(&entry_name);
520        let size = entry.size();
521        let compressed_size = entry.compressed_size().max(1);
522        let entry_ratio = size.saturating_div(compressed_size);
523        if entry_ratio > MAX_ENTRY_COMPRESSION_RATIO {
524            return Err(anyhow!(
525                "zip entry compression ratio too high: {} ({}/{})",
526                entry_name,
527                size,
528                compressed_size
529            ));
530        }
531        if size > MAX_FILE_BYTES {
532            return Err(anyhow!(
533                "zip entry exceeds max size: {} ({} > {})",
534                entry_name,
535                size,
536                MAX_FILE_BYTES
537            ));
538        }
539        extracted_files = extracted_files.saturating_add(1);
540        if extracted_files > MAX_FILES {
541            return Err(anyhow!(
542                "zip has too many files ({} > {})",
543                extracted_files,
544                MAX_FILES
545            ));
546        }
547        extracted_total = extracted_total.saturating_add(size);
548        if extracted_total > MAX_EXTRACTED_BYTES {
549            return Err(anyhow!(
550                "zip extracted bytes exceed max ({} > {})",
551                extracted_total,
552                MAX_EXTRACTED_BYTES
553            ));
554        }
555        compressed_total = compressed_total.saturating_add(compressed_size);
556        let archive_ratio_ceiling = compressed_total.saturating_mul(MAX_ARCHIVE_COMPRESSION_RATIO);
557        if extracted_total > archive_ratio_ceiling {
558            return Err(anyhow!(
559                "zip archive compression ratio too high (extracted={} compressed={})",
560                extracted_total,
561                compressed_total
562            ));
563        }
564        if let Some(parent) = out_path.parent() {
565            fs::create_dir_all(parent)
566                .with_context(|| format!("create dir {}", parent.display()))?;
567        }
568        let mut outfile =
569            File::create(&out_path).with_context(|| format!("create {}", out_path.display()))?;
570        let mut limited = entry.take(MAX_FILE_BYTES + 1);
571        let written = copy(&mut limited, &mut outfile)?;
572        if written > MAX_FILE_BYTES {
573            return Err(anyhow!(
574                "zip entry exceeded max copied bytes: {}",
575                entry_name
576            ));
577        }
578    }
579    Ok(())
580}
581
582fn validate_zip_entry_name(name: &str) -> anyhow::Result<()> {
583    if name.starts_with('/') || name.starts_with('\\') || name.contains('\0') {
584        return Err(anyhow!("invalid zip entry path: {}", name));
585    }
586    let path = Path::new(name);
587    let mut depth = 0usize;
588    for component in path.components() {
589        match component {
590            Component::Normal(_) => {
591                depth = depth.saturating_add(1);
592                if depth > MAX_PATH_DEPTH {
593                    return Err(anyhow!("zip entry path too deep: {}", name));
594                }
595            }
596            Component::CurDir => {}
597            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
598                return Err(anyhow!("unsafe zip entry path: {}", name));
599            }
600        }
601    }
602    Ok(())
603}
604
605fn zip_directory(src_dir: &Path, output_zip: &Path) -> anyhow::Result<()> {
606    let file =
607        File::create(output_zip).with_context(|| format!("create {}", output_zip.display()))?;
608    let mut writer = ZipWriter::new(file);
609    let opts = SimpleFileOptions::default()
610        .compression_method(CompressionMethod::Deflated)
611        .unix_permissions(0o644);
612    let mut stack = vec![src_dir.to_path_buf()];
613    while let Some(current) = stack.pop() {
614        let mut entries = fs::read_dir(&current)?
615            .filter_map(|entry| entry.ok())
616            .collect::<Vec<_>>();
617        entries.sort_by_key(|entry| entry.path());
618        for entry in entries {
619            let path = entry.path();
620            let rel = path
621                .strip_prefix(src_dir)
622                .context("strip source prefix")?
623                .to_string_lossy()
624                .replace('\\', "/");
625            if path.is_dir() {
626                if !rel.is_empty() {
627                    writer.add_directory(format!("{}/", rel), opts)?;
628                }
629                stack.push(path);
630                continue;
631            }
632            let mut input = File::open(&path)?;
633            writer.start_file(rel, opts)?;
634            copy(&mut input, &mut writer)?;
635        }
636    }
637    writer.finish()?;
638    Ok(())
639}
640
641fn sha256_file(path: &Path) -> anyhow::Result<String> {
642    let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
643    let mut hasher = Sha256::new();
644    let mut buffer = vec![0u8; 64 * 1024];
645    loop {
646        let n = file.read(&mut buffer)?;
647        if n == 0 {
648            break;
649        }
650        hasher.update(&buffer[..n]);
651    }
652    Ok(format!("{:x}", hasher.finalize()))
653}
654
655fn now_ms() -> u64 {
656    std::time::SystemTime::now()
657        .duration_since(std::time::UNIX_EPOCH)
658        .map(|d| d.as_millis() as u64)
659        .unwrap_or(0)
660}
661
662fn scan_embedded_secrets(root: &Path) -> anyhow::Result<Vec<String>> {
663    let mut findings = Vec::new();
664    for path in walk_files(root)? {
665        let rel = path
666            .strip_prefix(root)
667            .unwrap_or(path.as_path())
668            .to_string_lossy()
669            .to_string();
670        let rel_lower = rel.to_ascii_lowercase();
671        if rel_lower.contains(".example") || rel_lower.ends_with("secrets.example.env") {
672            continue;
673        }
674        let meta = std::fs::metadata(&path)?;
675        if meta.len() == 0 || meta.len() > SECRET_SCAN_MAX_FILE_BYTES {
676            continue;
677        }
678        let bytes = std::fs::read(&path)?;
679        if bytes.contains(&0) {
680            continue;
681        }
682        let content = String::from_utf8_lossy(&bytes);
683        for needle in SECRET_SCAN_PATTERNS {
684            if content.contains(needle) {
685                findings.push(format!("{rel}:{needle}"));
686                break;
687            }
688        }
689    }
690    Ok(findings)
691}
692
693fn walk_files(root: &Path) -> anyhow::Result<Vec<PathBuf>> {
694    let mut out = Vec::new();
695    let mut stack = vec![root.to_path_buf()];
696    while let Some(dir) = stack.pop() {
697        for entry in std::fs::read_dir(&dir)? {
698            let entry = entry?;
699            let path = entry.path();
700            let ty = entry.file_type()?;
701            if ty.is_dir() {
702                stack.push(path);
703            } else if ty.is_file() {
704                out.push(path);
705            }
706        }
707    }
708    Ok(out)
709}
710
711fn inspect_trust(manifest: &Value, install_path: &str) -> Value {
712    let signature_path = PathBuf::from(install_path).join("tandempack.sig");
713    let signature = if signature_path.exists() {
714        "present_unverified"
715    } else {
716        "unsigned"
717    };
718    let publisher_verification = manifest
719        .pointer("/publisher/verification")
720        .or_else(|| manifest.pointer("/publisher/verification_tier"))
721        .or_else(|| manifest.pointer("/marketplace/publisher_verification"))
722        .and_then(|v| v.as_str())
723        .unwrap_or("unknown");
724    let publisher_verification_normalized =
725        match publisher_verification.to_ascii_lowercase().as_str() {
726            "official" => "official",
727            "verified" => "verified",
728            _ => "unverified",
729        };
730    let verification_badge = match publisher_verification_normalized {
731        "official" => "official",
732        "verified" => "verified",
733        _ => "unverified",
734    };
735    serde_json::json!({
736        "publisher_verification": publisher_verification_normalized,
737        "verification_badge": verification_badge,
738        "signature": signature,
739    })
740}
741
742fn inspect_risk(manifest: &Value, installed: &PackInstallRecord) -> Value {
743    let required_capabilities_count = manifest
744        .pointer("/capabilities/required")
745        .and_then(|v| v.as_array())
746        .map(|rows| rows.len())
747        .unwrap_or(0);
748    let optional_capabilities_count = manifest
749        .pointer("/capabilities/optional")
750        .and_then(|v| v.as_array())
751        .map(|rows| rows.len())
752        .unwrap_or(0);
753    let routines_declared = manifest
754        .pointer("/contents/routines")
755        .and_then(|v| v.as_array())
756        .map(|rows| !rows.is_empty())
757        .unwrap_or(false);
758    let workflows_declared = manifest
759        .pointer("/contents/workflows")
760        .and_then(|v| v.as_array())
761        .map(|rows| !rows.is_empty())
762        .unwrap_or(false);
763    let workflow_hooks_declared = manifest
764        .pointer("/contents/workflow_hooks")
765        .and_then(|v| v.as_array())
766        .map(|rows| !rows.is_empty())
767        .unwrap_or(false);
768    let non_portable_dependencies = manifest
769        .pointer("/capabilities/provider_specific")
770        .map(|v| match v {
771            Value::Array(rows) => !rows.is_empty(),
772            Value::Object(map) => !map.is_empty(),
773            Value::Bool(flag) => *flag,
774            _ => false,
775        })
776        .unwrap_or(false);
777    serde_json::json!({
778        "routines_enabled": installed.routines_enabled,
779        "routines_declared": routines_declared,
780        "workflows_declared": workflows_declared,
781        "workflow_hooks_declared": workflow_hooks_declared,
782        "required_capabilities_count": required_capabilities_count,
783        "optional_capabilities_count": optional_capabilities_count,
784        "non_portable_dependencies": non_portable_dependencies,
785    })
786}
787
788fn inspect_permission_sheet(manifest: &Value, risk: &Value) -> Value {
789    let required_capabilities = manifest
790        .pointer("/capabilities/required")
791        .and_then(|v| v.as_array())
792        .cloned()
793        .unwrap_or_default();
794    let optional_capabilities = manifest
795        .pointer("/capabilities/optional")
796        .and_then(|v| v.as_array())
797        .cloned()
798        .unwrap_or_default();
799    let provider_specific = manifest
800        .pointer("/capabilities/provider_specific")
801        .map(|v| match v {
802            Value::Array(rows) => rows.clone(),
803            _ => Vec::new(),
804        })
805        .unwrap_or_default();
806    let routines = manifest
807        .pointer("/contents/routines")
808        .and_then(|v| v.as_array())
809        .cloned()
810        .unwrap_or_default();
811    let workflows = manifest
812        .pointer("/contents/workflows")
813        .and_then(|v| v.as_array())
814        .cloned()
815        .unwrap_or_default();
816    let workflow_hooks = manifest
817        .pointer("/contents/workflow_hooks")
818        .and_then(|v| v.as_array())
819        .cloned()
820        .unwrap_or_default();
821    serde_json::json!({
822        "required_capabilities": required_capabilities,
823        "optional_capabilities": optional_capabilities,
824        "provider_specific_dependencies": provider_specific,
825        "routines_declared": routines,
826        "workflows_declared": workflows,
827        "workflow_hooks_declared": workflow_hooks,
828        "routines_enabled": risk.get("routines_enabled").cloned().unwrap_or(Value::Bool(false)),
829        "risk_level": if !provider_specific.is_empty() { "elevated" } else { "standard" },
830    })
831}
832
833fn inspect_workflow_extensions(manifest: &Value) -> Value {
834    let workflow_entrypoints = manifest
835        .pointer("/entrypoints/workflows")
836        .and_then(|v| v.as_array())
837        .cloned()
838        .unwrap_or_default();
839    let workflows = manifest
840        .pointer("/contents/workflows")
841        .and_then(|v| v.as_array())
842        .cloned()
843        .unwrap_or_default();
844    let workflow_hooks = manifest
845        .pointer("/contents/workflow_hooks")
846        .and_then(|v| v.as_array())
847        .cloned()
848        .unwrap_or_default();
849    serde_json::json!({
850        "workflow_entrypoints": workflow_entrypoints,
851        "workflows": workflows,
852        "workflow_hooks": workflow_hooks,
853        "workflow_count": workflows.len(),
854        "workflow_hook_count": workflow_hooks.len(),
855    })
856}
857
858#[allow(dead_code)]
859pub fn map_missing_capability_error(
860    workflow_id: &str,
861    missing_capabilities: &[String],
862    available_capability_bindings: &HashMap<String, Vec<String>>,
863) -> Value {
864    let suggestions = missing_capabilities
865        .iter()
866        .map(|cap| {
867            let bindings = available_capability_bindings
868                .get(cap)
869                .cloned()
870                .unwrap_or_default();
871            serde_json::json!({
872                "capability_id": cap,
873                "available_bindings": bindings,
874            })
875        })
876        .collect::<Vec<_>>();
877    serde_json::json!({
878        "code": "missing_capability",
879        "workflow_id": workflow_id,
880        "missing_capabilities": missing_capabilities,
881        "suggestions": suggestions,
882    })
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888    use std::io::Write;
889
890    fn write_zip(path: &Path, entries: &[(&str, &str)]) {
891        let file = File::create(path).expect("create zip");
892        let mut zip = ZipWriter::new(file);
893        let opts = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
894        for (name, body) in entries {
895            zip.start_file(*name, opts).expect("start");
896            zip.write_all(body.as_bytes()).expect("write");
897        }
898        zip.finish().expect("finish");
899    }
900
901    #[test]
902    fn detects_root_marker_only() {
903        let root = std::env::temp_dir().join(format!("tandem-pack-test-{}", Uuid::new_v4()));
904        std::fs::create_dir_all(&root).expect("mkdir");
905        let ok = root.join("ok.zip");
906        write_zip(
907            &ok,
908            &[
909                ("tandempack.yaml", "name: x\nversion: 1.0.0\ntype: skill\n"),
910                ("README.md", "# x"),
911            ],
912        );
913        let nested = root.join("nested.zip");
914        write_zip(
915            &nested,
916            &[(
917                "sub/tandempack.yaml",
918                "name: x\nversion: 1.0.0\ntype: skill\n",
919            )],
920        );
921        assert!(contains_root_marker(&ok).expect("detect"));
922        assert!(!contains_root_marker(&nested).expect("detect nested"));
923        let _ = std::fs::remove_dir_all(root);
924    }
925
926    #[test]
927    fn safe_extract_blocks_traversal() {
928        let root = std::env::temp_dir().join(format!("tandem-pack-test-{}", Uuid::new_v4()));
929        std::fs::create_dir_all(&root).expect("mkdir");
930        let bad = root.join("bad.zip");
931        write_zip(&bad, &[("../escape.txt", "x")]);
932        let out = root.join("out");
933        std::fs::create_dir_all(&out).expect("mkdir out");
934        let err = safe_extract_zip(&bad, &out).expect_err("should fail");
935        assert!(err.to_string().contains("unsafe zip entry path"));
936        let _ = std::fs::remove_dir_all(root);
937    }
938
939    #[test]
940    fn safe_extract_blocks_extreme_compression_ratio() {
941        let root = std::env::temp_dir().join(format!("tandem-pack-test-{}", Uuid::new_v4()));
942        std::fs::create_dir_all(&root).expect("mkdir");
943        let bad = root.join("bomb.zip");
944        let repeated = "A".repeat(300_000);
945        write_zip(&bad, &[("payload.txt", repeated.as_str())]);
946        let out = root.join("out");
947        std::fs::create_dir_all(&out).expect("mkdir out");
948        let err = safe_extract_zip(&bad, &out).expect_err("should fail");
949        assert!(err.to_string().contains("compression ratio"));
950        let _ = std::fs::remove_dir_all(root);
951    }
952
953    #[tokio::test]
954    async fn inspect_reports_signature_and_risk_summary() {
955        let root = std::env::temp_dir().join(format!("tandem-pack-test-{}", Uuid::new_v4()));
956        std::fs::create_dir_all(&root).expect("mkdir");
957        let pack_zip = root.join("inspect.zip");
958        write_zip(
959            &pack_zip,
960            &[
961                (
962                    "tandempack.yaml",
963                    "name: inspect-pack\nversion: 1.0.0\ntype: workflow\npack_id: inspect-pack\npublisher:\n  verification: verified\nentrypoints:\n  workflows:\n    - build_feature\ncapabilities:\n  required:\n    - github.create_pull_request\n  optional:\n    - slack.post_message\ncontents:\n  routines:\n    - routines/nightly.yaml\n  workflows:\n    - id: build_feature\n      path: workflows/build_feature.yaml\n  workflow_hooks:\n    - id: build_feature.task_completed.notify\n      path: hooks/notify.yaml\n",
964                ),
965                ("tandempack.sig", "fake-signature"),
966                ("routines/nightly.yaml", "id: nightly\n"),
967            ],
968        );
969        let manager = PackManager::new(root.join("packs"));
970        let installed = manager
971            .install(PackInstallRequest {
972                path: Some(pack_zip.to_string_lossy().to_string()),
973                url: None,
974                source: Value::Null,
975            })
976            .await
977            .expect("install");
978        let inspection = manager.inspect(&installed.pack_id).await.expect("inspect");
979        assert_eq!(
980            inspection.trust.get("signature").and_then(|v| v.as_str()),
981            Some("present_unverified")
982        );
983        assert_eq!(
984            inspection
985                .trust
986                .get("publisher_verification")
987                .and_then(|v| v.as_str()),
988            Some("verified")
989        );
990        assert_eq!(
991            inspection
992                .trust
993                .get("verification_badge")
994                .and_then(|v| v.as_str()),
995            Some("verified")
996        );
997        assert_eq!(
998            inspection
999                .risk
1000                .get("required_capabilities_count")
1001                .and_then(|v| v.as_u64()),
1002            Some(1)
1003        );
1004        assert_eq!(
1005            inspection
1006                .risk
1007                .get("routines_declared")
1008                .and_then(|v| v.as_bool()),
1009            Some(true)
1010        );
1011        assert_eq!(
1012            inspection
1013                .permission_sheet
1014                .get("required_capabilities")
1015                .and_then(|v| v.as_array())
1016                .map(|v| v.len()),
1017            Some(1)
1018        );
1019        assert_eq!(
1020            inspection
1021                .permission_sheet
1022                .get("routines_declared")
1023                .and_then(|v| v.as_array())
1024                .map(|v| v.len()),
1025            Some(1)
1026        );
1027        assert_eq!(
1028            inspection
1029                .risk
1030                .get("workflows_declared")
1031                .and_then(|v| v.as_bool()),
1032            Some(true)
1033        );
1034        assert_eq!(
1035            inspection
1036                .risk
1037                .get("workflow_hooks_declared")
1038                .and_then(|v| v.as_bool()),
1039            Some(true)
1040        );
1041        assert_eq!(
1042            inspection
1043                .permission_sheet
1044                .get("workflows_declared")
1045                .and_then(|v| v.as_array())
1046                .map(|v| v.len()),
1047            Some(1)
1048        );
1049        assert_eq!(
1050            inspection
1051                .permission_sheet
1052                .get("workflow_hooks_declared")
1053                .and_then(|v| v.as_array())
1054                .map(|v| v.len()),
1055            Some(1)
1056        );
1057        assert_eq!(
1058            inspection
1059                .workflow_extensions
1060                .get("workflow_entrypoints")
1061                .and_then(|v| v.as_array())
1062                .map(|v| v.len()),
1063            Some(1)
1064        );
1065        assert_eq!(
1066            inspection
1067                .workflow_extensions
1068                .get("workflow_count")
1069                .and_then(|v| v.as_u64()),
1070            Some(1)
1071        );
1072        assert_eq!(
1073            inspection
1074                .workflow_extensions
1075                .get("workflow_hook_count")
1076                .and_then(|v| v.as_u64()),
1077            Some(1)
1078        );
1079        let _ = std::fs::remove_dir_all(root);
1080    }
1081
1082    #[tokio::test]
1083    async fn inspect_defaults_verification_badge_to_unverified() {
1084        let root = std::env::temp_dir().join(format!("tandem-pack-test-{}", Uuid::new_v4()));
1085        std::fs::create_dir_all(&root).expect("mkdir");
1086        let pack_zip = root.join("inspect-unverified.zip");
1087        write_zip(
1088            &pack_zip,
1089            &[(
1090                "tandempack.yaml",
1091                "name: inspect-pack-2\nversion: 1.0.0\ntype: workflow\npack_id: inspect-pack-2\n",
1092            )],
1093        );
1094        let manager = PackManager::new(root.join("packs"));
1095        let installed = manager
1096            .install(PackInstallRequest {
1097                path: Some(pack_zip.to_string_lossy().to_string()),
1098                url: None,
1099                source: Value::Null,
1100            })
1101            .await
1102            .expect("install");
1103        let inspection = manager.inspect(&installed.pack_id).await.expect("inspect");
1104        assert_eq!(
1105            inspection
1106                .trust
1107                .get("verification_badge")
1108                .and_then(|v| v.as_str()),
1109            Some("unverified")
1110        );
1111        assert_eq!(
1112            inspection.trust.get("signature").and_then(|v| v.as_str()),
1113            Some("unsigned")
1114        );
1115        let _ = std::fs::remove_dir_all(root);
1116    }
1117
1118    #[test]
1119    fn scan_embedded_secrets_finds_real_and_ignores_examples() {
1120        let root = std::env::temp_dir().join(format!("tandem-pack-test-{}", Uuid::new_v4()));
1121        std::fs::create_dir_all(&root).expect("mkdir");
1122        let real = root.join("resources").join("token.txt");
1123        std::fs::create_dir_all(real.parent().expect("parent")).expect("mkdir resources");
1124        std::fs::write(&real, "token=ghp_example_not_real_but_pattern").expect("write real");
1125        let example = root.join("secrets.example.env");
1126        std::fs::write(&example, "API_KEY=sk-live-example").expect("write example");
1127        let findings = scan_embedded_secrets(&root).expect("scan");
1128        assert_eq!(findings.len(), 1);
1129        assert!(findings[0].contains("resources/token.txt"));
1130        let _ = std::fs::remove_dir_all(root);
1131    }
1132}