use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use anyhow::{anyhow, bail, Context, Result};
use reqwest::blocking::Client;
use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED, USER_AGENT};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tempfile::NamedTempFile;
use url::Url;
pub const DEFAULT_TTL_SECS: u64 = 86_400;
pub const DEFAULT_MAX_BYTES: u64 = 20 * 1024 * 1024;
pub const DEFAULT_TIMEOUT_SECS: u64 = 20;
const USER_AGENT_VALUE: &str = concat!("jan-cli/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone)]
pub struct FetchOpts {
pub ttl_secs: u64,
pub max_bytes: u64,
pub timeout_secs: u64,
pub allow_http: bool,
}
impl FetchOpts {
pub fn new() -> Self {
Self {
ttl_secs: DEFAULT_TTL_SECS,
max_bytes: DEFAULT_MAX_BYTES,
timeout_secs: DEFAULT_TIMEOUT_SECS,
allow_http: allow_http_from_env(),
}
}
pub fn with_ttl(mut self, ttl_secs: u64) -> Self {
self.ttl_secs = ttl_secs;
self
}
pub fn with_allow_http(mut self, allow: bool) -> Self {
self.allow_http = allow;
self
}
}
impl Default for FetchOpts {
fn default() -> Self {
Self::new()
}
}
fn allow_http_from_env() -> bool {
matches!(
std::env::var("JAN_ALLOW_HTTP").as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES")
)
}
#[derive(Serialize, Deserialize)]
struct CacheMetadata {
downloaded_at: SystemTime,
content_hash: String,
etag: Option<String>,
last_modified: Option<String>,
url: String,
}
pub fn cache_root() -> Result<PathBuf> {
if let Ok(p) = std::env::var("JAN_CACHE_DIR") {
let p = p.trim();
if !p.is_empty() {
let root = PathBuf::from(p);
fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
return Ok(root);
}
}
let base = dirs::cache_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".cache")))
.ok_or_else(|| anyhow!("could not resolve cache directory"))?;
let root = base.join("jan");
fs::create_dir_all(&root).with_context(|| format!("create {}", root.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&root)?.permissions();
perms.set_mode(0o700);
fs::set_permissions(&root, perms)?;
}
Ok(root)
}
pub fn objects_dir() -> Result<PathBuf> {
let d = cache_root()?.join("objects");
fs::create_dir_all(&d).with_context(|| format!("create {}", d.display()))?;
Ok(d)
}
pub fn trees_dir() -> Result<PathBuf> {
let d = cache_root()?.join("trees");
fs::create_dir_all(&d).with_context(|| format!("create {}", d.display()))?;
Ok(d)
}
fn normalize_sha256(s: &str) -> Result<String> {
let s = s.trim().to_ascii_lowercase();
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
bail!("sha256 must be a 64-character hex string");
}
Ok(s)
}
fn validate_url(url: &str, allow_http: bool) -> Result<Url> {
let parsed = Url::parse(url).with_context(|| format!("invalid URL: {url}"))?;
match parsed.scheme() {
"https" => Ok(parsed),
"http" if allow_http => Ok(parsed),
"http" => bail!("refusing non-HTTPS URL (set JAN_ALLOW_HTTP=1 or pass --allow-http)"),
other => bail!("unsupported URL scheme `{other}` (only https is allowed by default)"),
}
}
fn build_client(timeout_secs: u64) -> Result<Client> {
Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.redirect(reqwest::redirect::Policy::limited(5))
.user_agent(USER_AGENT_VALUE)
.build()
.context("build HTTP client")
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex_encode(&hasher.finalize())
}
fn sha256_file(path: &Path) -> Result<String> {
let mut file = File::open(path).with_context(|| format!("open {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 32 * 1024];
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex_encode(&hasher.finalize()))
}
fn verify_file_hash(path: &Path, expected: &str) -> Result<bool> {
Ok(sha256_file(path)? == expected)
}
fn write_metadata(path: &Path, meta: &CacheMetadata) {
if let Ok(s) = serde_json::to_string(meta) {
let _ = fs::write(path, s);
}
}
fn persist_temp_to_cache(temp_path: &Path, cache_path: &Path) -> Result<()> {
if let Some(parent) = cache_path.parent() {
fs::create_dir_all(parent)?;
}
match fs::rename(temp_path, cache_path) {
Ok(()) => Ok(()),
Err(_) => {
fs::copy(temp_path, cache_path)?;
let _ = fs::remove_file(temp_path);
Ok(())
}
}
}
#[cfg(unix)]
fn make_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o100);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn make_executable(_path: &Path) -> Result<()> {
Ok(())
}
enum FetchResult {
NotModified,
Downloaded {
temp_path: PathBuf,
etag: Option<String>,
last_modified: Option<String>,
sha256: String,
},
}
fn fetch_conditional(
client: &Client,
url: &str,
metadata: Option<&CacheMetadata>,
max_bytes: u64,
) -> Result<FetchResult> {
let mut req = client.get(url);
if let Some(m) = metadata {
if let Some(ref etag) = m.etag {
req = req.header(IF_NONE_MATCH, etag.clone());
}
if let Some(ref lm) = m.last_modified {
req = req.header(IF_MODIFIED_SINCE, lm.clone());
}
}
let mut resp = req.header(USER_AGENT, USER_AGENT_VALUE).send()?;
if resp.status() == reqwest::StatusCode::NOT_MODIFIED {
return Ok(FetchResult::NotModified);
}
if !resp.status().is_success() {
bail!("HTTP error: {}", resp.status());
}
if let Some(len) = resp.content_length() {
if len > max_bytes {
bail!("content too large ({len} bytes > max {max_bytes})");
}
}
let mut hasher = Sha256::new();
let mut tmp = NamedTempFile::new()?;
let mut total: u64 = 0;
let mut buf = [0u8; 16 * 1024];
loop {
let n = resp.read(&mut buf)?;
if n == 0 {
break;
}
total += n as u64;
if total > max_bytes {
bail!("exceeded max bytes {max_bytes}");
}
hasher.update(&buf[..n]);
tmp.write_all(&buf[..n])?;
}
let sha256 = hex_encode(&hasher.finalize());
let etag = resp
.headers()
.get(ETAG)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let last_modified = resp
.headers()
.get(LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let (_file, temp_path) = tmp.keep()?;
Ok(FetchResult::Downloaded {
temp_path,
etag,
last_modified,
sha256,
})
}
pub fn fetch_verified(
url: &str,
expected_sha256: &str,
opts: &FetchOpts,
executable: bool,
) -> Result<PathBuf> {
let expected = normalize_sha256(expected_sha256)?;
validate_url(url, opts.allow_http)?;
let client = build_client(opts.timeout_secs)?;
let cache_dir = objects_dir()?;
let cache_file = cache_dir.join(&expected);
let metadata_path = cache_dir.join(format!("{expected}.meta"));
let mut metadata: Option<CacheMetadata> = None;
if let Ok(s) = fs::read_to_string(&metadata_path) {
if let Ok(m) = serde_json::from_str::<CacheMetadata>(&s) {
metadata = Some(m);
}
}
let mut cache_ok = false;
if cache_file.exists() {
match verify_file_hash(&cache_file, &expected) {
Ok(true) => cache_ok = true,
Ok(false) => {
let _ = fs::remove_file(&cache_file);
}
Err(_) => {
let _ = fs::remove_file(&cache_file);
}
}
}
let mut cache_fresh = false;
if cache_ok {
if let Some(ref m) = metadata {
if let Ok(elapsed) = m.downloaded_at.elapsed() {
if elapsed < Duration::from_secs(opts.ttl_secs) {
cache_fresh = true;
}
}
}
}
if !cache_fresh {
match fetch_conditional(&client, url, metadata.as_ref(), opts.max_bytes) {
Ok(FetchResult::NotModified) => {
if let Some(mut m) = metadata.take() {
m.downloaded_at = SystemTime::now();
write_metadata(&metadata_path, &m);
}
cache_ok = true;
}
Ok(FetchResult::Downloaded {
temp_path,
etag,
last_modified,
sha256,
}) => {
if sha256 == expected {
persist_temp_to_cache(&temp_path, &cache_file)?;
if executable {
make_executable(&cache_file)?;
}
let new_meta = CacheMetadata {
downloaded_at: SystemTime::now(),
content_hash: expected.clone(),
etag,
last_modified,
url: url.to_string(),
};
write_metadata(&metadata_path, &new_meta);
cache_ok = true;
} else {
let _ = fs::remove_file(&temp_path);
if !cache_ok {
bail!(
"SHA256 mismatch for {url}: expected {expected}, got {sha256}"
);
}
}
}
Err(e) => {
if !cache_ok {
return Err(e).with_context(|| format!("fetch {url}"));
}
}
}
}
if !cache_ok {
match fetch_conditional(&client, url, None, opts.max_bytes)? {
FetchResult::NotModified => unreachable!("no validators"),
FetchResult::Downloaded {
temp_path,
etag,
last_modified,
sha256,
} => {
if sha256 != expected {
let _ = fs::remove_file(&temp_path);
bail!("SHA256 mismatch for {url}: expected {expected}, got {sha256}");
}
persist_temp_to_cache(&temp_path, &cache_file)?;
if executable {
make_executable(&cache_file)?;
}
let new_meta = CacheMetadata {
downloaded_at: SystemTime::now(),
content_hash: expected.clone(),
etag,
last_modified,
url: url.to_string(),
};
write_metadata(&metadata_path, &new_meta);
}
}
}
if executable {
make_executable(&cache_file)?;
}
Ok(cache_file)
}
pub fn fetch_verified_text(url: &str, expected_sha256: &str, opts: &FetchOpts) -> Result<String> {
let path = fetch_verified(url, expected_sha256, opts, false)?;
fs::read_to_string(&path).with_context(|| format!("read cached {}", path.display()))
}
pub fn looks_like_remote_url(s: &str) -> bool {
let s = s.trim();
s.starts_with("https://") || s.starts_with("http://")
}
const MAX_MANIFEST_SIZE: u64 = 1024 * 1024;
const MAX_MEMBER_SIZE: u64 = 128 * 1024 * 1024;
const MAX_TOTAL_SIZE: u64 = 512 * 1024 * 1024;
#[derive(Debug, Deserialize)]
struct BundleManifest {
root_yaml: String,
files: serde_json::Map<String, serde_json::Value>,
}
fn validate_member_name(name: &str) -> Result<()> {
if name.is_empty() || name.contains('\\') {
bail!("unsafe ZIP member path: {name:?}");
}
let path = Path::new(name);
if path.is_absolute() {
bail!("unsafe ZIP member path: {name:?}");
}
for part in path.components() {
match part {
std::path::Component::Normal(s) => {
let s = s.to_string_lossy();
if s.is_empty() || s == "." || s == ".." {
bail!("unsafe ZIP member path: {name:?}");
}
}
std::path::Component::CurDir | std::path::Component::ParentDir => {
bail!("unsafe ZIP member path: {name:?}");
}
_ => bail!("unsafe ZIP member path: {name:?}"),
}
}
let canonical = path
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if canonical != name.trim_end_matches('/') {
bail!("non-canonical ZIP member path: {name:?}");
}
Ok(())
}
pub fn fetch_and_install_bundle(
url: &str,
zip_sha256: &str,
opts: &FetchOpts,
) -> Result<(PathBuf, String)> {
let expected = normalize_sha256(zip_sha256)?;
let mut zip_opts = opts.clone();
zip_opts.max_bytes = MAX_TOTAL_SIZE;
let zip_path = fetch_verified(url, &expected, &zip_opts, false)?;
let tree_dir = trees_dir()?.join(&expected);
let marker = tree_dir.join(".jan-tree-ready");
if tree_dir.is_dir() && marker.is_file() {
let root = fs::read_to_string(&marker)?.trim().to_string();
if !root.is_empty() && tree_dir.join(&root).is_file() {
return Ok((tree_dir, root));
}
}
if tree_dir.exists() {
fs::remove_dir_all(&tree_dir)
.with_context(|| format!("remove stale tree {}", tree_dir.display()))?;
}
let parent = tree_dir
.parent()
.ok_or_else(|| anyhow!("trees dir has no parent"))?
.to_path_buf();
let staging = parent.join(format!(".staging-{expected}"));
if staging.exists() {
fs::remove_dir_all(&staging)?;
}
fs::create_dir_all(&staging)?;
let root_yaml = extract_verified_bundle(&zip_path, &staging)?;
if tree_dir.exists() {
fs::remove_dir_all(&tree_dir)?;
}
fs::rename(&staging, &tree_dir)
.with_context(|| format!("move staging to {}", tree_dir.display()))?;
fs::write(&marker, format!("{root_yaml}\n"))?;
Ok((tree_dir, root_yaml))
}
fn extract_verified_bundle(zip_path: &Path, dest: &Path) -> Result<String> {
let file = File::open(zip_path).with_context(|| format!("open {}", zip_path.display()))?;
let mut archive = zip::ZipArchive::new(file).context("open zip archive")?;
let mut by_name: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let mut total_size: u64 = 0;
for i in 0..archive.len() {
let entry = archive.by_index(i)?;
let name = entry.name().to_string();
let is_dir = entry.is_dir();
let name_for_check = if is_dir {
name.trim_end_matches('/').to_string()
} else {
name.clone()
};
if !name_for_check.is_empty() {
validate_member_name(&name_for_check)?;
}
let key = if is_dir {
format!("{}/", name_for_check)
} else {
name_for_check.clone()
};
if by_name.contains_key(&key) || by_name.contains_key(&name_for_check) {
bail!("duplicate ZIP member: {name_for_check}");
}
if entry.size() > MAX_MEMBER_SIZE {
bail!("ZIP member too large: {name_for_check}");
}
total_size = total_size.saturating_add(entry.size());
if total_size > MAX_TOTAL_SIZE {
bail!("bundle exceeds extraction size limit");
}
by_name.insert(name_for_check, i);
}
let manifest_idx = *by_name
.get("manifest.json")
.ok_or_else(|| anyhow!("bundle is missing root manifest.json"))?;
let mut manifest_entry = archive.by_index(manifest_idx)?;
if manifest_entry.size() > MAX_MANIFEST_SIZE {
bail!("manifest.json is too large");
}
let mut manifest_bytes = Vec::new();
manifest_entry
.read_to_end(&mut manifest_bytes)
.context("read manifest.json")?;
drop(manifest_entry);
let manifest: BundleManifest =
serde_json::from_slice(&manifest_bytes).context("invalid manifest.json")?;
if manifest.files.is_empty() {
bail!("manifest.json must contain a non-empty files object");
}
if !manifest.files.contains_key(&manifest.root_yaml) {
bail!("manifest root_yaml must identify a listed file");
}
let mut listed: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (name, expected) in &manifest.files {
validate_member_name(name)?;
let obj = expected
.as_object()
.ok_or_else(|| anyhow!("invalid manifest file entry: {name:?}"))?;
let digest = obj
.get("sha256")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("invalid manifest hash for {name}"))?;
normalize_sha256(digest)?;
let size = obj
.get("size")
.and_then(|v| v.as_u64())
.ok_or_else(|| anyhow!("invalid manifest size for {name}"))?;
let idx = by_name
.get(name.as_str())
.ok_or_else(|| anyhow!("manifest file missing from ZIP: {name}"))?;
let entry = archive.by_index(*idx)?;
if entry.is_dir() {
bail!("manifest file missing from ZIP: {name}");
}
if entry.size() != size {
bail!("manifest size mismatch for {name}");
}
listed.insert(name.clone());
}
let required_metadata: std::collections::BTreeSet<&str> =
["manifest.json", "env.sh"].into_iter().collect();
for meta in &required_metadata {
if !by_name.contains_key(*meta) {
bail!("bundle missing required metadata: {meta}");
}
}
let mut allowed_dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
let mut path_seeds: Vec<String> = listed.iter().cloned().collect();
for meta in &required_metadata {
path_seeds.push((*meta).to_string());
}
for name in &path_seeds {
let mut parent = Path::new(name).parent();
while let Some(p) = parent {
let s = p.to_string_lossy().replace('\\', "/");
if s.is_empty() || s == "." {
break;
}
allowed_dirs.insert(s);
parent = p.parent();
}
}
for name in by_name.keys() {
let is_listed = listed.contains(name) || required_metadata.contains(name.as_str());
if is_listed {
continue;
}
if allowed_dirs.contains(name) {
continue;
}
let idx = by_name[name];
let entry = archive.by_index(idx)?;
if entry.is_dir() {
if !allowed_dirs.contains(name) {
bail!("unlisted directory in bundle: {name}");
}
} else {
bail!("unlisted file in bundle: {name}");
}
}
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let raw_name = entry.name().to_string();
let is_dir = entry.is_dir();
let name = raw_name.trim_end_matches('/').to_string();
if name.is_empty() {
continue;
}
let target = dest.join(Path::new(&name));
if is_dir {
fs::create_dir_all(&target)?;
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
let mut hasher = Sha256::new();
let mut out = File::create(&target)
.with_context(|| format!("create {}", target.display()))?;
let mut size: u64 = 0;
let mut buf = [0u8; 1024 * 1024];
loop {
let n = entry.read(&mut buf)?;
if n == 0 {
break;
}
size += n as u64;
if size > MAX_MEMBER_SIZE {
bail!("ZIP member expanded past limit: {name}");
}
hasher.update(&buf[..n]);
out.write_all(&buf[..n])?;
}
if let Some(expected) = manifest.files.get(&name) {
let obj = expected.as_object().unwrap();
let digest = obj.get("sha256").and_then(|v| v.as_str()).unwrap();
let expected_size = obj.get("size").and_then(|v| v.as_u64()).unwrap();
let got = hex_encode(&hasher.finalize());
if size != expected_size || got != digest.to_ascii_lowercase() {
bail!("manifest verification failed for {name}");
}
}
}
Ok(manifest.root_yaml)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_sha256_rejects_bad() {
assert!(normalize_sha256("abc").is_err());
assert!(normalize_sha256(&"a".repeat(64)).is_ok());
}
#[test]
fn looks_like_remote() {
assert!(looks_like_remote_url("https://example.com/a.zip"));
assert!(looks_like_remote_url("http://example.com/a.zip"));
assert!(!looks_like_remote_url("/tmp/foo"));
assert!(!looks_like_remote_url("ftp://x"));
}
#[test]
fn validate_member_rejects_traversal() {
assert!(validate_member_name("../etc/passwd").is_err());
assert!(validate_member_name("/abs").is_err());
assert!(validate_member_name("ok/path.yaml").is_ok());
}
#[test]
fn sha256_hex_stable() {
assert_eq!(
sha256_hex(b"hello"),
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
);
}
#[test]
fn fetch_verified_from_local_http() {
use std::io::Write as _;
use std::net::TcpListener;
use std::sync::Mutex;
use std::thread;
static LOCK: Mutex<()> = Mutex::new(());
let _g = LOCK.lock().unwrap();
let body = b"#!/bin/sh\necho hi\n";
let digest = sha256_hex(body);
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut buf = [0u8; 1024];
let _ = stream.read(&mut buf);
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(resp.as_bytes()).unwrap();
stream.write_all(body).unwrap();
});
let cache = tempfile::tempdir().unwrap();
std::env::set_var("JAN_CACHE_DIR", cache.path());
let url = format!("http://{addr}/script.sh");
let opts = FetchOpts::new().with_allow_http(true);
let path = fetch_verified(&url, &digest, &opts, true).unwrap();
assert_eq!(fs::read(&path).unwrap(), body);
std::env::remove_var("JAN_CACHE_DIR");
handle.join().unwrap();
}
#[test]
fn extract_bundle_roundtrip() {
use zip::write::FileOptions;
use zip::CompressionMethod;
let tmp = tempfile::tempdir().unwrap();
let zip_path = tmp.path().join("b.zip");
let yaml = b"commands:\n hi:\n exec:\n argv: [\"echo\", \"hi\"]\n";
let yaml_hash = sha256_hex(yaml);
let mut files = serde_json::Map::new();
files.insert(
"scripts.spec.yaml".into(),
serde_json::json!({ "sha256": yaml_hash, "size": yaml.len() }),
);
let manifest = serde_json::json!({
"root_yaml": "scripts.spec.yaml",
"files": files,
});
let manifest_bytes = serde_json::to_vec_pretty(&manifest).unwrap();
{
let file = File::create(&zip_path).unwrap();
let mut z = zip::ZipWriter::new(file);
let opts = FileOptions::<'_, ()>::default().compression_method(CompressionMethod::Stored);
z.start_file("scripts.spec.yaml", opts).unwrap();
z.write_all(yaml).unwrap();
z.start_file("env.sh", opts).unwrap();
z.write_all(b"# env\n").unwrap();
z.start_file("manifest.json", opts).unwrap();
z.write_all(&manifest_bytes).unwrap();
z.finish().unwrap();
}
let dest = tmp.path().join("out");
fs::create_dir_all(&dest).unwrap();
let root = extract_verified_bundle(&zip_path, &dest).unwrap();
assert_eq!(root, "scripts.spec.yaml");
assert_eq!(fs::read(dest.join("scripts.spec.yaml")).unwrap(), yaml);
}
}