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