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}122123fn is_up_to_date(dest: &Path, version: &str) -> bool {124 fs::read_to_string(dest.join(VERSION_FILE)).is_ok_and(|v| v.trim() == version)125}126127fn write_version(dest: &Path, version: &str) -> Result<(), Error> {128 fs::write(dest.join(VERSION_FILE), format!("{version}\n"))129 .map_err(|e| Error::Io(dest.join(VERSION_FILE), e))130}131132pub fn execute(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {133 if !dry_run {134 for (path, source) in &plan.entries {135 let dest = vendor_dir.join(path);136 match source {137 VendorSource::GitTree {138 repo_path,139 commit_sha,140 subdir,141 } => {142 if is_up_to_date(&dest, commit_sha) {143 continue;144 }145 info!("extract {path}");146 if dest.exists() {147 fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;148 }149 fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;150 git::extract(repo_path, commit_sha, subdir, &dest)?;151 write_version(&dest, commit_sha)?;152 }153 VendorSource::GithubZip {154 zip_path,155 commit_sha,156 subdir,157 } => {158 if is_up_to_date(&dest, commit_sha) {159 continue;160 }161 info!("extract {path}");162 if dest.exists() {163 fs::remove_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;164 }165 fs::create_dir_all(&dest).map_err(|e| Error::Io(dest.clone(), e))?;166 github::extract(zip_path, subdir, &dest)?;167 write_version(&dest, commit_sha)?;168 }169 VendorSource::Symlink(_) => {}170 }171 }172 for (path, source) in &plan.entries {173 if let VendorSource::Symlink(target) = source {174 let dest = vendor_dir.join(path);175 if dest176 .symlink_metadata()177 .is_ok_and(|m| m.file_type().is_symlink())178 {179 if fs::read_link(&dest).is_ok_and(|t| t == target.as_std_path()) {180 continue;181 }182 fs::remove_file(&dest).map_err(|e| Error::Io(dest.clone(), e))?;183 }184 info!("symlink {path} -> {target}");185 std::os::unix::fs::symlink(target.as_std_path(), &dest)186 .map_err(|e| Error::Io(dest.clone(), e))?;187 }188 }189 }190 prune(plan, vendor_dir, dry_run)?;191 Ok(())192}193194fn prune(plan: &InstallPlan, vendor_dir: &Path, dry_run: bool) -> Result<(), Error> {195 if !vendor_dir.is_dir() {196 return Ok(());197 }198 prune_recursive(plan, vendor_dir, vendor_dir, dry_run)199}200201fn prune_recursive(202 plan: &InstallPlan,203 vendor_dir: &Path,204 dir: &Path,205 dry_run: bool,206) -> Result<(), Error> {207 let entries = fs::read_dir(dir).map_err(|e| Error::Io(dir.to_owned(), e))?;208 for entry in entries {209 let entry = entry.map_err(|e| Error::Io(dir.to_owned(), e))?;210 let path = entry.path();211 let rel = path212 .strip_prefix(vendor_dir)213 .expect("path is under vendor_dir");214 let Ok(rel) = Utf8PathBuf::try_from(rel.to_owned()) else {215 info!("prune (non-utf8) {}", rel.display());216 continue;217 };218219 if plan.entries.contains_key(&rel) {220 continue;221 }222223 let ft = entry.file_type().map_err(|e| Error::Io(path.clone(), e))?;224 if ft.is_symlink() {225 info!("prune {rel}");226 if !dry_run {227 fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;228 }229 } else if ft.is_dir() {230 let prefix: Utf8PathBuf = format!("{rel}/").into();231 let has_descendants = plan232 .entries233 .range(prefix.clone()..)234 .next()235 .is_some_and(|(k, _)| k.starts_with(&prefix));236 if has_descendants {237 prune_recursive(plan, vendor_dir, &path, dry_run)?;238 } else {239 info!("prune {rel}");240 if !dry_run {241 fs::remove_dir_all(&path).map_err(|e| Error::Io(path, e))?;242 }243 }244 } else {245 info!("prune {rel}");246 if !dry_run {247 fs::remove_file(&path).map_err(|e| Error::Io(path, e))?;248 }249 }250 }251252 if !dry_run253 && dir != vendor_dir254 && let Ok(mut entries) = fs::read_dir(dir)255 && entries.next().is_none()256 {257 let _ = fs::remove_dir(dir);258 }259260 Ok(())261}262263fn resolve_one(git_source: &GitSource, version: Option<&str>) -> Result<ResolveResult, Error> {264 if git_source.host == "github.com" && git_source.scheme == GitScheme::Https {265 match github::resolve(git_source, version) {266 Ok(result) => return Ok(result),267 Err(e) => {268 info!("github archive failed ({e}), falling back to git");269 }270 }271 }272 git::resolve(git_source, version)273}274275fn locked_version<'a>(dep: &Dependency, lock: Option<&'a JsonnetFile>) -> Option<&'a str> {276 let lock = lock?;277 let key = dep.canonical_name();278 lock.dependencies279 .iter()280 .find(|d| d.canonical_name() == key)281 .and_then(|d| d.version.as_deref())282}283284fn resolve_deps(285 deps: &[Dependency],286 lock: Option<&JsonnetFile>,287 legacy_imports: bool,288 plan: &mut InstallPlan,289 installed: &mut HashSet<Utf8PathBuf>,290) -> Result<(), Error> {291 for dep in deps {292 let Source::Git(git_source) = &dep.source else {293 continue;294 };295296 let canonical = dep.canonical_name();297 if !installed.insert(canonical.clone()) {298 continue;299 }300301 let version = locked_version(dep, lock).or(dep.version.as_deref());302303 info!(304 "resolving {canonical} (version: {})",305 version.unwrap_or("<TBD>")306 );307308 let result = resolve_one(git_source, version)?;309310 plan.lock.dependencies.push(Dependency {311 source: dep.source.clone(),312 version: Some(result.version),313 sum: dep.sum.clone(),314 name: dep.name.clone(),315 single: dep.single,316 });317318 let mut repo_base = Utf8PathBuf::from(git_source.host.as_str());319 repo_base.push(git_source.plain_repo_name());320321 322 323 if legacy_imports || dep.name.is_some() {324 let legacy = Utf8PathBuf::from(dep.legacy_link_name());325 if legacy != canonical {326 plan.entries327 .insert(legacy, VendorSource::Symlink(canonical.clone()));328 }329 }330331 for extraction in &result.local_extractions {332 let extraction_canonical = repo_base.join(&extraction.tree_path);333 plan.entries.insert(334 extraction_canonical.clone(),335 result.source.with_subdir(extraction.tree_path.clone()),336 );337 if legacy_imports {338 let extraction_name = Utf8PathBuf::from(&extraction.name);339 if extraction_name != extraction_canonical {340 plan.entries341 .insert(extraction_name, VendorSource::Symlink(extraction_canonical));342 }343 }344 }345346 347 plan.entries.insert(canonical, result.source);348349 resolve_deps(350 &result.transitive_git_deps,351 lock,352 legacy_imports,353 plan,354 installed,355 )?;356 }357358 Ok(())359}360361#[derive(Debug, thiserror::Error)]362pub enum Error {363 #[error("io error for {0}: {1}")]364 Io(PathBuf, std::io::Error),365 #[error("failed to discover xdg directories")]366 XdgUnavailable,367 #[error("git clone failed: {0}")]368 GitClone(#[from] gix::clone::Error),369 #[error(transparent)]370 GitRemote(#[from] gix::remote::init::Error),371 #[error(transparent)]372 GitConnect(#[from] gix::remote::connect::Error),373 #[error(transparent)]374 GitFetchPrepare(#[from] gix::remote::fetch::prepare::Error),375 #[error(transparent)]376 GitRemoteFetch(#[from] gix::remote::fetch::Error),377 #[error(transparent)]378 GitCloneFetch(#[from] gix::clone::fetch::Error),379 #[error(transparent)]380 GitFindObject(#[from] gix::object::find::existing::Error),381 #[error(transparent)]382 GitTraverse(#[from] gix::traverse::tree::breadthfirst::Error),383 #[error(transparent)]384 GitHead(#[from] gix::reference::head_id::Error),385 #[error(transparent)]386 GitCommit(#[from] gix::object::commit::Error),387 #[error(transparent)]388 GitRevparse(#[from] gix::revision::spec::parse::single::Error),389 #[error(transparent)]390 GitRefspec(#[from] gix::refspec::parse::Error),391 #[error(transparent)]392 GitPeel(#[from] gix::reference::peel::Error),393 #[error(transparent)]394 GitOpen(#[from] gix::open::Error),395 #[error("http error: {0}")]396 Http(#[from] reqwest::Error),397 #[error("zip error: {0}")]398 Zip(Box<zip::result::ZipError>),399 #[error(transparent)]400 Accessor(#[from] accessor::Error),401 #[error("unknown subdir: {0}")]402 SubdirNotFound(String),403 #[error("invalid path in tree: {0}")]404 InvalidPath(String),405}406pub(crate) type Result<T, E = Error> = result::Result<T, E>;