git.delta.rocks / jrsonnet / refs/commits / c93de7763652

difftreelog

feat jrb

orkooprxYaroslav Bolyukin2026-05-06parent: #3732ed2.patch.diff
in: master

11 files changed

modifiedCargo.lockdiffbeforeafterboth
before · Cargo.lock
255 packageslockfile v4
after · Cargo.lock
478 packageslockfile v4
modifiedCargo.tomldiffbeforeafterboth
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,7 @@
 jrsonnet-types = { path = "./crates/jrsonnet-types", version = "0.5.0-pre98" }
 jrsonnet-formatter = { path = "./crates/jrsonnet-formatter", version = "0.5.0-pre98" }
 jrsonnet-lexer = { path = "./crates/jrsonnet-lexer", version = "0.5.0-pre98" }
+jrsonnet-pkg = { path = "./crates/jrsonnet-pkg", version = "0.5.0-pre98" }
 jrsonnet-gcmodule = { version = "0.5.0" }
 # Diagnostics.
 # hi-doc is my library, which handles text formatting very well, but isn't polished enough yet
@@ -116,6 +117,21 @@
 console_error_panic_hook = "0.1"
 getrandom = "0.3.4"
 
+# Bundler
+tracing = "0.1.44"
+tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+reqwest = { version = "0.13", features = [
+  "blocking",
+  "rustls",
+], default-features = false }
+zip = { version = "8", default-features = false, features = ["deflate"] }
+directories = "6.0.0"
+gix = { version = "0.83.0", features = [
+  "blocking-network-client",
+  "blocking-http-transport-reqwest-rust-tls",
+] }
+camino = { version = "1.2.2", features = ["serde1"] }
+
 [workspace.lints.rust]
 unsafe_op_in_unsafe_fn = "deny"
 
addedcmds/jrb/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/cmds/jrb/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "jrb"
+description = "jsonnet package manager"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+jrsonnet-pkg.workspace = true
+
+clap = { workspace = true, features = ["derive"] }
+serde = { workspace = true }
+serde_json.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
addedcmds/jrb/src/main.rsdiffbeforeafterboth
--- /dev/null
+++ b/cmds/jrb/src/main.rs
@@ -0,0 +1,222 @@
+use std::{
+	path::{Path, PathBuf},
+	process::exit,
+};
+
+use clap::{Parser, Subcommand};
+use jrsonnet_pkg::{
+	install,
+	jsonnet_bundler::{GitSource, JsonnetFile},
+};
+use tracing::{error, info, warn};
+
+#[derive(Parser)]
+#[clap(about = "A jsonnet package manager")]
+struct Opts {
+	/// The directory used to cache packages in.
+	#[clap(long, default_value = "vendor")]
+	jsonnetpkg_home: PathBuf,
+	#[clap(subcommand)]
+	command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+	/// Initialize a new empty jsonnetfile
+	Init,
+	/// Install new dependencies. Existing ones are silently skipped
+	Install {
+		/// Package URIs to install
+		uris: Vec<String>,
+		/// Show what would be done without making changes
+		#[clap(long)]
+		dry_run: bool,
+	},
+	/// Update all or specific dependencies
+	Update {
+		/// Package URIs to update (all if empty)
+		uris: Vec<String>,
+		/// Show what would be done without making changes
+		#[clap(long)]
+		dry_run: bool,
+	},
+	/// Remove dependencies by name
+	Remove {
+		/// Dependency names (matched against both canonical and legacy names)
+		names: Vec<String>,
+		/// Show what would be removed without making changes
+		#[clap(long)]
+		dry_run: bool,
+	},
+}
+
+const MANIFEST: &str = "jsonnetfile.json";
+const LOCKFILE: &str = "jsonnetfile.lock.json";
+
+fn load_manifest() -> JsonnetFile {
+	let path = Path::new(MANIFEST);
+	if path.exists() {
+		JsonnetFile::load(path).unwrap_or_else(|e| {
+			error!("failed to load {MANIFEST}: {e}");
+			exit(1);
+		})
+	} else {
+		JsonnetFile {
+			version: 1,
+			dependencies: Vec::new(),
+			legacy_imports: true,
+		}
+	}
+}
+
+fn save_json(path: &Path, value: &impl serde::Serialize) {
+	let json = serde_json::to_string_pretty(value).expect("serialization failed");
+	std::fs::write(path, format!("{json}\n")).unwrap_or_else(|e| {
+		error!("failed to write {}: {e}", path.display());
+		exit(1);
+	});
+}
+
+fn load_lockfile() -> Option<JsonnetFile> {
+	let path = Path::new(LOCKFILE);
+	if path.exists() {
+		Some(JsonnetFile::load(path).unwrap_or_else(|e| {
+			error!("failed to load {LOCKFILE}: {e}");
+			exit(1);
+		}))
+	} else {
+		None
+	}
+}
+
+fn do_install(
+	manifest: &JsonnetFile,
+	lock: Option<&JsonnetFile>,
+	vendor_dir: &Path,
+	dry_run: bool,
+) {
+	let new_lock = install::install(manifest, lock, vendor_dir, dry_run).unwrap_or_else(|e| {
+		error!("install failed: {e}");
+		exit(1);
+	});
+	if !dry_run {
+		save_json(Path::new(LOCKFILE), &new_lock);
+	}
+}
+
+fn main() {
+	tracing_subscriber::fmt().init();
+
+	let opts = Opts::parse();
+
+	match opts.command {
+		Command::Init => {
+			let path = Path::new(MANIFEST);
+			if path.exists() {
+				warn!("{MANIFEST} already exists");
+				exit(1);
+			}
+			let jf = JsonnetFile {
+				version: 1,
+				dependencies: Vec::new(),
+				legacy_imports: true,
+			};
+			save_json(path, &jf);
+		}
+		Command::Install { uris, dry_run } => {
+			let mut manifest = load_manifest();
+
+			for uri in &uris {
+				let dep = GitSource::parse(uri).unwrap_or_else(|| {
+					eprintln!("failed to parse URI: {uri}");
+					exit(1);
+				});
+				let is_new = !manifest.dependencies.iter().any(|d| {
+					std::mem::discriminant(&d.source) == std::mem::discriminant(&dep.source)
+						&& d.canonical_name() == dep.canonical_name()
+				});
+				if is_new {
+					manifest.dependencies.push(dep);
+				}
+			}
+
+			if !uris.is_empty() {
+				save_json(Path::new(MANIFEST), &manifest);
+			}
+
+			let lock = load_lockfile();
+			do_install(&manifest, lock.as_ref(), &opts.jsonnetpkg_home, dry_run);
+		}
+		Command::Update { uris, dry_run } => {
+			let mut manifest = load_manifest();
+
+			if !uris.is_empty() {
+				for uri in &uris {
+					let dep = GitSource::parse(uri).unwrap_or_else(|| {
+						eprintln!("failed to parse URI: {uri}");
+						exit(1);
+					});
+					if let Some(existing) = manifest
+						.dependencies
+						.iter_mut()
+						.find(|d| d.canonical_name() == dep.canonical_name())
+					{
+						*existing = dep;
+					} else {
+						manifest.dependencies.push(dep);
+					}
+				}
+				save_json(Path::new(MANIFEST), &manifest);
+			}
+
+			do_install(&manifest, None, &opts.jsonnetpkg_home, dry_run);
+		}
+		Command::Remove { names, dry_run } => {
+			let mut manifest = load_manifest();
+
+			let matched: Vec<_> = manifest
+				.dependencies
+				.iter()
+				.filter(|dep| {
+					names.iter().any(|name| {
+						dep.canonical_name() == *name || dep.legacy_link_name() == *name
+					})
+				})
+				.cloned()
+				.collect::<Vec<_>>();
+
+			if matched.is_empty() {
+				eprintln!("no matching dependencies found");
+				exit(1);
+			}
+
+			for dep in &matched {
+				let canonical = dep.canonical_name();
+				let dir = opts.jsonnetpkg_home.join(&canonical);
+				let legacy = dep.legacy_link_name();
+				let link = opts.jsonnetpkg_home.join(&legacy);
+				if dry_run {
+					info!("would remove: {canonical} ({})", dir.display());
+				} else {
+					info!("removing: {canonical}");
+					if dir.exists() {
+						let _ = std::fs::remove_dir_all(&dir);
+					}
+					if link.symlink_metadata().is_ok() {
+						let _ = std::fs::remove_file(&link);
+					}
+				}
+			}
+
+			if !dry_run {
+				manifest.dependencies.retain(|dep| {
+					!names.iter().any(|name| {
+						dep.canonical_name() == *name || dep.legacy_link_name() == *name
+					})
+				});
+				save_json(Path::new(MANIFEST), &manifest);
+				save_json(Path::new(LOCKFILE), &manifest);
+			}
+		}
+	}
+}
addedcrates/jrsonnet-pkg/Cargo.tomldiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "jrsonnet-pkg"
+description = "jsonnet-bundler jsonnetfile parser and installer"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+version.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+
+# Source url parser
+peg.workspace = true
+
+# Gix for git repos, reqwest + zip for github
+gix.workspace = true
+reqwest.workspace = true
+zip.workspace = true
+url.workspace = true
+camino.workspace = true
+
+# Global cache dir
+directories.workspace = true
addedcrates/jrsonnet-pkg/src/install/accessor.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/src/install/accessor.rs
@@ -0,0 +1,132 @@
+use std::{
+	fs::File,
+	io::{self, Read},
+	result,
+	str::FromStr as _,
+	sync::Mutex,
+};
+
+use tracing::warn;
+use zip::{ZipArchive, result::ZipError};
+
+use crate::jsonnet_bundler::{SubDir, SubDirEscapeError};
+
+#[derive(thiserror::Error, Debug)]
+pub enum Error {
+	#[error(transparent)]
+	Zip(#[from] ZipError),
+	#[error("invalid prefixed archive")]
+	ZipInvalidPrefix,
+	#[error("zip io: {0}")]
+	ZipIo(io::Error),
+	#[error("subdir not found: {0}")]
+	SubDirNotFound(SubDir),
+	#[error(transparent)]
+	SubdirEscape(#[from] SubDirEscapeError),
+}
+type Result<T, E = Error> = result::Result<T, E>;
+
+pub trait SourceAccessor {}
+
+pub struct ZipFileAccessor {
+	archive: Mutex<ZipArchive<File>>,
+	// Github archives have top-level directory with repo name
+	prefix: SubDir,
+}
+
+impl ZipFileAccessor {
+	pub fn new_prefixed(file: File) -> Result<Self> {
+		let archive = ZipArchive::new(file)?;
+		let prefix = archive.name_for_index(0).ok_or(Error::ZipInvalidPrefix)?;
+
+		Ok(Self {
+			prefix: SubDir::from_str(prefix)?,
+			archive: Mutex::new(archive),
+		})
+	}
+	/// Read a file from inside the archive's logical root (after stripping the
+	/// github-style `<repo>-<sha>/` prefix).
+	#[allow(clippy::significant_drop_tightening, reason = "false-positive")]
+	pub fn read(&self, name: &SubDir) -> Result<Option<Vec<u8>>> {
+		let prefixed = self
+			.prefix
+			.join(name)
+			.expect("prefix and name are both subdirs");
+		let mut archive = self.archive.lock().expect("not poisoned");
+		let mut v = match archive.by_name(prefixed.as_str()) {
+			Ok(v) => v,
+			Err(ZipError::FileNotFound) => return Ok(None),
+			Err(e) => return Err(e.into()),
+		};
+		if !v.is_file() {
+			return Ok(None);
+		}
+		let mut out = Vec::new();
+		v.read_to_end(&mut out).map_err(Error::ZipIo)?;
+		Ok(Some(out))
+	}
+	#[allow(clippy::significant_drop_tightening, reason = "false-positive")]
+	pub fn iter<E>(
+		&self,
+		subdir: &SubDir,
+		cb: &mut dyn FnMut(SubDir, AccessorEntry) -> Result<(), E>,
+	) -> Result<(), E>
+	where
+		E: From<Error>,
+	{
+		let mut archive = self.archive.lock().expect("not poisoned");
+		let len = archive.len();
+
+		let mut found = false;
+		for i in 0..len {
+			let mut entry = archive.by_index(i).map_err(Error::from)?;
+			let raw = entry.name();
+			let Ok(full_name) = SubDir::from_str(raw) else {
+				warn!("invalid zip entry name: {raw}");
+				continue;
+			};
+			// Peel off the github-archive top-level `<repo>-<sha>/` prefix.
+			let Some(in_repo) = full_name.strip_prefix(&self.prefix) else {
+				continue;
+			};
+			let Some(name) = in_repo.strip_prefix(subdir) else {
+				continue;
+			};
+			found = true;
+			if name.is_empty() && entry.is_dir() {
+				continue;
+			}
+
+			cb(
+				name.clone(),
+				if entry.is_dir() {
+					AccessorEntry::Dir
+				} else if entry.is_file() {
+					let mut data = Vec::new();
+					entry.read_to_end(&mut data).map_err(Error::ZipIo)?;
+					AccessorEntry::File(data)
+				} else {
+					// TODO: Symlinks?
+					panic!("unknown accessor entry type: {name:?}")
+				},
+			)?;
+		}
+
+		if !found {
+			return Err(Error::SubDirNotFound(subdir.clone()).into());
+		}
+
+		Ok(())
+	}
+	pub fn len(&self) -> usize {
+		self.archive.lock().expect("not poisoned").len()
+	}
+	pub fn is_empty(&self) -> bool {
+		self.len() == 0
+	}
+}
+
+pub enum AccessorEntry {
+	Dir,
+	File(Vec<u8>),
+}
addedcrates/jrsonnet-pkg/src/install/git.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/src/install/git.rs
@@ -0,0 +1,212 @@
+#![allow(clippy::result_large_err)]
+
+use std::{collections::HashSet, fs, path::Path};
+
+use gix::{
+	bstr::{self, ByteSlice},
+	interrupt, progress,
+	remote::{self, ref_map},
+};
+use tracing::info;
+
+use super::{Error, LocalExtraction, ResolveResult, Result, VendorSource, cache_dir};
+use crate::jsonnet_bundler::{Dependency, GitSource, JsonnetFile, Source, SubDir};
+
+fn repo_cache_path(remote: &GitSource) -> Result<std::path::PathBuf> {
+	Ok(cache_dir("git")?.join(&remote.host).join(&remote.repo))
+}
+
+fn ensure_repo(remote: &GitSource) -> Result<gix::Repository> {
+	let cache_path = repo_cache_path(remote)?;
+
+	if cache_path.exists() {
+		if let Ok(repo) = gix::open(&cache_path) {
+			fetch_remote(&repo, &remote.remote())?;
+			return Ok(repo);
+		}
+		fs::remove_dir_all(&cache_path).map_err(|e| Error::Io(cache_path.clone(), e))?;
+	}
+
+	fs::create_dir_all(cache_path.parent().expect("has parent"))
+		.map_err(|e| Error::Io(cache_path.clone(), e))?;
+
+	let mut clone = gix::prepare_clone_bare(remote.remote(), &cache_path)?;
+	let (repo, _) = clone.fetch_only(progress::Discard, &interrupt::IS_INTERRUPTED)?;
+	fetch_remote(&repo, &remote.remote())?;
+
+	Ok(repo)
+}
+
+fn fetch_remote(repo: &gix::Repository, remote: &str) -> Result<(), Error> {
+	repo.remote_at(remote)?
+		.with_refspecs(["+refs/*:refs/*"], remote::Direction::Fetch)?
+		.connect(remote::Direction::Fetch)?
+		.prepare_fetch(progress::Discard, ref_map::Options::default())?
+		.receive(progress::Discard, &interrupt::IS_INTERRUPTED)?;
+	Ok(())
+}
+
+fn extract_tree(
+	repo: &gix::Repository,
+	tree: &gix::Tree<'_>,
+	subdir: &SubDir,
+	dest: &Path,
+) -> Result<(), Error> {
+	let target_tree;
+	let tree = if subdir.is_empty() {
+		tree
+	} else {
+		let mut t = tree.clone();
+		let entry = t
+			.peel_to_entry_by_path(subdir.as_path().as_std_path())?
+			.ok_or_else(|| Error::SubdirNotFound(subdir.to_string()))?;
+		target_tree = entry.object()?.into_tree();
+		&target_tree
+	};
+
+	let files = tree.traverse().breadthfirst.files()?;
+
+	for entry in &files {
+		if !entry.mode.is_blob() {
+			continue;
+		}
+		let rel_path = entry
+			.filepath
+			.to_str()
+			.map_err(|_| Error::InvalidPath(entry.filepath.to_string()))?;
+		let file_path = dest.join(rel_path);
+
+		if let Some(parent) = file_path.parent() {
+			fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_owned(), e))?;
+		}
+
+		let blob = repo.find_object(entry.oid)?;
+		fs::write(&file_path, &blob.data).map_err(|e| Error::Io(file_path, e))?;
+	}
+
+	Ok(())
+}
+
+fn resolve_version<'r>(repo: &'r gix::Repository, version: &str) -> Result<gix::Id<'r>> {
+	let spec: &bstr::BStr = version.into();
+	if let Ok(id) = repo.rev_parse_single(spec) {
+		return Ok(id);
+	}
+	for prefix in ["refs/heads/", "refs/tags/"] {
+		let refname = format!("{prefix}{version}");
+		if let Ok(r) = repo.find_reference(&refname) {
+			return Ok(r.into_fully_peeled_id()?);
+		}
+	}
+	Ok(repo.rev_parse_single(spec)?)
+}
+
+fn read_blob_at_path(
+	repo: &gix::Repository,
+	tree: &gix::Tree<'_>,
+	path: &SubDir,
+) -> Option<Vec<u8>> {
+	let mut t = tree.clone();
+	let entry = t
+		.peel_to_entry_by_path(path.as_path().as_std_path())
+		.ok()??;
+	let blob = repo.find_object(entry.oid()).ok()?;
+	Some(blob.data.clone())
+}
+
+fn collect_tree_deps(
+	repo: &gix::Repository,
+	tree: &gix::Tree<'_>,
+	dir: &SubDir,
+	git_deps: &mut Vec<Dependency>,
+	local_extractions: &mut Vec<LocalExtraction>,
+	visited: &mut HashSet<SubDir>,
+) {
+	if !visited.insert(dir.clone()) {
+		return;
+	}
+
+	let manifest_path = dir
+		.join("jsonnetfile.json")
+		.expect("appending a literal filename keeps it within parent");
+	let Some(data) = read_blob_at_path(repo, tree, &manifest_path) else {
+		return;
+	};
+	let Ok(manifest) = serde_json::from_slice::<JsonnetFile>(&data) else {
+		return;
+	};
+
+	for dep in manifest.dependencies {
+		match &dep.source {
+			Source::Git(_) => git_deps.push(dep),
+			Source::Local(local) => {
+				let Ok(child_dir) = local.resolve_under(dir) else {
+					info!("local source {local} escapes its package; skipping");
+					continue;
+				};
+				let name = child_dir
+					.file_name()
+					.map_or_else(|| local.to_string(), str::to_owned);
+				local_extractions.push(LocalExtraction {
+					tree_path: child_dir.clone(),
+					name,
+				});
+				collect_tree_deps(repo, tree, &child_dir, git_deps, local_extractions, visited);
+			}
+		}
+	}
+}
+
+pub(super) fn resolve(
+	git_source: &GitSource,
+	version: Option<&str>,
+) -> Result<ResolveResult, Error> {
+	info!("fetching via git: {}", git_source.remote());
+	let repo = ensure_repo(git_source)?;
+	let id = match version {
+		Some(v) => resolve_version(&repo, v)?,
+		None => repo.head_id()?,
+	};
+	let commit = repo.find_object(id)?.into_commit();
+	let tree = commit.tree()?;
+
+	let mut transitive_git_deps = Vec::new();
+	let mut local_extractions = Vec::new();
+	let mut visited = HashSet::new();
+	collect_tree_deps(
+		&repo,
+		&tree,
+		&git_source.subdir,
+		&mut transitive_git_deps,
+		&mut local_extractions,
+		&mut visited,
+	);
+
+	let repo_path = repo_cache_path(git_source)?;
+	let sha = id.to_string();
+
+	Ok(ResolveResult {
+		version: sha.clone(),
+		transitive_git_deps,
+		local_extractions,
+		source: VendorSource::GitTree {
+			repo_path,
+			commit_sha: sha,
+			subdir: git_source.subdir.clone(),
+		},
+	})
+}
+
+pub(super) fn extract(
+	repo_path: &Path,
+	commit_sha: &str,
+	subdir: &SubDir,
+	dest: &Path,
+) -> Result<(), Error> {
+	let repo = gix::open(repo_path)?;
+	let spec: &bstr::BStr = commit_sha.into();
+	let id = repo.rev_parse_single(spec)?;
+	let commit = repo.find_object(id)?.into_commit();
+	let tree = commit.tree()?;
+	extract_tree(&repo, &tree, subdir, dest)
+}
addedcrates/jrsonnet-pkg/src/install/github.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/src/install/github.rs
@@ -0,0 +1,190 @@
+#![allow(clippy::result_large_err)]
+
+use std::{
+	collections::HashSet,
+	fs::{self, File},
+	io::Write as _,
+	path::{Path, PathBuf},
+};
+
+use reqwest::{blocking::Response, header};
+use tracing::{debug, info};
+
+use super::{
+	Error, LocalExtraction, ResolveResult, Result, VendorSource,
+	accessor::{AccessorEntry, ZipFileAccessor},
+};
+use crate::{
+	install::{PKG_USER_AGENT, cache_dir},
+	jsonnet_bundler::{Dependency, GitSource, JsonnetFile, Source, SubDir},
+};
+
+fn is_sha(s: &str) -> bool {
+	s.len() == 40 && s.bytes().all(|b| b.is_ascii_hexdigit())
+}
+
+fn commit_cache_path(source: &GitSource, sha: &str) -> Result<PathBuf> {
+	Ok(cache_dir("github")?
+		.join(source.plain_repo_name())
+		.join(format!("{sha}.zip")))
+}
+
+fn resolve_sha(source: &GitSource, version: &str) -> Result<String> {
+	let url = format!(
+		"https://api.github.com/repos/{}/commits/{}",
+		source.plain_repo_name(),
+		version
+	);
+	let response = reqwest::blocking::Client::new()
+		.get(&url)
+		.header(header::ACCEPT, "application/vnd.github.sha")
+		.header(header::USER_AGENT, PKG_USER_AGENT)
+		.send()
+		.and_then(Response::error_for_status)?;
+	let sha = response.text()?;
+	Ok(sha.trim().to_owned())
+}
+
+fn fetch_zip(source: &GitSource, sha: &str) -> Result<ZipFileAccessor> {
+	let cached = commit_cache_path(source, sha)?;
+	if cached.exists() {
+		debug!("using cached archive {}", cached.display());
+		return Ok(ZipFileAccessor::new_prefixed(
+			File::open(&cached).map_err(|e| Error::Io(cached.clone(), e))?,
+		)?);
+	}
+
+	let url = format!(
+		"https://github.com/{}/archive/{}.zip",
+		source.plain_repo_name(),
+		sha
+	);
+	info!("downloading {url}");
+
+	let bytes = reqwest::blocking::Client::new()
+		.get(&url)
+		.header(header::USER_AGENT, PKG_USER_AGENT)
+		.send()
+		.and_then(Response::error_for_status)?
+		.bytes()?;
+
+	if let Some(parent) = cached.parent() {
+		fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_owned(), e))?;
+	}
+	let mut downloaded = File::create_new(&cached).map_err(|e| Error::Io(cached.clone(), e))?;
+	downloaded
+		.write_all(&bytes)
+		.map_err(|e| Error::Io(cached.clone(), e))?;
+
+	Ok(ZipFileAccessor::new_prefixed(downloaded)?)
+}
+
+fn open_cached_zip(zip_path: &Path) -> Result<ZipFileAccessor> {
+	Ok(ZipFileAccessor::new_prefixed(
+		File::open(zip_path).map_err(|e| Error::Io(zip_path.to_owned(), e))?,
+	)?)
+}
+
+fn extract_subdir(archive: &ZipFileAccessor, subdir: &SubDir, dest: &Path) -> Result<()> {
+	archive.iter(subdir, &mut |name, entry| {
+		let target = dest.join(name);
+		match entry {
+			AccessorEntry::Dir => {
+				fs::create_dir_all(&target).map_err(|e| Error::Io(target, e))?;
+			}
+			AccessorEntry::File(data) => {
+				if let Some(parent) = target.parent() {
+					fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_owned(), e))?;
+				}
+				fs::write(&target, &data).map_err(|e| Error::Io(target, e))?;
+			}
+		}
+		Ok(())
+	})
+}
+
+fn collect_archive_deps(
+	archive: &ZipFileAccessor,
+	dir: &SubDir,
+	git_deps: &mut Vec<Dependency>,
+	local_extractions: &mut Vec<LocalExtraction>,
+	visited: &mut HashSet<SubDir>,
+) -> Result<()> {
+	if !visited.insert(dir.clone()) {
+		return Ok(());
+	}
+
+	let manifest_path = dir
+		.join("jsonnetfile.json")
+		.expect("appending a literal filename keeps it within parent");
+
+	let Some(data) = archive.read(&manifest_path)? else {
+		return Ok(());
+	};
+	let Ok(manifest) = serde_json::from_slice::<JsonnetFile>(&data) else {
+		return Ok(());
+	};
+
+	for dep in manifest.dependencies {
+		match &dep.source {
+			Source::Git(_) => git_deps.push(dep),
+			Source::Local(local) => {
+				let Ok(child_dir) = local.resolve_under(dir) else {
+					tracing::info!("local source {local} escapes its package; skipping");
+					continue;
+				};
+				let name = child_dir
+					.file_name()
+					.map_or_else(|| local.to_string(), str::to_owned);
+				local_extractions.push(LocalExtraction {
+					tree_path: child_dir.clone(),
+					name,
+				});
+				collect_archive_deps(archive, &child_dir, git_deps, local_extractions, visited)?;
+			}
+		}
+	}
+	Ok(())
+}
+
+pub(super) fn resolve(source: &GitSource, version: Option<&str>) -> Result<ResolveResult> {
+	let version_str = version.unwrap_or("HEAD");
+	let sha = if is_sha(version_str) {
+		version_str.to_owned()
+	} else {
+		let resolved = resolve_sha(source, version_str)?;
+		info!("resolved {version_str} to {resolved}");
+		resolved
+	};
+
+	let archive = fetch_zip(source, &sha)?;
+
+	let mut transitive_git_deps = Vec::new();
+	let mut local_extractions = Vec::new();
+	let mut visited = HashSet::new();
+	collect_archive_deps(
+		&archive,
+		&source.subdir,
+		&mut transitive_git_deps,
+		&mut local_extractions,
+		&mut visited,
+	)?;
+
+	let zip_path = commit_cache_path(source, &sha)?;
+
+	Ok(ResolveResult {
+		version: sha.clone(),
+		transitive_git_deps,
+		local_extractions,
+		source: VendorSource::GithubZip {
+			zip_path,
+			commit_sha: sha,
+			subdir: source.subdir.clone(),
+		},
+	})
+}
+
+pub(super) fn extract(zip_path: &Path, subdir: &SubDir, dest: &Path) -> Result<()> {
+	let archive = open_cached_zip(zip_path)?;
+	extract_subdir(&archive, subdir, dest)
+}
addedcrates/jrsonnet-pkg/src/install/mod.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/src/install/mod.rs
@@ -0,0 +1,406 @@
+#![allow(clippy::result_large_err)]
+
+pub mod accessor;
+mod git;
+mod github;
+
+use std::{
+	collections::{BTreeMap, HashSet},
+	fs,
+	path::{Path, PathBuf},
+	result,
+};
+
+use camino::Utf8PathBuf;
+use tracing::info;
+
+use crate::jsonnet_bundler::{Dependency, GitScheme, GitSource, JsonnetFile, Source, SubDir};
+
+pub const PKG_USER_AGENT: &str = "jrsonnet-pkg (https://delta.rocks/jrsonnet)";
+
+pub fn cache_dir(subdir: &str) -> Result<std::path::PathBuf> {
+	Ok(directories::ProjectDirs::from("rocks", "delta", "jrsonnet")
+		.ok_or(Error::XdgUnavailable)?
+		.cache_dir()
+		.join(subdir))
+}
+
+pub(crate) struct LocalExtraction {
+	/// Path inside the parent repo's tree where this local source lives.
+	pub tree_path: SubDir,
+	pub name: String,
+}
+
+pub(crate) struct ResolveResult {
+	pub version: String,
+	pub transitive_git_deps: Vec<Dependency>,
+	pub local_extractions: Vec<LocalExtraction>,
+	pub source: VendorSource,
+}
+
+const VERSION_FILE: &str = ".version";
+
+/// How to populate a vendor path.
+pub enum VendorSource {
+	GitTree {
+		repo_path: PathBuf,
+		commit_sha: String,
+		subdir: SubDir,
+	},
+	GithubZip {
+		zip_path: PathBuf,
+		commit_sha: String,
+		subdir: SubDir,
+	},
+	Symlink(Utf8PathBuf),
+}
+
+impl VendorSource {
+	fn with_subdir(&self, new_subdir: SubDir) -> Self {
+		match self {
+			VendorSource::GitTree {
+				repo_path,
+				commit_sha,
+				..
+			} => VendorSource::GitTree {
+				repo_path: repo_path.clone(),
+				commit_sha: commit_sha.clone(),
+				subdir: new_subdir,
+			},
+			VendorSource::GithubZip {
+				zip_path,
+				commit_sha,
+				..
+			} => VendorSource::GithubZip {
+				zip_path: zip_path.clone(),
+				commit_sha: commit_sha.clone(),
+				subdir: new_subdir,
+			},
+			VendorSource::Symlink(target) => VendorSource::Symlink(target.clone()),
+		}
+	}
+}
+
+pub struct InstallPlan {
+	pub lock: JsonnetFile,
+	/// vendor-relative path -> how to obtain it.
+	pub entries: BTreeMap<Utf8PathBuf, VendorSource>,
+}
+
+pub fn install(
+	manifest: &JsonnetFile,
+	lock: Option<&JsonnetFile>,
+	vendor_dir: &Path,
+	dry_run: bool,
+) -> Result<JsonnetFile, Error> {
+	let plan = resolve(manifest, lock)?;
+	execute(&plan, vendor_dir, dry_run)?;
+	Ok(plan.lock)
+}
+
+pub fn resolve(manifest: &JsonnetFile, lock: Option<&JsonnetFile>) -> Result<InstallPlan, Error> {
+	let mut plan = InstallPlan {
+		lock: JsonnetFile {
+			version: manifest.version,
+			dependencies: Vec::new(),
+			legacy_imports: manifest.legacy_imports,
+		},
+		entries: BTreeMap::new(),
+	};
+	let mut installed = HashSet::new();
+
+	resolve_deps(
+		&manifest.dependencies,
+		lock,
+		manifest.legacy_imports,
+		&mut plan,
+		&mut installed,
+	)?;
+
+	Ok(plan)
+}
+
+fn is_up_to_date(dest: &Path, version: &str) -> bool {
+	fs::read_to_string(dest.join(VERSION_FILE)).is_ok_and(|v| v.trim() == version)
+}
+
+fn write_version(dest: &Path, version: &str) -> Result<(), Error> {
+	fs::write(dest.join(VERSION_FILE), format!("{version}\n"))
+		.map_err(|e| Error::Io(dest.join(VERSION_FILE), e))
+}
+
+pub fn execute(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {
+	if !dry_run {
+		for (path, source) in &plan.entries {
+			let dest = vendor_dir.join(path);
+			match source {
+				VendorSource::GitTree {
+					repo_path,
+					commit_sha,
+					subdir,
+				} => {
+					if is_up_to_date(&dest, commit_sha) {
+						continue;
+					}
+					info!("extract {path}");
+					if dest.exists() {
+						fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
+					}
+					fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
+					git::extract(repo_path, commit_sha, subdir, &dest)?;
+					write_version(&dest, commit_sha)?;
+				}
+				VendorSource::GithubZip {
+					zip_path,
+					commit_sha,
+					subdir,
+				} => {
+					if is_up_to_date(&dest, commit_sha) {
+						continue;
+					}
+					info!("extract {path}");
+					if dest.exists() {
+						fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
+					}
+					fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
+					github::extract(zip_path, subdir, &dest)?;
+					write_version(&dest, commit_sha)?;
+				}
+				VendorSource::Symlink(_) => {}
+			}
+		}
+		for (path, source) in &plan.entries {
+			if let VendorSource::Symlink(target) = source {
+				let dest = vendor_dir.join(path);
+				if dest
+					.symlink_metadata()
+					.is_ok_and(|m| m.file_type().is_symlink())
+				{
+					if fs::read_link(&dest).is_ok_and(|t| t == target.as_std_path()) {
+						continue;
+					}
+					fs::remove_file(&dest).map_err(|e| Error::Io(dest.clone(), e))?;
+				}
+				info!("symlink {path} -> {target}");
+				std::os::unix::fs::symlink(target.as_std_path(), &dest)
+					.map_err(|e| Error::Io(dest.clone(), e))?;
+			}
+		}
+	}
+	prune(plan, vendor_dir, dry_run)?;
+	Ok(())
+}
+
+fn prune(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {
+	if !vendor_dir.is_dir() {
+		return Ok(());
+	}
+	prune_recursive(plan, vendor_dir, vendor_dir, dry_run)
+}
+
+fn prune_recursive(
+	plan: &InstallPlan,
+	vendor_dir: &Path,
+	dir: &Path,
+	dry_run: bool,
+) -> Result<(), Error> {
+	let entries = fs::read_dir(dir).map_err(|e| Error::Io(dir.to_owned(), e))?;
+	for entry in entries {
+		let entry = entry.map_err(|e| Error::Io(dir.to_owned(), e))?;
+		let path = entry.path();
+		let rel = path
+			.strip_prefix(vendor_dir)
+			.expect("path is under vendor_dir");
+		let Ok(rel) = Utf8PathBuf::try_from(rel.to_owned()) else {
+			info!("prune (non-utf8) {}", rel.display());
+			continue;
+		};
+
+		if plan.entries.contains_key(&rel) {
+			continue;
+		}
+
+		let ft = entry.file_type().map_err(|e| Error::Io(path.clone(), e))?;
+		if ft.is_symlink() {
+			info!("prune {rel}");
+			if !dry_run {
+				fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;
+			}
+		} else if ft.is_dir() {
+			let prefix: Utf8PathBuf = format!("{rel}/").into();
+			let has_descendants = plan
+				.entries
+				.range(prefix.clone()..)
+				.next()
+				.is_some_and(|(k, _)| k.starts_with(&prefix));
+			if has_descendants {
+				prune_recursive(plan, vendor_dir, &path, dry_run)?;
+			} else {
+				info!("prune {rel}");
+				if !dry_run {
+					fs::remove_dir_all(&path).map_err(|e| Error::Io(path, e))?;
+				}
+			}
+		} else {
+			info!("prune {rel}");
+			if !dry_run {
+				fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;
+			}
+		}
+	}
+
+	if !dry_run
+		&& dir != vendor_dir
+		&& let Ok(mut entries) = fs::read_dir(dir)
+		&& entries.next().is_none()
+	{
+		let _ = fs::remove_dir(dir);
+	}
+
+	Ok(())
+}
+
+fn resolve_one(git_source: &GitSource, version: Option<&str>) -> Result<ResolveResult, Error> {
+	if git_source.host == "github.com" && git_source.scheme == GitScheme::Https {
+		match github::resolve(git_source, version) {
+			Ok(result) => return Ok(result),
+			Err(e) => {
+				info!("github archive failed ({e}), falling back to git");
+			}
+		}
+	}
+	git::resolve(git_source, version)
+}
+
+fn locked_version<'a>(dep: &Dependency, lock: Option<&'a JsonnetFile>) -> Option<&'a str> {
+	let lock = lock?;
+	let key = dep.canonical_name();
+	lock.dependencies
+		.iter()
+		.find(|d| d.canonical_name() == key)
+		.and_then(|d| d.version.as_deref())
+}
+
+fn resolve_deps(
+	deps: &[Dependency],
+	lock: Option<&JsonnetFile>,
+	legacy_imports: bool,
+	plan: &mut InstallPlan,
+	installed: &mut HashSet<Utf8PathBuf>,
+) -> Result<(), Error> {
+	for dep in deps {
+		let Source::Git(git_source) = &dep.source else {
+			continue;
+		};
+
+		let canonical = dep.canonical_name();
+		if !installed.insert(canonical.clone()) {
+			continue;
+		}
+
+		let version = locked_version(dep, lock).or(dep.version.as_deref());
+
+		info!(
+			"resolving {canonical} (version: {})",
+			version.unwrap_or("<TBD>")
+		);
+
+		let result = resolve_one(git_source, version)?;
+
+		plan.lock.dependencies.push(Dependency {
+			source: dep.source.clone(),
+			version: Some(result.version),
+			sum: dep.sum.clone(),
+			name: dep.name.clone(),
+			single: dep.single,
+		});
+
+		let mut repo_base = Utf8PathBuf::from(git_source.host.as_str());
+		repo_base.push(git_source.plain_repo_name());
+
+		// Legacy symlink for the dep. Skipped if `legacyImports: false`, unless
+		// the user explicitly set `dep.name` (which is always honored).
+		if legacy_imports || dep.name.is_some() {
+			let legacy = Utf8PathBuf::from(dep.legacy_link_name());
+			if legacy != canonical {
+				plan.entries
+					.insert(legacy, VendorSource::Symlink(canonical.clone()));
+			}
+		}
+
+		for extraction in &result.local_extractions {
+			let extraction_canonical = repo_base.join(&extraction.tree_path);
+			plan.entries.insert(
+				extraction_canonical.clone(),
+				result.source.with_subdir(extraction.tree_path.clone()),
+			);
+			if legacy_imports {
+				let extraction_name = Utf8PathBuf::from(&extraction.name);
+				if extraction_name != extraction_canonical {
+					plan.entries
+						.insert(extraction_name, VendorSource::Symlink(extraction_canonical));
+				}
+			}
+		}
+
+		// Main entry (after local extractions used with_subdir)
+		plan.entries.insert(canonical, result.source);
+
+		resolve_deps(
+			&result.transitive_git_deps,
+			lock,
+			legacy_imports,
+			plan,
+			installed,
+		)?;
+	}
+
+	Ok(())
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+	#[error("io error for {0}: {1}")]
+	Io(PathBuf, std::io::Error),
+	#[error("failed to discover xdg directories")]
+	XdgUnavailable,
+	#[error("git clone failed: {0}")]
+	GitClone(#[from] gix::clone::Error),
+	#[error(transparent)]
+	GitRemote(#[from] gix::remote::init::Error),
+	#[error(transparent)]
+	GitConnect(#[from] gix::remote::connect::Error),
+	#[error(transparent)]
+	GitFetchPrepare(#[from] gix::remote::fetch::prepare::Error),
+	#[error(transparent)]
+	GitRemoteFetch(#[from] gix::remote::fetch::Error),
+	#[error(transparent)]
+	GitCloneFetch(#[from] gix::clone::fetch::Error),
+	#[error(transparent)]
+	GitFindObject(#[from] gix::object::find::existing::Error),
+	#[error(transparent)]
+	GitTraverse(#[from] gix::traverse::tree::breadthfirst::Error),
+	#[error(transparent)]
+	GitHead(#[from] gix::reference::head_id::Error),
+	#[error(transparent)]
+	GitCommit(#[from] gix::object::commit::Error),
+	#[error(transparent)]
+	GitRevparse(#[from] gix::revision::spec::parse::single::Error),
+	#[error(transparent)]
+	GitRefspec(#[from] gix::refspec::parse::Error),
+	#[error(transparent)]
+	GitPeel(#[from] gix::reference::peel::Error),
+	#[error(transparent)]
+	GitOpen(#[from] gix::open::Error),
+	#[error("http error: {0}")]
+	Http(#[from] reqwest::Error),
+	#[error("zip error: {0}")]
+	Zip(Box<zip::result::ZipError>),
+	#[error(transparent)]
+	Accessor(#[from] accessor::Error),
+	#[error("unknown subdir: {0}")]
+	SubdirNotFound(String),
+	#[error("invalid path in tree: {0}")]
+	InvalidPath(String),
+}
+pub(crate) type Result<T, E = Error> = result::Result<T, E>;
addedcrates/jrsonnet-pkg/src/jsonnet_bundler.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/src/jsonnet_bundler.rs
@@ -0,0 +1,883 @@
+use std::{fmt, path::Path, str::FromStr};
+
+use camino::{Utf8Component, Utf8Path, Utf8PathBuf};
+use serde::{Deserialize, Serialize, de};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct JsonnetFile {
+	pub version: u32,
+	#[serde(default)]
+	pub dependencies: Vec<Dependency>,
+	#[serde(default = "legacy_imports_default", rename = "legacyImports")]
+	pub legacy_imports: bool,
+}
+
+fn legacy_imports_default() -> bool {
+	true
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Dependency {
+	pub source: Source,
+	#[serde(default, skip_serializing_if = "Option::is_none")]
+	pub version: Option<String>,
+	#[serde(default, skip_serializing_if = "Option::is_none")]
+	pub sum: Option<String>,
+	#[serde(default, skip_serializing_if = "Option::is_none")]
+	pub name: Option<String>,
+	#[serde(default, skip_serializing_if = "is_false")]
+	pub single: bool,
+}
+
+#[allow(clippy::trivially_copy_pass_by_ref, reason = "serde")]
+fn is_false(v: &bool) -> bool {
+	!v
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Source {
+	Git(GitSource),
+	Local(LocalSource),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum GitScheme {
+	Https,
+	Ssh,
+}
+
+/// Wrapper over `Utf8PathBuf`, ensuring it can't escape to either an absolute
+/// path or a parent directory.
+#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct SubDir(Utf8PathBuf);
+
+#[derive(Debug, thiserror::Error)]
+#[error("subdir attempted to escape")]
+pub struct SubDirEscapeError;
+
+impl FromStr for SubDir {
+	type Err = SubDirEscapeError;
+	fn from_str(s: &str) -> Result<Self, Self::Err> {
+		Self::try_from(Utf8PathBuf::from(s))
+	}
+}
+impl TryFrom<Utf8PathBuf> for SubDir {
+	type Error = SubDirEscapeError;
+
+	fn try_from(buf: Utf8PathBuf) -> Result<Self, Self::Error> {
+		for ele in buf.components() {
+			match ele {
+				Utf8Component::Prefix(_) | Utf8Component::RootDir | Utf8Component::ParentDir => {
+					return Err(SubDirEscapeError);
+				}
+				Utf8Component::CurDir | Utf8Component::Normal(_) => {}
+			}
+		}
+		Ok(Self(buf))
+	}
+}
+
+impl SubDir {
+	pub fn empty() -> Self {
+		Self(Utf8PathBuf::new())
+	}
+	pub fn as_str(&self) -> &str {
+		self.0.as_str()
+	}
+	pub fn as_path(&self) -> &Utf8Path {
+		&self.0
+	}
+	pub fn into_inner(self) -> Utf8PathBuf {
+		self.0
+	}
+	pub fn join(&self, other: impl AsRef<Utf8Path>) -> Result<SubDir, SubDirEscapeError> {
+		SubDir::try_from(self.0.join(other))
+	}
+	pub fn strip_prefix(&self, prefix: &SubDir) -> Option<SubDir> {
+		Some(
+			SubDir::try_from(self.0.strip_prefix(&prefix.0).ok()?.to_owned())
+				.expect("stripping would not result in escape"),
+		)
+	}
+	pub fn is_empty(&self) -> bool {
+		self.0.as_str().is_empty()
+	}
+	pub fn file_name(&self) -> Option<&str> {
+		self.0.file_name()
+	}
+	/// Strip a trailing `.git` extension, if any.
+	#[must_use]
+	pub fn without_git_suffix(&self) -> SubDir {
+		let mut p = self.0.clone();
+		if p.extension() == Some("git") {
+			p.set_extension("");
+		}
+		SubDir(p)
+	}
+}
+impl AsRef<Utf8Path> for SubDir {
+	fn as_ref(&self) -> &Utf8Path {
+		&self.0
+	}
+}
+impl AsRef<Path> for SubDir {
+	fn as_ref(&self) -> &Path {
+		self.0.as_ref()
+	}
+}
+impl AsRef<str> for SubDir {
+	fn as_ref(&self) -> &str {
+		self.0.as_str()
+	}
+}
+impl fmt::Display for SubDir {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		write!(f, "{}", self.0)
+	}
+}
+impl PartialEq<str> for SubDir {
+	fn eq(&self, other: &str) -> bool {
+		self.0.as_str() == other
+	}
+}
+impl PartialEq<&str> for SubDir {
+	fn eq(&self, other: &&str) -> bool {
+		self.0.as_str() == *other
+	}
+}
+
+/// Wrapper over `String`, guaranteeing the value is a valid host: only ASCII
+/// alphanumerics, dashes and dots, with at least one segment.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct Hostname(String);
+
+#[derive(Debug, thiserror::Error)]
+#[error("invalid hostname")]
+pub struct InvalidHostnameError;
+
+impl FromStr for Hostname {
+	type Err = InvalidHostnameError;
+
+	fn from_str(s: &str) -> Result<Self, Self::Err> {
+		if s.is_empty() || s == "." || s == ".." {
+			return Err(InvalidHostnameError);
+		}
+		for seg in s.split('.') {
+			if seg.is_empty() {
+				return Err(InvalidHostnameError);
+			}
+			if !seg.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
+				return Err(InvalidHostnameError);
+			}
+		}
+		Ok(Self(s.to_owned()))
+	}
+}
+
+impl Hostname {
+	pub fn as_str(&self) -> &str {
+		&self.0
+	}
+}
+impl AsRef<str> for Hostname {
+	fn as_ref(&self) -> &str {
+		&self.0
+	}
+}
+impl AsRef<Path> for Hostname {
+	fn as_ref(&self) -> &Path {
+		self.0.as_ref()
+	}
+}
+impl AsRef<Utf8Path> for Hostname {
+	fn as_ref(&self) -> &Utf8Path {
+		self.0.as_str().into()
+	}
+}
+impl fmt::Display for Hostname {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		f.write_str(&self.0)
+	}
+}
+impl PartialEq<str> for Hostname {
+	fn eq(&self, other: &str) -> bool {
+		self.0 == other
+	}
+}
+impl PartialEq<&str> for Hostname {
+	fn eq(&self, other: &&str) -> bool {
+		self.0 == *other
+	}
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GitSource {
+	pub scheme: GitScheme,
+	pub host: Hostname,
+	/// Repo path relative to host: `user/repo[.git]` (or with subgroups).
+	pub repo: SubDir,
+	/// Subdirectory within the repo. Empty means the repo root.
+	pub subdir: SubDir,
+}
+
+/// A relative path that may climb out of its package via `..` parts, but only
+/// at the head - once you go down (`SubDir` portion) you can't go back up.
+///
+/// The total upward count is bounded only at resolution time, against the
+/// containing package's depth.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct LocalSource {
+	pub ups: usize,
+	pub dir: SubDir,
+}
+
+impl FromStr for LocalSource {
+	// Technically incorrect, as it only rejects mid-path ../'s...
+	type Err = SubDirEscapeError;
+
+	fn from_str(s: &str) -> Result<Self, Self::Err> {
+		let mut ups = 0usize;
+		let mut rest = s;
+		loop {
+			if let Some(r) = rest.strip_prefix("./") {
+				rest = r;
+			} else if rest == "." {
+				rest = "";
+				break;
+			} else if let Some(r) = rest.strip_prefix("../") {
+				ups = ups.checked_add(1).expect("can't be longer than s length");
+				rest = r;
+			} else if rest == ".." {
+				ups = ups.checked_add(1).expect("can't be longer than s length");
+				rest = "";
+				break;
+			} else {
+				break;
+			}
+		}
+		Ok(Self {
+			ups,
+			dir: SubDir::from_str(rest)?,
+		})
+	}
+}
+
+impl fmt::Display for LocalSource {
+	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+		let mut out = String::with_capacity(self.ups * 3 + self.dir.as_str().len());
+		for _ in 0..self.ups {
+			out.push_str("../");
+		}
+		out.push_str(self.dir.as_str());
+		if out.is_empty() {
+			out.push('.');
+		} else if out.ends_with('/') {
+			out.pop();
+		}
+		// TODO: I didn't finish
+		f.write_str(&out)
+	}
+}
+
+impl LocalSource {
+	pub fn resolve_under(&self, parent: &SubDir) -> Result<SubDir, SubDirEscapeError> {
+		let mut comps: Vec<&str> = parent.as_path().components().map(|c| c.as_str()).collect();
+		if self.ups > comps.len() {
+			return Err(SubDirEscapeError);
+		}
+		comps.truncate(comps.len() - self.ups);
+		let mut buf = Utf8PathBuf::from_iter(comps);
+		buf.push(self.dir.as_path());
+		SubDir::try_from(buf)
+	}
+}
+
+impl Serialize for LocalSource {
+	fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
+		#[derive(Serialize)]
+		struct JsonLocal<'a> {
+			directory: &'a str,
+		}
+		let rendered = self.to_string();
+		JsonLocal {
+			directory: &rendered,
+		}
+		.serialize(ser)
+	}
+}
+
+impl<'de> Deserialize<'de> for LocalSource {
+	fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
+		#[derive(Deserialize)]
+		struct JsonLocal {
+			directory: String,
+		}
+		let j = JsonLocal::deserialize(de)?;
+		LocalSource::from_str(&j.directory)
+			.map_err(|e| de::Error::custom(format!("invalid local path {:?}: {e}", j.directory)))
+	}
+}
+
+impl GitSource {
+	/// Repo path with the trailing `.git` (if any) stripped.
+	pub fn plain_repo_name(&self) -> SubDir {
+		self.repo.without_git_suffix()
+	}
+
+	/// Canonical install path: `host/user/repo[/subdir]`.
+	pub fn name(&self) -> SubDir {
+		let mut p = Utf8PathBuf::from(self.host.as_str());
+		p.push(self.plain_repo_name());
+		if !self.subdir.is_empty() {
+			p.push(self.subdir.as_path());
+		}
+		SubDir::try_from(p).expect("host + subdirs is a valid SubDir")
+	}
+
+	/// Last path component of `repo[/subdir]`, used as the legacy symlink name.
+	pub fn legacy_name(&self) -> String {
+		self.name()
+			.file_name()
+			.expect("name has at least one component")
+			.to_owned()
+	}
+
+	/// Git remote URL for cloning.
+	pub fn remote(&self) -> String {
+		let host = self.host.as_str();
+		let repo = self.repo.as_str();
+		match self.scheme {
+			GitScheme::Ssh => format!("ssh://git@{host}/{repo}"),
+			GitScheme::Https => format!("https://{host}/{repo}"),
+		}
+	}
+
+	/// Parse a URI like `github.com/user/repo/subdir@version` into a
+	/// `Dependency`.
+	pub fn parse(uri: &str) -> Option<Dependency> {
+		git_uri::parse(uri).ok()
+	}
+}
+
+peg::parser! {
+	grammar git_uri() for str {
+		rule host_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '-']+;
+		rule host() -> Hostname
+			= s:$(host_segment()++".")
+			{ Hostname::from_str(s).expect("grammar restricted to valid host chars") }
+
+		// User/repo path segments. `~` is allowed for Bitbucket personal repos.
+		rule path_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '~']+;
+		// Subdir segments allow dots (e.g. `ksonnet.beta.3`).
+		rule subdir_segment() = ['a'..='z' | 'A'..='Z' | '0'..='9' | '_' | '-' | '.']+;
+
+		// `user[/group...]/repo.git`
+		rule repo_dotgit() -> SubDir
+			= s:$(path_segment()++"/" ".git")
+			{ SubDir::from_str(s).expect("grammar restricted to subpath chars") }
+		// `user/repo` (exactly two segments, no `.git`)
+		rule repo_simple() -> SubDir
+			= s:$(path_segment() "/" path_segment())
+			{ SubDir::from_str(s).expect("grammar restricted to subpath chars") }
+
+		// Subdir starts with `/`. May be empty.
+		rule subdir() -> SubDir
+			= "/" s:$(subdir_segment() ** "/") "/"?
+			{ SubDir::from_str(s).expect("grammar restricted to subdir chars") }
+			/ { SubDir::empty() }
+
+		rule version() -> &'input str
+			= "@" v:$([_]+) { v }
+
+
+		// git@host:path.git[/subdir][@version]  (SCP style)
+		rule scp_uri() -> Dependency
+			= "git@" h:host() ":" repo:repo_dotgit() subdir:subdir()
+			  v:version()?
+		{
+			make_dep(GitScheme::Ssh, h, repo, subdir, v)
+		}
+
+		// ssh://git@host/path.git[/subdir][@version]
+		rule ssh_uri() -> Dependency
+			= "ssh://git@" h:host() "/" repo:repo_dotgit() subdir:subdir()
+			  v:version()?
+		{
+			make_dep(GitScheme::Ssh, h, repo, subdir, v)
+		}
+
+		// [https://]host/path.git[/subdir][@version]
+		rule https_dotgit() -> Dependency
+			= "https://"? h:host() "/" repo:repo_dotgit() subdir:subdir()
+			  v:version()?
+		{
+			make_dep(GitScheme::Https, h, repo, subdir, v)
+		}
+
+		// [https://]host/user/repo[/subdir[/...]][@version]
+		rule https_simple() -> Dependency
+			= "https://"? h:host() "/" repo:repo_simple() subdir:subdir()
+			  v:version()?
+		{
+			make_dep(GitScheme::Https, h, repo, subdir, v)
+		}
+
+		pub rule parse() -> Dependency
+			= ssh_uri() / scp_uri() / https_dotgit() / https_simple()
+	}
+}
+
+fn make_dep(
+	scheme: GitScheme,
+	host: Hostname,
+	repo: SubDir,
+	subdir: SubDir,
+	version: Option<&str>,
+) -> Dependency {
+	Dependency {
+		source: Source::Git(GitSource {
+			scheme,
+			host,
+			repo,
+			subdir,
+		}),
+		version: version.map(str::to_owned),
+		sum: None,
+		name: None,
+		single: false,
+	}
+}
+
+impl Dependency {
+	/// Canonical install path for deduplication and vendor extraction.
+	pub fn canonical_name(&self) -> Utf8PathBuf {
+		match &self.source {
+			Source::Git(git) => git.name().into_inner(),
+			Source::Local(local) => Utf8PathBuf::from(local.to_string()),
+		}
+	}
+
+	/// Legacy symlink name: `dep.name` override, or last path component.
+	pub fn legacy_link_name(&self) -> String {
+		if let Some(name) = &self.name {
+			return name.clone();
+		}
+		match &self.source {
+			Source::Git(git) => git.legacy_name(),
+			Source::Local(local) => local
+				.dir
+				.file_name()
+				.map_or_else(|| local.to_string(), str::to_owned),
+		}
+	}
+}
+
+impl Serialize for GitSource {
+	fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+		#[derive(Serialize)]
+		struct JsonGit<'a> {
+			remote: String,
+			#[serde(skip_serializing_if = "str::is_empty")]
+			subdir: &'a str,
+		}
+		JsonGit {
+			remote: self.remote(),
+			subdir: self.subdir.as_str(),
+		}
+		.serialize(serializer)
+	}
+}
+
+impl<'de> Deserialize<'de> for GitSource {
+	fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+		#[derive(Deserialize)]
+		struct JsonGit {
+			remote: String,
+			#[serde(default)]
+			subdir: String,
+		}
+		let j = JsonGit::deserialize(deserializer)?;
+
+		let parsed = GitSource::parse(&j.remote)
+			.ok_or_else(|| de::Error::custom(format!("unable to parse git url {:?}", j.remote)))?;
+		let Source::Git(mut gs) = parsed.source else {
+			unreachable!()
+		};
+
+		if !j.subdir.is_empty() {
+			gs.subdir = SubDir::from_str(j.subdir.trim_start_matches('/'))
+				.map_err(|e| de::Error::custom(format!("invalid subdir {:?}: {e}", j.subdir)))?;
+		}
+
+		Ok(gs)
+	}
+}
+
+impl JsonnetFile {
+	pub fn load(path: &Path) -> Result<Self, Error> {
+		let data = std::fs::read(path).map_err(|e| Error::Io(path.to_owned(), e))?;
+		serde_json::from_slice(&data).map_err(Error::Json)
+	}
+}
+
+#[derive(Debug)]
+pub enum Error {
+	Io(std::path::PathBuf, std::io::Error),
+	Json(serde_json::Error),
+}
+impl std::fmt::Display for Error {
+	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+		match self {
+			Error::Io(path, e) => write!(f, "{}: {e}", path.display()),
+			Error::Json(e) => write!(f, "{e}"),
+		}
+	}
+}
+impl std::error::Error for Error {}
+
+#[cfg(test)]
+mod tests {
+	use super::*;
+
+	fn host(s: &str) -> Hostname {
+		Hostname::from_str(s).expect("test host")
+	}
+	fn sd(s: &str) -> SubDir {
+		SubDir::from_str(s).expect("test subdir")
+	}
+
+	#[test]
+	fn parse_basic() {
+		let input = r#"{
+			"version": 1,
+			"dependencies": [
+				{
+					"source": {
+						"git": {
+							"remote": "https://github.com/grafana/jsonnet-libs.git",
+							"subdir": "grafana-builder"
+						}
+					},
+					"version": "54865853ebc1f901964e25a2e7a0e4d2cb6b9648",
+					"sum": "ELsYwK+kGdzX1mee2Yy+/b2mdO4Y503BOCDkFzwmGbE="
+				}
+			],
+			"legacyImports": false
+		}"#;
+
+		let jf: JsonnetFile = serde_json::from_str(input).unwrap();
+		assert_eq!(jf.version, 1);
+		assert!(!jf.legacy_imports);
+		assert_eq!(jf.dependencies.len(), 1);
+
+		let dep = &jf.dependencies[0];
+		let Source::Git(git) = &dep.source else {
+			panic!("expected git source");
+		};
+		assert_eq!(git.host, "github.com");
+		assert_eq!(git.repo, "grafana/jsonnet-libs.git");
+		assert_eq!(git.subdir, "grafana-builder");
+		assert_eq!(
+			git.name(),
+			"github.com/grafana/jsonnet-libs/grafana-builder"
+		);
+		assert_eq!(git.legacy_name(), "grafana-builder");
+		assert_eq!(git.remote(), "https://github.com/grafana/jsonnet-libs.git");
+		assert_eq!(
+			dep.version.as_deref(),
+			Some("54865853ebc1f901964e25a2e7a0e4d2cb6b9648")
+		);
+	}
+
+	#[test]
+	fn parse_local_source() {
+		let input = r#"{
+			"version": 1,
+			"dependencies": [
+				{
+					"source": {
+						"local": { "directory": "../shared-lib" }
+					},
+					"version": ""
+				}
+			]
+		}"#;
+
+		let jf: JsonnetFile = serde_json::from_str(input).unwrap();
+		let dep = &jf.dependencies[0];
+		let Source::Local(local) = &dep.source else {
+			panic!("expected local source");
+		};
+		assert_eq!(local.ups, 1);
+		assert_eq!(local.dir, "shared-lib");
+		assert_eq!(local.to_string(), "../shared-lib");
+		assert!(jf.legacy_imports);
+	}
+
+	#[test]
+	fn parse_uri_github_slug() {
+		let dep = GitSource::parse("github.com/ksonnet/ksonnet-lib/ksonnet.beta.3").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.scheme, GitScheme::Https);
+		assert_eq!(gs.host, "github.com");
+		assert_eq!(gs.repo, "ksonnet/ksonnet-lib");
+		assert_eq!(gs.subdir, "ksonnet.beta.3");
+		assert_eq!(dep.version, None);
+		assert_eq!(gs.remote(), "https://github.com/ksonnet/ksonnet-lib");
+	}
+
+	#[test]
+	fn parse_uri_ssh() {
+		let dep = GitSource::parse("ssh://git@example.com/user/repo.git/foobar@v1").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.scheme, GitScheme::Ssh);
+		assert_eq!(gs.host, "example.com");
+		assert_eq!(gs.repo, "user/repo.git");
+		assert_eq!(gs.subdir, "foobar");
+		assert_eq!(dep.version.as_deref(), Some("v1"));
+		assert_eq!(gs.remote(), "ssh://git@example.com/user/repo.git");
+	}
+
+	#[test]
+	fn parse_uri_scp() {
+		let dep = GitSource::parse("git@my.host:user/repo.git/foobar@v1").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.scheme, GitScheme::Ssh);
+		assert_eq!(gs.host, "my.host");
+		assert_eq!(gs.subdir, "foobar");
+		assert_eq!(dep.version.as_deref(), Some("v1"));
+		assert_eq!(gs.remote(), "ssh://git@my.host/user/repo.git");
+	}
+
+	#[test]
+	fn parse_uri_https_explicit() {
+		let dep = GitSource::parse("https://example.com/foo/bar").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.scheme, GitScheme::Https);
+		assert_eq!(gs.host, "example.com");
+		assert_eq!(gs.repo, "foo/bar");
+		assert_eq!(gs.subdir, "");
+		assert_eq!(gs.remote(), "https://example.com/foo/bar");
+	}
+
+	#[test]
+	fn parse_uri_no_scheme() {
+		let dep = GitSource::parse("example.com/foo/bar").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.scheme, GitScheme::Https);
+		assert_eq!(gs.host, "example.com");
+		assert_eq!(gs.remote(), "https://example.com/foo/bar");
+	}
+
+	#[test]
+	fn parse_uri_path_and_version() {
+		let dep = GitSource::parse("example.com/foo/bar/baz@bat").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.repo, "foo/bar");
+		assert_eq!(gs.subdir, "baz");
+		assert_eq!(dep.version.as_deref(), Some("bat"));
+	}
+
+	#[test]
+	fn parse_uri_version_only() {
+		let dep = GitSource::parse("example.com/foo/bar@baz").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.repo, "foo/bar");
+		assert_eq!(gs.subdir, "");
+		assert_eq!(dep.version.as_deref(), Some("baz"));
+	}
+
+	#[test]
+	fn parse_uri_deep_path() {
+		let dep = GitSource::parse("example.com/foo/bar/baz/bat").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.repo, "foo/bar");
+		assert_eq!(gs.subdir, "baz/bat");
+	}
+
+	#[test]
+	fn parse_uri_subgroups() {
+		let dep = GitSource::parse("example.com/group/subgroup/repository.git").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.repo, "group/subgroup/repository.git");
+		assert_eq!(gs.plain_repo_name(), "group/subgroup/repository");
+		assert_eq!(gs.subdir, "");
+		assert_eq!(
+			gs.remote(),
+			"https://example.com/group/subgroup/repository.git"
+		);
+	}
+
+	#[test]
+	fn parse_uri_subgroup_subdir() {
+		let dep = GitSource::parse("example.com/group/subgroup/repository.git/subdir").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.plain_repo_name(), "group/subgroup/repository");
+		assert_eq!(gs.subdir, "subdir");
+	}
+
+	#[test]
+	fn parse_uri_bitbucket_personal() {
+		let dep = GitSource::parse("bitbucket.org/~user/repository.git").unwrap();
+		let Source::Git(gs) = &dep.source else {
+			panic!()
+		};
+		assert_eq!(gs.host, "bitbucket.org");
+		assert_eq!(gs.repo, "~user/repository.git");
+		assert_eq!(gs.remote(), "https://bitbucket.org/~user/repository.git");
+	}
+
+	#[test]
+	fn name_with_subdir() {
+		let gs = GitSource {
+			scheme: GitScheme::Https,
+			host: host("github.com"),
+			repo: sd("ksonnet/ksonnet-lib"),
+			subdir: sd("ksonnet.beta.3"),
+		};
+		assert_eq!(gs.name(), "github.com/ksonnet/ksonnet-lib/ksonnet.beta.3");
+		assert_eq!(gs.legacy_name(), "ksonnet.beta.3");
+	}
+
+	#[test]
+	fn name_without_subdir() {
+		let gs = GitSource {
+			scheme: GitScheme::Https,
+			host: host("github.com"),
+			repo: sd("user/repo"),
+			subdir: SubDir::empty(),
+		};
+		assert_eq!(gs.name(), "github.com/user/repo");
+		assert_eq!(gs.legacy_name(), "repo");
+	}
+
+	#[test]
+	fn defaults() {
+		let input = r#"{ "version": 1 }"#;
+		let jf: JsonnetFile = serde_json::from_str(input).unwrap();
+		assert!(jf.dependencies.is_empty());
+		assert!(jf.legacy_imports);
+	}
+
+	#[test]
+	fn roundtrip() {
+		let jf = JsonnetFile {
+			version: 1,
+			dependencies: vec![Dependency {
+				source: Source::Git(GitSource {
+					scheme: GitScheme::Https,
+					host: host("github.com"),
+					repo: sd("user/repo"),
+					subdir: sd("lib"),
+				}),
+				version: Some("main".into()),
+				sum: None,
+				name: None,
+				single: false,
+			}],
+			legacy_imports: false,
+		};
+		let json = serde_json::to_string_pretty(&jf).unwrap();
+		let parsed: JsonnetFile = serde_json::from_str(&json).unwrap();
+		assert_eq!(parsed.dependencies.len(), 1);
+		let Source::Git(gs) = &parsed.dependencies[0].source else {
+			panic!()
+		};
+		assert_eq!(gs.host, "github.com");
+		assert_eq!(gs.repo, "user/repo");
+		assert_eq!(gs.subdir, "lib");
+	}
+
+	#[test]
+	fn hostname_rejects_slash() {
+		assert!(Hostname::from_str("foo/bar").is_err());
+		assert!(Hostname::from_str("").is_err());
+		assert!(Hostname::from_str(".").is_err());
+		assert!(Hostname::from_str("..").is_err());
+		assert!(Hostname::from_str(".foo").is_err());
+		assert!(Hostname::from_str("foo.").is_err());
+		assert!(Hostname::from_str("foo..bar").is_err());
+		assert!(Hostname::from_str("foo bar").is_err());
+		assert!(Hostname::from_str("foo.bar").is_ok());
+	}
+
+	#[test]
+	fn subdir_rejects_escape() {
+		assert!(SubDir::from_str("../foo").is_err());
+		assert!(SubDir::from_str("/foo").is_err());
+		assert!(SubDir::from_str("foo/../bar").is_err());
+		assert!(SubDir::from_str("foo/bar").is_ok());
+		assert!(SubDir::from_str("").is_ok());
+	}
+
+	#[test]
+	fn local_source_parse() {
+		let l = LocalSource::from_str("../shared-lib").unwrap();
+		assert_eq!(l.ups, 1);
+		assert_eq!(l.dir, "shared-lib");
+
+		let l = LocalSource::from_str("../../foo/bar").unwrap();
+		assert_eq!(l.ups, 2);
+		assert_eq!(l.dir, "foo/bar");
+
+		let l = LocalSource::from_str("./foo").unwrap();
+		assert_eq!(l.ups, 0);
+		assert_eq!(l.dir, "foo");
+
+		let l = LocalSource::from_str(".").unwrap();
+		assert_eq!(l.ups, 0);
+		assert!(l.dir.is_empty());
+
+		let l = LocalSource::from_str("..").unwrap();
+		assert_eq!(l.ups, 1);
+		assert!(l.dir.is_empty());
+
+		// Mid-path `..` is rejected.
+		assert!(LocalSource::from_str("foo/../bar").is_err());
+		// Absolute path is rejected.
+		assert!(LocalSource::from_str("/foo").is_err());
+	}
+
+	#[test]
+	fn local_source_render_roundtrip() {
+		for s in ["../shared-lib", "../../foo/bar", "foo", "."] {
+			assert_eq!(LocalSource::from_str(s).unwrap().to_string(), s);
+		}
+	}
+
+	#[test]
+	fn local_source_resolve_under() {
+		// `../foo` from `pkg/sub` lands at `pkg/foo`.
+		let l = LocalSource::from_str("../foo").unwrap();
+		assert_eq!(l.resolve_under(&sd("pkg/sub")).unwrap(), "pkg/foo");
+
+		// Plain `foo` from `pkg/sub` lands at `pkg/sub/foo`.
+		let l = LocalSource::from_str("foo").unwrap();
+		assert_eq!(l.resolve_under(&sd("pkg/sub")).unwrap(), "pkg/sub/foo");
+
+		// Too many `..` escapes the parent.
+		let l = LocalSource::from_str("../../../foo").unwrap();
+		assert!(l.resolve_under(&sd("pkg")).is_err());
+	}
+}
addedcrates/jrsonnet-pkg/src/lib.rsdiffbeforeafterboth
--- /dev/null
+++ b/crates/jrsonnet-pkg/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod install;
+pub mod jsonnet_bundler;