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!(
369                            "SHA256 mismatch for {url}: expected {expected}, got {sha256}"
370                        );
371                    }
372                    // Keep existing valid cache if remote drifted.
373                }
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        // Full download path when no usable cache.
385        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
419/// Read verified remote content as a UTF-8 string (for YAML includes).
420pub 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
425/// Whether a string looks like an http(s) URL suitable for `jan use`.
426pub fn looks_like_remote_url(s: &str) -> bool {
427    let s = s.trim();
428    s.starts_with("https://") || s.starts_with("http://")
429}
430
431// ---------------------------------------------------------------------------
432// Bundle zip: verify + safe extract into ~/.cache/jan/trees/<sha256>/
433// ---------------------------------------------------------------------------
434
435const 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
478/// Download a jan bundle zip, verify the zip SHA256, verify manifest members, unpack
479/// under `~/.cache/jan/trees/<sha256>/`, and return `(tree_dir, root_yaml)`.
480pub 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    // Zips can be larger than a single script object.
487    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    // Re-extract into a staging dir then atomically replace.
501    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    // Atomic-ish replace
518    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    // Read manifest
564    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        // Directory entries may appear with or without trailing slash in our map keys (no slash).
641        let is_listed = listed.contains(name) || required_metadata.contains(name.as_str());
642        if is_listed {
643            continue;
644        }
645        // Treat as directory placeholder only if it is an allowed dir and we never extract content.
646        if allowed_dirs.contains(name) {
647            continue;
648        }
649        // Check if this key corresponds to a dir-only entry in the archive
650        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    // Extract all members
662    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}