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