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