Skip to main content

tandem_server/
pack_manager.rs

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