1#![allow(clippy::result_large_err)]23pub mod accessor;4mod git;5mod github;67use std::{8 collections::{BTreeMap, HashSet},9 fs,10 path::{Path, PathBuf},11 result,12};1314use camino::Utf8PathBuf;15use tracing::info;1617use crate::jsonnet_bundler::{Dependency, GitScheme, GitSource, JsonnetFile, Source, SubDir};1819pub const PKG_USER_AGENT: &str = "jrsonnet-pkg (https://delta.rocks/jrsonnet)";2021pub fn cache_dir(subdir: &str) -> Result<std::path::PathBuf> {22 Ok(directories::ProjectDirs::from("rocks", "delta", "jrsonnet")23 .ok_or(Error::XdgUnavailable)?24 .cache_dir()25 .join(subdir))26}2728pub(crate) struct LocalExtraction {29 30 pub tree_path: SubDir,31 pub name: String,32}3334pub(crate) struct ResolveResult {35 pub version: String,36 pub transitive_git_deps: Vec<Dependency>,37 pub local_extractions: Vec<LocalExtraction>,38 pub source: VendorSource,39}4041const VERSION_FILE: &str = ".version";424344pub enum VendorSource {45 GitTree {46 repo_path: PathBuf,47 commit_sha: String,48 subdir: SubDir,49 },50 GithubZip {51 zip_path: PathBuf,52 commit_sha: String,53 subdir: SubDir,54 },55 Symlink(Utf8PathBuf),56}5758impl VendorSource {59 fn with_subdir(&self, new_subdir: SubDir) -> Self {60 match self {61 VendorSource::GitTree {62 repo_path,63 commit_sha,64 ..65 } => VendorSource::GitTree {66 repo_path: repo_path.clone(),67 commit_sha: commit_sha.clone(),68 subdir: new_subdir,69 },70 VendorSource::GithubZip {71 zip_path,72 commit_sha,73 ..74 } => VendorSource::GithubZip {75 zip_path: zip_path.clone(),76 commit_sha: commit_sha.clone(),77 subdir: new_subdir,78 },79 VendorSource::Symlink(target) => VendorSource::Symlink(target.clone()),80 }81 }82}8384pub struct InstallPlan {85 pub lock: JsonnetFile,86 87 pub entries: BTreeMap<Utf8PathBuf, VendorSource>,88}8990pub fn install(91 manifest: &JsonnetFile,92 lock: Option<&JsonnetFile>,93 vendor_dir: &Path,94 dry_run: bool,95) -> Result<JsonnetFile, Error> {96 let plan = resolve(manifest, lock)?;97 execute(&plan, vendor_dir, dry_run)?;98 Ok(plan.lock)99}100101pub fn resolve(manifest: &JsonnetFile, lock: Option<&JsonnetFile>) -> Result<InstallPlan, Error> {102 let mut plan = InstallPlan {103 lock: JsonnetFile {104 version: manifest.version,105 dependencies: Vec::new(),106 legacy_imports: manifest.legacy_imports,107 },108 entries: BTreeMap::new(),109 };110 let mut installed = HashSet::new();111112 resolve_deps(113 &manifest.dependencies,114 lock,115 manifest.legacy_imports,116 &mut plan,117 &mut installed,118 )?;119120 Ok(plan)121}122123#[cfg(unix)]124fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {125 std::os::unix::fs::symlink(target, link)126}127128#[cfg(windows)]129fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {130 std::os::windows::fs::symlink_dir(target, link)131}132133#[cfg(not(any(unix, windows)))]134fn make_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {135 Err(std::io::Error::new(136 std::io::ErrorKind::Unsupported,137 "symlinks are not supported on this platform",138 ))139}140141fn is_up_to_date(dest: &Path, version: &str) -> bool {142 fs::read_to_string(dest.join(VERSION_FILE)).is_ok_and(|v| v.trim() == version)143}144145fn write_version(dest: &Path, version: &str) -> Result<(), Error> {146 fs::write(dest.join(VERSION_FILE), format!("{version}\n"))147 .map_err(|e| Error::Io(dest.join(VERSION_FILE), e))148}149150pub fn execute(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {151 if !dry_run {152 for (path, source) in &plan.entries {153 let dest = vendor_dir.join(path);154 match source {155 VendorSource::GitTree {156 repo_path,157 commit_sha,158 subdir,159 } => {160 if is_up_to_date(&dest, commit_sha) {161 continue;162 }163 info!("extract {path}");164 if dest.exists() {165 fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;166 }167 fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;168 git::extract(repo_path, commit_sha, subdir, &dest)?;169 write_version(&dest, commit_sha)?;170 }171 VendorSource::GithubZip {172 zip_path,173 commit_sha,174 subdir,175 } => {176 if is_up_to_date(&dest, commit_sha) {177 continue;178 }179 info!("extract {path}");180 if dest.exists() {181 fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;182 }183 fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;184 github::extract(zip_path, subdir, &dest)?;185 write_version(&dest, commit_sha)?;186 }187 VendorSource::Symlink(_) => {}188 }189 }190 for (path, source) in &plan.entries {191 if let VendorSource::Symlink(target) = source {192 let dest = vendor_dir.join(path);193 if dest194 .symlink_metadata()195 .is_ok_and(|m| m.file_type().is_symlink())196 {197 if fs::read_link(&dest).is_ok_and(|t| t == target.as_std_path()) {198 continue;199 }200 fs::remove_file(&dest).map_err(|e| Error::Io(dest.clone(), e))?;201 }202 info!("symlink {path} -> {target}");203 make_symlink(target.as_str(), &dest).map_err(|e| Error::Io(dest.clone(), e))?;204 }205 }206 }207 prune(plan, vendor_dir, dry_run)?;208 Ok(())209}210211fn prune(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {212 if !vendor_dir.is_dir() {213 return Ok(());214 }215 prune_recursive(plan, vendor_dir, vendor_dir, dry_run)216}217218fn prune_recursive(219 plan: &InstallPlan,220 vendor_dir: &Path,221 dir: &Path,222 dry_run: bool,223) -> Result<(), Error> {224 let entries = fs::read_dir(dir).map_err(|e| Error::Io(dir.to_owned(), e))?;225 for entry in entries {226 let entry = entry.map_err(|e| Error::Io(dir.to_owned(), e))?;227 let path = entry.path();228 let rel = path229 .strip_prefix(vendor_dir)230 .expect("path is under vendor_dir");231 let Ok(rel) = Utf8PathBuf::try_from(rel.to_owned()) else {232 info!("prune (non-utf8) {}", rel.display());233 continue;234 };235236 if plan.entries.contains_key(&rel) {237 continue;238 }239240 let ft = entry.file_type().map_err(|e| Error::Io(path.clone(), e))?;241 if ft.is_symlink() {242 info!("prune {rel}");243 if !dry_run {244 fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;245 }246 } else if ft.is_dir() {247 let prefix: Utf8PathBuf = format!("{rel}/").into();248 let has_descendants = plan249 .entries250 .range(prefix.clone()..)251 .next()252 .is_some_and(|(k, _)| k.starts_with(&prefix));253 if has_descendants {254 prune_recursive(plan, vendor_dir, &path, dry_run)?;255 } else {256 info!("prune {rel}");257 if !dry_run {258 fs::remove_dir_all(&path).map_err(|e| Error::Io(path, e))?;259 }260 }261 } else {262 info!("prune {rel}");263 if !dry_run {264 fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;265 }266 }267 }268269 if !dry_run270 && dir != vendor_dir271 && let Ok(mut entries) = fs::read_dir(dir)272 && entries.next().is_none()273 {274 let _ = fs::remove_dir(dir);275 }276277 Ok(())278}279280fn resolve_one(git_source: &GitSource, version: Option<&str>) -> Result<ResolveResult, Error> {281 if git_source.host == "github.com" && git_source.scheme == GitScheme::Https {282 match github::resolve(git_source, version) {283 Ok(result) => return Ok(result),284 Err(e) => {285 info!("github archive failed ({e}), falling back to git");286 }287 }288 }289 git::resolve(git_source, version)290}291292fn locked_version<'a>(dep: &Dependency, lock: Option<&'a JsonnetFile>) -> Option<&'a str> {293 let lock = lock?;294 let key = dep.canonical_name();295 lock.dependencies296 .iter()297 .find(|d| d.canonical_name() == key)298 .and_then(|d| d.version.as_deref())299}300301fn resolve_deps(302 deps: &[Dependency],303 lock: Option<&JsonnetFile>,304 legacy_imports: bool,305 plan: &mut InstallPlan,306 installed: &mut HashSet<Utf8PathBuf>,307) -> Result<(), Error> {308 for dep in deps {309 let Source::Git(git_source) = &dep.source else {310 continue;311 };312313 let canonical = dep.canonical_name();314 if !installed.insert(canonical.clone()) {315 continue;316 }317318 let version = locked_version(dep, lock).or(dep.version.as_deref());319320 info!(321 "resolving {canonical} (version: {})",322 version.unwrap_or("<TBD>")323 );324325 let result = resolve_one(git_source, version)?;326327 plan.lock.dependencies.push(Dependency {328 source: dep.source.clone(),329 version: Some(result.version),330 sum: dep.sum.clone(),331 name: dep.name.clone(),332 single: dep.single,333 });334335 let mut repo_base = Utf8PathBuf::from(git_source.host.as_str());336 repo_base.push(git_source.plain_repo_name());337338 339 340 if legacy_imports || dep.name.is_some() {341 let legacy = Utf8PathBuf::from(dep.legacy_link_name());342 if legacy != canonical {343 plan.entries344 .insert(legacy, VendorSource::Symlink(canonical.clone()));345 }346 }347348 for extraction in &result.local_extractions {349 let extraction_canonical = repo_base.join(&extraction.tree_path);350 plan.entries.insert(351 extraction_canonical.clone(),352 result.source.with_subdir(extraction.tree_path.clone()),353 );354 if legacy_imports {355 let extraction_name = Utf8PathBuf::from(&extraction.name);356 if extraction_name != extraction_canonical {357 plan.entries358 .insert(extraction_name, VendorSource::Symlink(extraction_canonical));359 }360 }361 }362363 364 plan.entries.insert(canonical, result.source);365366 resolve_deps(367 &result.transitive_git_deps,368 lock,369 legacy_imports,370 plan,371 installed,372 )?;373 }374375 Ok(())376}377378#[derive(Debug, thiserror::Error)]379pub enum Error {380 #[error("io error for {0}: {1}")]381 Io(PathBuf, std::io::Error),382 #[error("failed to discover xdg directories")]383 XdgUnavailable,384 #[error("git clone failed: {0}")]385 GitClone(#[from] gix::clone::Error),386 #[error(transparent)]387 GitRemote(#[from] gix::remote::init::Error),388 #[error(transparent)]389 GitConnect(#[from] gix::remote::connect::Error),390 #[error(transparent)]391 GitFetchPrepare(#[from] gix::remote::fetch::prepare::Error),392 #[error(transparent)]393 GitRemoteFetch(#[from] gix::remote::fetch::Error),394 #[error(transparent)]395 GitCloneFetch(#[from] gix::clone::fetch::Error),396 #[error(transparent)]397 GitFindObject(#[from] gix::object::find::existing::Error),398 #[error(transparent)]399 GitTraverse(#[from] gix::traverse::tree::breadthfirst::Error),400 #[error(transparent)]401 GitHead(#[from] gix::reference::head_id::Error),402 #[error(transparent)]403 GitCommit(#[from] gix::object::commit::Error),404 #[error(transparent)]405 GitRevparse(#[from] gix::revision::spec::parse::single::Error),406 #[error(transparent)]407 GitRefspec(#[from] gix::refspec::parse::Error),408 #[error(transparent)]409 GitPeel(#[from] gix::reference::peel::Error),410 #[error(transparent)]411 GitPeelToKind(#[from] gix::object::peel::to_kind::Error),412 #[error(transparent)]413 GitOpen(#[from] gix::open::Error),414 #[error("http error: {0}")]415 Http(#[from] reqwest::Error),416 #[error("zip error: {0}")]417 Zip(Box<zip::result::ZipError>),418 #[error(transparent)]419 Accessor(#[from] accessor::Error),420 #[error("unknown subdir: {0}")]421 SubdirNotFound(String),422 #[error("invalid path in tree: {0}")]423 InvalidPath(String),424}425pub(crate) type Result<T, E = Error> = result::Result<T, E>;