Skip to main content

jan_cli/
remote.rs

1//! HTTPS fetch with SHA256 verification and TTL-based local caching (wgex-inspired).
2
3use 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
16/// Default TTL for cached remote objects (24 hours).
17pub const DEFAULT_TTL_SECS: u64 = 86_400;
18/// Default max download size for a single object (20 MiB).
19pub const DEFAULT_MAX_BYTES: u64 = 20 * 1024 * 1024;
20/// Default HTTP timeout.
21pub 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
76/// Cache root: `$JAN_CACHE_DIR` or `$XDG_CACHE_HOME/jan` (or platform equivalent).
77pub 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
164/// Verify `path` contents match `expected` (64-char hex). Returns Ok on match.
165pub 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
289/// Fetch `url`, verify against `expected_sha256`, and return the cached file path.
290///
291/// When `executable` is true, sets the executable bit on Unix (for remote scripts).
292pub 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                    // Keep existing valid cache if remote drifted.
371                }
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        // Full download path when no usable cache.
383        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
417/// Read verified remote content as a UTF-8 string (for YAML includes).
418pub 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
423/// Whether a string looks like an http(s) URL suitable for `jan use`.
424pub fn looks_like_remote_url(s: &str) -> bool {
425    let s = s.trim();
426    s.starts_with("https://") || s.starts_with("http://")
427}
428
429// ---------------------------------------------------------------------------
430// Bundle zip: verify + safe extract into ~/.cache/jan/trees/<sha256>/
431// ---------------------------------------------------------------------------
432
433const 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
476/// Download a jan bundle zip, verify the zip SHA256, verify manifest members, unpack
477/// under `~/.cache/jan/trees/<sha256>/`, and return `(tree_dir, root_yaml)`.
478pub 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    // Zips can be larger than a single script object.
485    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    // Re-extract into a staging dir then atomically replace.
499    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    // Atomic-ish replace
516    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    // Read manifest
562    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        // Directory entries may appear with or without trailing slash in our map keys (no slash).
639        let is_listed = listed.contains(name) || required_metadata.contains(name.as_str());
640        if is_listed {
641            continue;
642        }
643        // Treat as directory placeholder only if it is an allowed dir and we never extract content.
644        if allowed_dirs.contains(name) {
645            continue;
646        }
647        // Check if this key corresponds to a dir-only entry in the archive
648        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    // Extract all members
660    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}