1use std::fs::{self, File};
4use std::io::{Read, Write};
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime};
7
8use anyhow::{anyhow, bail, Context, Result};
9use reqwest::blocking::Client;
10use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, USER_AGENT};
11use serde::{Deserialize, Serialize};
12use sha2::{Digest, Sha256};
13use tempfile::NamedTempFile;
14use url::Url;
15
16pub const DEFAULT_TTL_SECS: u64 = 86_400;
18pub const DEFAULT_MAX_BYTES: u64 = 20 * 1024 * 1024;
20pub const DEFAULT_TIMEOUT_SECS: u64 = 20;
22
23const USER_AGENT_VALUE: &str = concat!("jan-cli/", env!("CARGO_PKG_VERSION"));
24
25#[derive(Debug, Clone)]
26pub struct FetchOpts {
27 pub ttl_secs: u64,
28 pub max_bytes: u64,
29 pub timeout_secs: u64,
30 pub allow_http: bool,
31}
32
33impl FetchOpts {
34 pub fn new() -> Self {
35 Self {
36 ttl_secs: DEFAULT_TTL_SECS,
37 max_bytes: DEFAULT_MAX_BYTES,
38 timeout_secs: DEFAULT_TIMEOUT_SECS,
39 allow_http: allow_http_from_env(),
40 }
41 }
42
43 pub fn with_ttl(mut self, ttl_secs: u64) -> Self {
44 self.ttl_secs = ttl_secs;
45 self
46 }
47
48 pub fn with_allow_http(mut self, allow: bool) -> Self {
49 self.allow_http = allow;
50 self
51 }
52}
53
54impl Default for FetchOpts {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60fn allow_http_from_env() -> bool {
61 matches!(
62 std::env::var("JAN_ALLOW_HTTP").as_deref(),
63 Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
64 )
65}
66
67#[derive(Serialize, Deserialize)]
68struct CacheMetadata {
69 downloaded_at: SystemTime,
70 content_hash: String,
71 etag: Option<String>,
72 last_modified: Option<String>,
73 url: String,
74}
75
76pub fn cache_root() -> Result<PathBuf> {
78 if let Ok(p) = std::env::var("JAN_CACHE_DIR") {
79 let p = p.trim();
80 if !p.is_empty() {
81 let root = PathBuf::from(p);
82 fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
83 return Ok(root);
84 }
85 }
86 let base = dirs::cache_dir()
87 .or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
88 .ok_or_else(|| anyhow!("could not resolve cache directory"))?;
89 let root = base.join("jan");
90 fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
91 #[cfg(unix)]
92 {
93 use std::os::unix::fs::PermissionsExt;
94 let mut perms = fs::metadata(&root)?.permissions();
95 perms.set_mode(0o700);
96 fs::set_permissions(&root, perms)?;
97 }
98 Ok(root)
99}
100
101pub fn objects_dir() -> Result<PathBuf> {
102 let d = cache_root()?.join("objects");
103 fs::create_dir_all(&d).with_context(|| format!("create {}", d.display()))?;
104 Ok(d)
105}
106
107pub fn trees_dir() -> Result<PathBuf> {
108 let d = cache_root()?.join("trees");
109 fs::create_dir_all(&d).with_context(|| format!("create {}", d.display()))?;
110 Ok(d)
111}
112
113pub fn normalize_sha256(s: &str) -> Result<String> {
114 let s = s.trim().to_ascii_lowercase();
115 if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
116 bail!("sha256 must be a 64-character hex string");
117 }
118 Ok(s)
119}
120
121fn validate_url(url: &str, allow_http: bool) -> Result<Url> {
122 let parsed = Url::parse(url).with_context(|| format!("invalid URL: {url}"))?;
123 match parsed.scheme() {
124 "https" => Ok(parsed),
125 "http" if allow_http => Ok(parsed),
126 "http" => bail!("refusing non-HTTPS URL (set JAN_ALLOW_HTTP=1 or pass --allow-http)"),
127 other => bail!("unsupported URL scheme `{other}` (only https is allowed by default)"),
128 }
129}
130
131fn build_client(timeout_secs: u64) -> Result<Client> {
132 Client::builder()
133 .timeout(Duration::from_secs(timeout_secs))
134 .redirect(reqwest::redirect::Policy::limited(5))
135 .user_agent(USER_AGENT_VALUE)
136 .build()
137 .context("build HTTP client")
138}
139
140fn hex_encode(bytes: &[u8]) -> String {
141 bytes.iter().map(|b| format!("{b:02x}")).collect()
142}
143
144pub fn sha256_hex(data: &[u8]) -> String {
145 let mut hasher = Sha256::new();
146 hasher.update(data);
147 hex_encode(&hasher.finalize())
148}
149
150pub fn sha256_file(path: &Path) -> Result<String> {
151 let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
152 let mut hasher = Sha256::new();
153 let mut buf = [0u8; 32 * 1024];
154 loop {
155 let n = file.read(&mut buf)?;
156 if n == 0 {
157 break;
158 }
159 hasher.update(&buf[..n]);
160 }
161 Ok(hex_encode(&hasher.finalize()))
162}
163
164pub fn verify_file_sha256(path: &Path, expected: &str) -> Result<()> {
166 let expected = normalize_sha256(expected)?;
167 let got = sha256_file(path)?;
168 if got != expected {
169 bail!(
170 "SHA256 mismatch for {}: expected {expected}, got {got}",
171 path.display()
172 );
173 }
174 Ok(())
175}
176
177fn verify_file_hash(path: &Path, expected: &str) -> Result<bool> {
178 Ok(sha256_file(path)? == expected)
179}
180
181fn write_metadata(path: &Path, meta: &CacheMetadata) {
182 if let Ok(s) = serde_json::to_string(meta) {
183 let _ = fs::write(path, s);
184 }
185}
186
187fn persist_temp_to_cache(temp_path: &Path, cache_path: &Path) -> Result<()> {
188 if let Some(parent) = cache_path.parent() {
189 fs::create_dir_all(parent)?;
190 }
191 match fs::rename(temp_path, cache_path) {
192 Ok(()) => Ok(()),
193 Err(_) => {
194 fs::copy(temp_path, cache_path)?;
195 let _ = fs::remove_file(temp_path);
196 Ok(())
197 }
198 }
199}
200
201#[cfg(unix)]
202fn make_executable(path: &Path) -> Result<()> {
203 use std::os::unix::fs::PermissionsExt;
204 let mut perms = fs::metadata(path)?.permissions();
205 perms.set_mode(perms.mode() | 0o100);
206 fs::set_permissions(path, perms)?;
207 Ok(())
208}
209
210#[cfg(not(unix))]
211fn make_executable(_path: &Path) -> Result<()> {
212 Ok(())
213}
214
215enum FetchResult {
216 NotModified,
217 Downloaded {
218 temp_path: PathBuf,
219 etag: Option<String>,
220 last_modified: Option<String>,
221 sha256: String,
222 },
223}
224
225fn fetch_conditional(
226 client: &Client,
227 url: &str,
228 metadata: Option<&CacheMetadata>,
229 max_bytes: u64,
230) -> Result<FetchResult> {
231 let mut req = client.get(url);
232 if let Some(m) = metadata {
233 if let Some(ref etag) = m.etag {
234 req = req.header(IF_NONE_MATCH, etag.clone());
235 }
236 if let Some(ref lm) = m.last_modified {
237 req = req.header(IF_MODIFIED_SINCE, lm.clone());
238 }
239 }
240 let mut resp = req.header(USER_AGENT, USER_AGENT_VALUE).send()?;
241 if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
242 return Ok(FetchResult::NotModified);
243 }
244 if !resp.status().is_success() {
245 bail!("HTTP error: {}", resp.status());
246 }
247 if let Some(len) = resp.content_length() {
248 if len > max_bytes {
249 bail!("content too large ({len} bytes > max {max_bytes})");
250 }
251 }
252
253 let mut hasher = Sha256::new();
254 let mut tmp = NamedTempFile::new()?;
255 let mut total: u64 = 0;
256 let mut buf = [0u8; 16 * 1024];
257 loop {
258 let n = resp.read(&mut buf)?;
259 if n == 0 {
260 break;
261 }
262 total += n as u64;
263 if total > max_bytes {
264 bail!("exceeded max bytes {max_bytes}");
265 }
266 hasher.update(&buf[..n]);
267 tmp.write_all(&buf[..n])?;
268 }
269 let sha256 = hex_encode(&hasher.finalize());
270 let etag = resp
271 .headers()
272 .get(ETAG)
273 .and_then(|v| v.to_str().ok())
274 .map(|s| s.to_string());
275 let last_modified = resp
276 .headers()
277 .get(LAST_MODIFIED)
278 .and_then(|v| v.to_str().ok())
279 .map(|s| s.to_string());
280 let (_file, temp_path) = tmp.keep()?;
281 Ok(FetchResult::Downloaded {
282 temp_path,
283 etag,
284 last_modified,
285 sha256,
286 })
287}
288
289pub fn fetch_verified(
293 url: &str,
294 expected_sha256: &str,
295 opts: &FetchOpts,
296 executable: bool,
297) -> Result<PathBuf> {
298 let expected = normalize_sha256(expected_sha256)?;
299 validate_url(url, opts.allow_http)?;
300 let client = build_client(opts.timeout_secs)?;
301 let cache_dir = objects_dir()?;
302 let cache_file = cache_dir.join(&expected);
303 let metadata_path = cache_dir.join(format!("{expected}.meta"));
304
305 let mut metadata: Option<CacheMetadata> = None;
306 if let Ok(s) = fs::read_to_string(&metadata_path) {
307 if let Ok(m) = serde_json::from_str::<CacheMetadata>(&s) {
308 metadata = Some(m);
309 }
310 }
311
312 let mut cache_ok = false;
313 if cache_file.exists() {
314 match verify_file_hash(&cache_file, &expected) {
315 Ok(true) => cache_ok = true,
316 Ok(false) => {
317 let _ = fs::remove_file(&cache_file);
318 }
319 Err(_) => {
320 let _ = fs::remove_file(&cache_file);
321 }
322 }
323 }
324
325 let mut cache_fresh = false;
326 if cache_ok {
327 if let Some(ref m) = metadata {
328 if let Ok(elapsed) = m.downloaded_at.elapsed() {
329 if elapsed < Duration::from_secs(opts.ttl_secs) {
330 cache_fresh = true;
331 }
332 }
333 }
334 }
335
336 if !cache_fresh {
337 match fetch_conditional(&client, url, metadata.as_ref(), opts.max_bytes) {
338 Ok(FetchResult::NotModified) => {
339 if let Some(mut m) = metadata.take() {
340 m.downloaded_at = SystemTime::now();
341 write_metadata(&metadata_path, &m);
342 }
343 cache_ok = true;
344 }
345 Ok(FetchResult::Downloaded {
346 temp_path,
347 etag,
348 last_modified,
349 sha256,
350 }) => {
351 if sha256 == expected {
352 persist_temp_to_cache(&temp_path, &cache_file)?;
353 if executable {
354 make_executable(&cache_file)?;
355 }
356 let new_meta = CacheMetadata {
357 downloaded_at: SystemTime::now(),
358 content_hash: expected.clone(),
359 etag,
360 last_modified,
361 url: url.to_string(),
362 };
363 write_metadata(&metadata_path, &new_meta);
364 cache_ok = true;
365 } else {
366 let _ = fs::remove_file(&temp_path);
367 if !cache_ok {
368 bail!("SHA256 mismatch for {url}: expected {expected}, got {sha256}");
369 }
370 }
372 }
373 Err(e) => {
374 if !cache_ok {
375 return Err(e).with_context(|| format!("fetch {url}"));
376 }
377 }
378 }
379 }
380
381 if !cache_ok {
382 match fetch_conditional(&client, url, None, opts.max_bytes)? {
384 FetchResult::NotModified => unreachable!("no validators"),
385 FetchResult::Downloaded {
386 temp_path,
387 etag,
388 last_modified,
389 sha256,
390 } => {
391 if sha256 != expected {
392 let _ = fs::remove_file(&temp_path);
393 bail!("SHA256 mismatch for {url}: expected {expected}, got {sha256}");
394 }
395 persist_temp_to_cache(&temp_path, &cache_file)?;
396 if executable {
397 make_executable(&cache_file)?;
398 }
399 let new_meta = CacheMetadata {
400 downloaded_at: SystemTime::now(),
401 content_hash: expected.clone(),
402 etag,
403 last_modified,
404 url: url.to_string(),
405 };
406 write_metadata(&metadata_path, &new_meta);
407 }
408 }
409 }
410
411 if executable {
412 make_executable(&cache_file)?;
413 }
414 Ok(cache_file)
415}
416
417pub fn fetch_verified_text(url: &str, expected_sha256: &str, opts: &FetchOpts) -> Result<String> {
419 let path = fetch_verified(url, expected_sha256, opts, false)?;
420 fs::read_to_string(&path).with_context(|| format!("read cached {}", path.display()))
421}
422
423pub fn looks_like_remote_url(s: &str) -> bool {
425 let s = s.trim();
426 s.starts_with("https://") || s.starts_with("http://")
427}
428
429const MAX_MANIFEST_SIZE: u64 = 1024 * 1024;
434const MAX_MEMBER_SIZE: u64 = 128 * 1024 * 1024;
435const MAX_TOTAL_SIZE: u64 = 512 * 1024 * 1024;
436
437#[derive(Debug, Deserialize)]
438struct BundleManifest {
439 root_yaml: String,
440 files: serde_json::Map<String, serde_json::Value>,
441}
442
443fn validate_member_name(name: &str) -> Result<()> {
444 if name.is_empty() || name.contains('\\') {
445 bail!("unsafe ZIP member path: {name:?}");
446 }
447 let path = Path::new(name);
448 if path.is_absolute() {
449 bail!("unsafe ZIP member path: {name:?}");
450 }
451 for part in path.components() {
452 match part {
453 std::path::Component::Normal(s) => {
454 let s = s.to_string_lossy();
455 if s.is_empty() || s == "." || s == ".." {
456 bail!("unsafe ZIP member path: {name:?}");
457 }
458 }
459 std::path::Component::CurDir | std::path::Component::ParentDir => {
460 bail!("unsafe ZIP member path: {name:?}");
461 }
462 _ => bail!("unsafe ZIP member path: {name:?}"),
463 }
464 }
465 let canonical = path
466 .components()
467 .map(|c| c.as_os_str().to_string_lossy())
468 .collect::<Vec<_>>()
469 .join("/");
470 if canonical != name.trim_end_matches('/') {
471 bail!("non-canonical ZIP member path: {name:?}");
472 }
473 Ok(())
474}
475
476pub fn fetch_and_install_bundle(
479 url: &str,
480 zip_sha256: &str,
481 opts: &FetchOpts,
482) -> Result<(PathBuf, String)> {
483 let expected = normalize_sha256(zip_sha256)?;
484 let mut zip_opts = opts.clone();
486 zip_opts.max_bytes = MAX_TOTAL_SIZE;
487 let zip_path = fetch_verified(url, &expected, &zip_opts, false)?;
488
489 let tree_dir = trees_dir()?.join(&expected);
490 let marker = tree_dir.join(".jan-tree-ready");
491 if tree_dir.is_dir() && marker.is_file() {
492 let root = fs::read_to_string(&marker)?.trim().to_string();
493 if !root.is_empty() && tree_dir.join(&root).is_file() {
494 return Ok((tree_dir, root));
495 }
496 }
497
498 if tree_dir.exists() {
500 fs::remove_dir_all(&tree_dir)
501 .with_context(|| format!("remove stale tree {}", tree_dir.display()))?;
502 }
503
504 let parent = tree_dir
505 .parent()
506 .ok_or_else(|| anyhow!("trees dir has no parent"))?
507 .to_path_buf();
508 let staging = parent.join(format!(".staging-{expected}"));
509 if staging.exists() {
510 fs::remove_dir_all(&staging)?;
511 }
512 fs::create_dir_all(&staging)?;
513
514 let root_yaml = extract_verified_bundle(&zip_path, &staging)?;
515 if tree_dir.exists() {
517 fs::remove_dir_all(&tree_dir)?;
518 }
519 fs::rename(&staging, &tree_dir)
520 .with_context(|| format!("move staging to {}", tree_dir.display()))?;
521 fs::write(&marker, format!("{root_yaml}\n"))?;
522 Ok((tree_dir, root_yaml))
523}
524
525fn extract_verified_bundle(zip_path: &Path, dest: &Path) -> Result<String> {
526 let file = File::open(zip_path).with_context(|| format!("open {}", zip_path.display()))?;
527 let mut archive = zip::ZipArchive::new(file).context("open zip archive")?;
528
529 let mut by_name: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
530 let mut total_size: u64 = 0;
531 for i in 0..archive.len() {
532 let entry = archive.by_index(i)?;
533 let name = entry.name().to_string();
534 let is_dir = entry.is_dir();
535 let name_for_check = if is_dir {
536 name.trim_end_matches('/').to_string()
537 } else {
538 name.clone()
539 };
540 if !name_for_check.is_empty() {
541 validate_member_name(&name_for_check)?;
542 }
543 let key = if is_dir {
544 format!("{}/", name_for_check)
545 } else {
546 name_for_check.clone()
547 };
548 if by_name.contains_key(&key) || by_name.contains_key(&name_for_check) {
549 bail!("duplicate ZIP member: {name_for_check}");
550 }
551 if entry.size() > MAX_MEMBER_SIZE {
552 bail!("ZIP member too large: {name_for_check}");
553 }
554 total_size = total_size.saturating_add(entry.size());
555 if total_size > MAX_TOTAL_SIZE {
556 bail!("bundle exceeds extraction size limit");
557 }
558 by_name.insert(name_for_check, i);
559 }
560
561 let manifest_idx = *by_name
563 .get("manifest.json")
564 .ok_or_else(|| anyhow!("bundle is missing root manifest.json"))?;
565 let mut manifest_entry = archive.by_index(manifest_idx)?;
566 if manifest_entry.size() > MAX_MANIFEST_SIZE {
567 bail!("manifest.json is too large");
568 }
569 let mut manifest_bytes = Vec::new();
570 manifest_entry
571 .read_to_end(&mut manifest_bytes)
572 .context("read manifest.json")?;
573 drop(manifest_entry);
574
575 let manifest: BundleManifest =
576 serde_json::from_slice(&manifest_bytes).context("invalid manifest.json")?;
577 if manifest.files.is_empty() {
578 bail!("manifest.json must contain a non-empty files object");
579 }
580 if !manifest.files.contains_key(&manifest.root_yaml) {
581 bail!("manifest root_yaml must identify a listed file");
582 }
583
584 let mut listed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
585 for (name, expected) in &manifest.files {
586 validate_member_name(name)?;
587 let obj = expected
588 .as_object()
589 .ok_or_else(|| anyhow!("invalid manifest file entry: {name:?}"))?;
590 let digest = obj
591 .get("sha256")
592 .and_then(|v| v.as_str())
593 .ok_or_else(|| anyhow!("invalid manifest hash for {name}"))?;
594 normalize_sha256(digest)?;
595 let size = obj
596 .get("size")
597 .and_then(|v| v.as_u64())
598 .ok_or_else(|| anyhow!("invalid manifest size for {name}"))?;
599 let idx = by_name
600 .get(name.as_str())
601 .ok_or_else(|| anyhow!("manifest file missing from ZIP: {name}"))?;
602 let entry = archive.by_index(*idx)?;
603 if entry.is_dir() {
604 bail!("manifest file missing from ZIP: {name}");
605 }
606 if entry.size() != size {
607 bail!("manifest size mismatch for {name}");
608 }
609 listed.insert(name.clone());
610 }
611
612 let required_metadata: std::collections::BTreeSet<&str> =
613 ["manifest.json", "env.sh"].into_iter().collect();
614 for meta in &required_metadata {
615 if !by_name.contains_key(*meta) {
616 bail!("bundle missing required metadata: {meta}");
617 }
618 }
619
620 let mut allowed_dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
621 let mut path_seeds: Vec<String> = listed.iter().cloned().collect();
622 for meta in &required_metadata {
623 path_seeds.push((*meta).to_string());
624 }
625 for name in &path_seeds {
626 let mut parent = Path::new(name).parent();
627 while let Some(p) = parent {
628 let s = p.to_string_lossy().replace('\\', "/");
629 if s.is_empty() || s == "." {
630 break;
631 }
632 allowed_dirs.insert(s);
633 parent = p.parent();
634 }
635 }
636
637 for name in by_name.keys() {
638 let is_listed = listed.contains(name) || required_metadata.contains(name.as_str());
640 if is_listed {
641 continue;
642 }
643 if allowed_dirs.contains(name) {
645 continue;
646 }
647 let idx = by_name[name];
649 let entry = archive.by_index(idx)?;
650 if entry.is_dir() {
651 if !allowed_dirs.contains(name) {
652 bail!("unlisted directory in bundle: {name}");
653 }
654 } else {
655 bail!("unlisted file in bundle: {name}");
656 }
657 }
658
659 for i in 0..archive.len() {
661 let mut entry = archive.by_index(i)?;
662 let raw_name = entry.name().to_string();
663 let is_dir = entry.is_dir();
664 let name = raw_name.trim_end_matches('/').to_string();
665 if name.is_empty() {
666 continue;
667 }
668 let target = dest.join(Path::new(&name));
669 if is_dir {
670 fs::create_dir_all(&target)?;
671 continue;
672 }
673 if let Some(parent) = target.parent() {
674 fs::create_dir_all(parent)?;
675 }
676 let mut hasher = Sha256::new();
677 let mut out =
678 File::create(&target).with_context(|| format!("create {}", target.display()))?;
679 let mut size: u64 = 0;
680 let mut buf = [0u8; 1024 * 1024];
681 loop {
682 let n = entry.read(&mut buf)?;
683 if n == 0 {
684 break;
685 }
686 size += n as u64;
687 if size > MAX_MEMBER_SIZE {
688 bail!("ZIP member expanded past limit: {name}");
689 }
690 hasher.update(&buf[..n]);
691 out.write_all(&buf[..n])?;
692 }
693 if let Some(expected) = manifest.files.get(&name) {
694 let obj = expected.as_object().unwrap();
695 let digest = obj.get("sha256").and_then(|v| v.as_str()).unwrap();
696 let expected_size = obj.get("size").and_then(|v| v.as_u64()).unwrap();
697 let got = hex_encode(&hasher.finalize());
698 if size != expected_size || got != digest.to_ascii_lowercase() {
699 bail!("manifest verification failed for {name}");
700 }
701 }
702 }
703
704 Ok(manifest.root_yaml)
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710
711 #[test]
712 fn normalize_sha256_rejects_bad() {
713 assert!(normalize_sha256("abc").is_err());
714 assert!(normalize_sha256(&"a".repeat(64)).is_ok());
715 }
716
717 #[test]
718 fn looks_like_remote() {
719 assert!(looks_like_remote_url("https://example.com/a.zip"));
720 assert!(looks_like_remote_url("http://example.com/a.zip"));
721 assert!(!looks_like_remote_url("/tmp/foo"));
722 assert!(!looks_like_remote_url("ftp://x"));
723 }
724
725 #[test]
726 fn validate_member_rejects_traversal() {
727 assert!(validate_member_name("../etc/passwd").is_err());
728 assert!(validate_member_name("/abs").is_err());
729 assert!(validate_member_name("ok/path.yaml").is_ok());
730 }
731
732 #[test]
733 fn sha256_hex_stable() {
734 assert_eq!(
735 sha256_hex(b"hello"),
736 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
737 );
738 }
739
740 #[test]
741 fn fetch_verified_from_local_http() {
742 use std::io::Write as _;
743 use std::net::TcpListener;
744 use std::sync::Mutex;
745 use std::thread;
746
747 static LOCK: Mutex<()> = Mutex::new(());
748 let _g = LOCK.lock().unwrap();
749
750 let body = b"#!/bin/sh\necho hi\n";
751 let digest = sha256_hex(body);
752 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
753 let addr = listener.local_addr().unwrap();
754 let handle = thread::spawn(move || {
755 let (mut stream, _) = listener.accept().unwrap();
756 let mut buf = [0u8; 1024];
757 let _ = stream.read(&mut buf);
758 let resp = format!(
759 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
760 body.len()
761 );
762 stream.write_all(resp.as_bytes()).unwrap();
763 stream.write_all(body).unwrap();
764 });
765
766 let cache = tempfile::tempdir().unwrap();
767 std::env::set_var("JAN_CACHE_DIR", cache.path());
768 let url = format!("http://{addr}/script.sh");
769 let opts = FetchOpts::new().with_allow_http(true);
770 let path = fetch_verified(&url, &digest, &opts, true).unwrap();
771 assert_eq!(fs::read(&path).unwrap(), body);
772 std::env::remove_var("JAN_CACHE_DIR");
773 handle.join().unwrap();
774 }
775
776 #[test]
777 fn extract_bundle_roundtrip() {
778 use zip::write::FileOptions;
779 use zip::CompressionMethod;
780
781 let tmp = tempfile::tempdir().unwrap();
782 let zip_path = tmp.path().join("b.zip");
783 let yaml = b"commands:\n hi:\n exec:\n argv: [\"echo\", \"hi\"]\n";
784 let yaml_hash = sha256_hex(yaml);
785 let mut files = serde_json::Map::new();
786 files.insert(
787 "scripts.spec.yaml".into(),
788 serde_json::json!({ "sha256": yaml_hash, "size": yaml.len() }),
789 );
790 let manifest = serde_json::json!({
791 "root_yaml": "scripts.spec.yaml",
792 "files": files,
793 });
794 let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
795
796 {
797 let file = File::create(&zip_path).unwrap();
798 let mut z = zip::ZipWriter::new(file);
799 let opts =
800 FileOptions::<'_, ()>::default().compression_method(CompressionMethod::Stored);
801 z.start_file("scripts.spec.yaml", opts).unwrap();
802 z.write_all(yaml).unwrap();
803 z.start_file("env.sh", opts).unwrap();
804 z.write_all(b"# env\n").unwrap();
805 z.start_file("manifest.json", opts).unwrap();
806 z.write_all(&manifest_bytes).unwrap();
807 z.finish().unwrap();
808 }
809
810 let dest = tmp.path().join("out");
811 fs::create_dir_all(&dest).unwrap();
812 let root = extract_verified_bundle(&zip_path, &dest).unwrap();
813 assert_eq!(root, "scripts.spec.yaml");
814 assert_eq!(fs::read(dest.join("scripts.spec.yaml")).unwrap(), yaml);
815 }
816}