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