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(¤t)?
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}