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
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
277/// Fetch `url`, verify against `expected_sha256`, and return the cached file path.
278///
279/// When `executable` is true, sets the executable bit on Unix (for remote scripts).
280pub 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                    // Keep existing valid cache if remote drifted.
361                }
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        // Full download path when no usable cache.
373        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
407/// Read verified remote content as a UTF-8 string (for YAML includes).
408pub 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
413/// Whether a string looks like an http(s) URL suitable for `jan use`.
414pub fn looks_like_remote_url(s: &str) -> bool {
415    let s = s.trim();
416    s.starts_with("https://") || s.starts_with("http://")
417}
418
419// ---------------------------------------------------------------------------
420// Bundle zip: verify + safe extract into ~/.cache/jan/trees/<sha256>/
421// ---------------------------------------------------------------------------
422
423const 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
466/// Download a jan bundle zip, verify the zip SHA256, verify manifest members, unpack
467/// under `~/.cache/jan/trees/<sha256>/`, and return `(tree_dir, root_yaml)`.
468pub 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    // Zips can be larger than a single script object.
475    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    // Re-extract into a staging dir then atomically replace.
489    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    // Atomic-ish replace
506    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    // Read manifest
552    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        // Directory entries may appear with or without trailing slash in our map keys (no slash).
629        let is_listed = listed.contains(name) || required_metadata.contains(name.as_str());
630        if is_listed {
631            continue;
632        }
633        // Treat as directory placeholder only if it is an allowed dir and we never extract content.
634        if allowed_dirs.contains(name) {
635            continue;
636        }
637        // Check if this key corresponds to a dir-only entry in the archive
638        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    // Extract all members
650    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}