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!(
369 "SHA256 mismatch for {url}: expected {expected}, got {sha256}"
370 );
371 }
372 }
374 }
375 Err(e) => {
376 if !cache_ok {
377 return Err(e).with_context(|| format!("fetch {url}"));
378 }
379 }
380 }
381 }
382
383 if !cache_ok {
384 match fetch_conditional(&client, url, None, opts.max_bytes)? {
386 FetchResult::NotModified => unreachable!("no validators"),
387 FetchResult::Downloaded {
388 temp_path,
389 etag,
390 last_modified,
391 sha256,
392 } => {
393 if sha256 != expected {
394 let _ = fs::remove_file(&temp_path);
395 bail!("SHA256 mismatch for {url}: expected {expected}, got {sha256}");
396 }
397 persist_temp_to_cache(&temp_path, &cache_file)?;
398 if executable {
399 make_executable(&cache_file)?;
400 }
401 let new_meta = CacheMetadata {
402 downloaded_at: SystemTime::now(),
403 content_hash: expected.clone(),
404 etag,
405 last_modified,
406 url: url.to_string(),
407 };
408 write_metadata(&metadata_path, &new_meta);
409 }
410 }
411 }
412
413 if executable {
414 make_executable(&cache_file)?;
415 }
416 Ok(cache_file)
417}
418
419pub fn fetch_verified_text(url: &str, expected_sha256: &str, opts: &FetchOpts) -> Result<String> {
421 let path = fetch_verified(url, expected_sha256, opts, false)?;
422 fs::read_to_string(&path).with_context(|| format!("read cached {}", path.display()))
423}
424
425pub fn looks_like_remote_url(s: &str) -> bool {
427 let s = s.trim();
428 s.starts_with("https://") || s.starts_with("http://")
429}
430
431const MAX_MANIFEST_SIZE: u64 = 1024 * 1024;
436const MAX_MEMBER_SIZE: u64 = 128 * 1024 * 1024;
437const MAX_TOTAL_SIZE: u64 = 512 * 1024 * 1024;
438
439#[derive(Debug, Deserialize)]
440struct BundleManifest {
441 root_yaml: String,
442 files: serde_json::Map<String, serde_json::Value>,
443}
444
445fn validate_member_name(name: &str) -> Result<()> {
446 if name.is_empty() || name.contains('\\') {
447 bail!("unsafe ZIP member path: {name:?}");
448 }
449 let path = Path::new(name);
450 if path.is_absolute() {
451 bail!("unsafe ZIP member path: {name:?}");
452 }
453 for part in path.components() {
454 match part {
455 std::path::Component::Normal(s) => {
456 let s = s.to_string_lossy();
457 if s.is_empty() || s == "." || s == ".." {
458 bail!("unsafe ZIP member path: {name:?}");
459 }
460 }
461 std::path::Component::CurDir | std::path::Component::ParentDir => {
462 bail!("unsafe ZIP member path: {name:?}");
463 }
464 _ => bail!("unsafe ZIP member path: {name:?}"),
465 }
466 }
467 let canonical = path
468 .components()
469 .map(|c| c.as_os_str().to_string_lossy())
470 .collect::<Vec<_>>()
471 .join("/");
472 if canonical != name.trim_end_matches('/') {
473 bail!("non-canonical ZIP member path: {name:?}");
474 }
475 Ok(())
476}
477
478pub fn fetch_and_install_bundle(
481 url: &str,
482 zip_sha256: &str,
483 opts: &FetchOpts,
484) -> Result<(PathBuf, String)> {
485 let expected = normalize_sha256(zip_sha256)?;
486 let mut zip_opts = opts.clone();
488 zip_opts.max_bytes = MAX_TOTAL_SIZE;
489 let zip_path = fetch_verified(url, &expected, &zip_opts, false)?;
490
491 let tree_dir = trees_dir()?.join(&expected);
492 let marker = tree_dir.join(".jan-tree-ready");
493 if tree_dir.is_dir() && marker.is_file() {
494 let root = fs::read_to_string(&marker)?.trim().to_string();
495 if !root.is_empty() && tree_dir.join(&root).is_file() {
496 return Ok((tree_dir, root));
497 }
498 }
499
500 if tree_dir.exists() {
502 fs::remove_dir_all(&tree_dir)
503 .with_context(|| format!("remove stale tree {}", tree_dir.display()))?;
504 }
505
506 let parent = tree_dir
507 .parent()
508 .ok_or_else(|| anyhow!("trees dir has no parent"))?
509 .to_path_buf();
510 let staging = parent.join(format!(".staging-{expected}"));
511 if staging.exists() {
512 fs::remove_dir_all(&staging)?;
513 }
514 fs::create_dir_all(&staging)?;
515
516 let root_yaml = extract_verified_bundle(&zip_path, &staging)?;
517 if tree_dir.exists() {
519 fs::remove_dir_all(&tree_dir)?;
520 }
521 fs::rename(&staging, &tree_dir)
522 .with_context(|| format!("move staging to {}", tree_dir.display()))?;
523 fs::write(&marker, format!("{root_yaml}\n"))?;
524 Ok((tree_dir, root_yaml))
525}
526
527fn extract_verified_bundle(zip_path: &Path, dest: &Path) -> Result<String> {
528 let file = File::open(zip_path).with_context(|| format!("open {}", zip_path.display()))?;
529 let mut archive = zip::ZipArchive::new(file).context("open zip archive")?;
530
531 let mut by_name: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
532 let mut total_size: u64 = 0;
533 for i in 0..archive.len() {
534 let entry = archive.by_index(i)?;
535 let name = entry.name().to_string();
536 let is_dir = entry.is_dir();
537 let name_for_check = if is_dir {
538 name.trim_end_matches('/').to_string()
539 } else {
540 name.clone()
541 };
542 if !name_for_check.is_empty() {
543 validate_member_name(&name_for_check)?;
544 }
545 let key = if is_dir {
546 format!("{}/", name_for_check)
547 } else {
548 name_for_check.clone()
549 };
550 if by_name.contains_key(&key) || by_name.contains_key(&name_for_check) {
551 bail!("duplicate ZIP member: {name_for_check}");
552 }
553 if entry.size() > MAX_MEMBER_SIZE {
554 bail!("ZIP member too large: {name_for_check}");
555 }
556 total_size = total_size.saturating_add(entry.size());
557 if total_size > MAX_TOTAL_SIZE {
558 bail!("bundle exceeds extraction size limit");
559 }
560 by_name.insert(name_for_check, i);
561 }
562
563 let manifest_idx = *by_name
565 .get("manifest.json")
566 .ok_or_else(|| anyhow!("bundle is missing root manifest.json"))?;
567 let mut manifest_entry = archive.by_index(manifest_idx)?;
568 if manifest_entry.size() > MAX_MANIFEST_SIZE {
569 bail!("manifest.json is too large");
570 }
571 let mut manifest_bytes = Vec::new();
572 manifest_entry
573 .read_to_end(&mut manifest_bytes)
574 .context("read manifest.json")?;
575 drop(manifest_entry);
576
577 let manifest: BundleManifest =
578 serde_json::from_slice(&manifest_bytes).context("invalid manifest.json")?;
579 if manifest.files.is_empty() {
580 bail!("manifest.json must contain a non-empty files object");
581 }
582 if !manifest.files.contains_key(&manifest.root_yaml) {
583 bail!("manifest root_yaml must identify a listed file");
584 }
585
586 let mut listed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
587 for (name, expected) in &manifest.files {
588 validate_member_name(name)?;
589 let obj = expected
590 .as_object()
591 .ok_or_else(|| anyhow!("invalid manifest file entry: {name:?}"))?;
592 let digest = obj
593 .get("sha256")
594 .and_then(|v| v.as_str())
595 .ok_or_else(|| anyhow!("invalid manifest hash for {name}"))?;
596 normalize_sha256(digest)?;
597 let size = obj
598 .get("size")
599 .and_then(|v| v.as_u64())
600 .ok_or_else(|| anyhow!("invalid manifest size for {name}"))?;
601 let idx = by_name
602 .get(name.as_str())
603 .ok_or_else(|| anyhow!("manifest file missing from ZIP: {name}"))?;
604 let entry = archive.by_index(*idx)?;
605 if entry.is_dir() {
606 bail!("manifest file missing from ZIP: {name}");
607 }
608 if entry.size() != size {
609 bail!("manifest size mismatch for {name}");
610 }
611 listed.insert(name.clone());
612 }
613
614 let required_metadata: std::collections::BTreeSet<&str> =
615 ["manifest.json", "env.sh"].into_iter().collect();
616 for meta in &required_metadata {
617 if !by_name.contains_key(*meta) {
618 bail!("bundle missing required metadata: {meta}");
619 }
620 }
621
622 let mut allowed_dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
623 let mut path_seeds: Vec<String> = listed.iter().cloned().collect();
624 for meta in &required_metadata {
625 path_seeds.push((*meta).to_string());
626 }
627 for name in &path_seeds {
628 let mut parent = Path::new(name).parent();
629 while let Some(p) = parent {
630 let s = p.to_string_lossy().replace('\\', "/");
631 if s.is_empty() || s == "." {
632 break;
633 }
634 allowed_dirs.insert(s);
635 parent = p.parent();
636 }
637 }
638
639 for name in by_name.keys() {
640 let is_listed = listed.contains(name) || required_metadata.contains(name.as_str());
642 if is_listed {
643 continue;
644 }
645 if allowed_dirs.contains(name) {
647 continue;
648 }
649 let idx = by_name[name];
651 let entry = archive.by_index(idx)?;
652 if entry.is_dir() {
653 if !allowed_dirs.contains(name) {
654 bail!("unlisted directory in bundle: {name}");
655 }
656 } else {
657 bail!("unlisted file in bundle: {name}");
658 }
659 }
660
661 for i in 0..archive.len() {
663 let mut entry = archive.by_index(i)?;
664 let raw_name = entry.name().to_string();
665 let is_dir = entry.is_dir();
666 let name = raw_name.trim_end_matches('/').to_string();
667 if name.is_empty() {
668 continue;
669 }
670 let target = dest.join(Path::new(&name));
671 if is_dir {
672 fs::create_dir_all(&target)?;
673 continue;
674 }
675 if let Some(parent) = target.parent() {
676 fs::create_dir_all(parent)?;
677 }
678 let mut hasher = Sha256::new();
679 let mut out = File::create(&target)
680 .with_context(|| format!("create {}", target.display()))?;
681 let mut size: u64 = 0;
682 let mut buf = [0u8; 1024 * 1024];
683 loop {
684 let n = entry.read(&mut buf)?;
685 if n == 0 {
686 break;
687 }
688 size += n as u64;
689 if size > MAX_MEMBER_SIZE {
690 bail!("ZIP member expanded past limit: {name}");
691 }
692 hasher.update(&buf[..n]);
693 out.write_all(&buf[..n])?;
694 }
695 if let Some(expected) = manifest.files.get(&name) {
696 let obj = expected.as_object().unwrap();
697 let digest = obj.get("sha256").and_then(|v| v.as_str()).unwrap();
698 let expected_size = obj.get("size").and_then(|v| v.as_u64()).unwrap();
699 let got = hex_encode(&hasher.finalize());
700 if size != expected_size || got != digest.to_ascii_lowercase() {
701 bail!("manifest verification failed for {name}");
702 }
703 }
704 }
705
706 Ok(manifest.root_yaml)
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 #[test]
714 fn normalize_sha256_rejects_bad() {
715 assert!(normalize_sha256("abc").is_err());
716 assert!(normalize_sha256(&"a".repeat(64)).is_ok());
717 }
718
719 #[test]
720 fn looks_like_remote() {
721 assert!(looks_like_remote_url("https://example.com/a.zip"));
722 assert!(looks_like_remote_url("http://example.com/a.zip"));
723 assert!(!looks_like_remote_url("/tmp/foo"));
724 assert!(!looks_like_remote_url("ftp://x"));
725 }
726
727 #[test]
728 fn validate_member_rejects_traversal() {
729 assert!(validate_member_name("../etc/passwd").is_err());
730 assert!(validate_member_name("/abs").is_err());
731 assert!(validate_member_name("ok/path.yaml").is_ok());
732 }
733
734 #[test]
735 fn sha256_hex_stable() {
736 assert_eq!(
737 sha256_hex(b"hello"),
738 "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
739 );
740 }
741
742 #[test]
743 fn fetch_verified_from_local_http() {
744 use std::io::Write as _;
745 use std::net::TcpListener;
746 use std::sync::Mutex;
747 use std::thread;
748
749 static LOCK: Mutex<()> = Mutex::new(());
750 let _g = LOCK.lock().unwrap();
751
752 let body = b"#!/bin/sh\necho hi\n";
753 let digest = sha256_hex(body);
754 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
755 let addr = listener.local_addr().unwrap();
756 let handle = thread::spawn(move || {
757 let (mut stream, _) = listener.accept().unwrap();
758 let mut buf = [0u8; 1024];
759 let _ = stream.read(&mut buf);
760 let resp = format!(
761 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
762 body.len()
763 );
764 stream.write_all(resp.as_bytes()).unwrap();
765 stream.write_all(body).unwrap();
766 });
767
768 let cache = tempfile::tempdir().unwrap();
769 std::env::set_var("JAN_CACHE_DIR", cache.path());
770 let url = format!("http://{addr}/script.sh");
771 let opts = FetchOpts::new().with_allow_http(true);
772 let path = fetch_verified(&url, &digest, &opts, true).unwrap();
773 assert_eq!(fs::read(&path).unwrap(), body);
774 std::env::remove_var("JAN_CACHE_DIR");
775 handle.join().unwrap();
776 }
777
778 #[test]
779 fn extract_bundle_roundtrip() {
780 use zip::write::FileOptions;
781 use zip::CompressionMethod;
782
783 let tmp = tempfile::tempdir().unwrap();
784 let zip_path = tmp.path().join("b.zip");
785 let yaml = b"commands:\n hi:\n exec:\n argv: [\"echo\", \"hi\"]\n";
786 let yaml_hash = sha256_hex(yaml);
787 let mut files = serde_json::Map::new();
788 files.insert(
789 "scripts.spec.yaml".into(),
790 serde_json::json!({ "sha256": yaml_hash, "size": yaml.len() }),
791 );
792 let manifest = serde_json::json!({
793 "root_yaml": "scripts.spec.yaml",
794 "files": files,
795 });
796 let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
797
798 {
799 let file = File::create(&zip_path).unwrap();
800 let mut z = zip::ZipWriter::new(file);
801 let opts = FileOptions::<'_, ()>::default().compression_method(CompressionMethod::Stored);
802 z.start_file("scripts.spec.yaml", opts).unwrap();
803 z.write_all(yaml).unwrap();
804 z.start_file("env.sh", opts).unwrap();
805 z.write_all(b"# env\n").unwrap();
806 z.start_file("manifest.json", opts).unwrap();
807 z.write_all(&manifest_bytes).unwrap();
808 z.finish().unwrap();
809 }
810
811 let dest = tmp.path().join("out");
812 fs::create_dir_all(&dest).unwrap();
813 let root = extract_verified_bundle(&zip_path, &dest).unwrap();
814 assert_eq!(root, "scripts.spec.yaml");
815 assert_eq!(fs::read(dest.join("scripts.spec.yaml")).unwrap(), yaml);
816 }
817}