git.delta.rocks / jrsonnet / refs/commits / 97dec27e23cc

difftreelog

feat(jrb) symlinks

kmklkzzmYaroslav Bolyukin2026-05-07parent: #6710888.patch.diff
in: master

2 files changed

modifiedcrates/jrsonnet-pkg/src/install/accessor.rsdiffbeforeafterboth
before · crates/jrsonnet-pkg/src/install/accessor.rs
1use std::{2	fs::File,3	io::{self, Read},4	result,5	str::FromStr as _,6	sync::Mutex,7};89use tracing::warn;10use zip::{ZipArchive, result::ZipError};1112use crate::jsonnet_bundler::{SubDir, SubDirEscapeError};1314#[derive(thiserror::Error, Debug)]15pub enum Error {16	#[error(transparent)]17	Zip(#[from] ZipError),18	#[error("invalid prefixed archive")]19	ZipInvalidPrefix,20	#[error("zip io: {0}")]21	ZipIo(io::Error),22	#[error("subdir not found: {0}")]23	SubDirNotFound(SubDir),24	#[error(transparent)]25	SubdirEscape(#[from] SubDirEscapeError),26}27type Result<T, E = Error> = result::Result<T, E>;2829pub trait SourceAccessor {}3031pub struct ZipFileAccessor {32	archive: Mutex<ZipArchive<File>>,33	// Github archives have top-level directory with repo name34	prefix: SubDir,35}3637impl ZipFileAccessor {38	pub fn new_prefixed(file: File) -> Result<Self> {39		let archive = ZipArchive::new(file)?;40		let prefix = archive.name_for_index(0).ok_or(Error::ZipInvalidPrefix)?;4142		Ok(Self {43			prefix: SubDir::from_str(prefix)?,44			archive: Mutex::new(archive),45		})46	}47	/// Read a file from inside the archive's logical root (after stripping the48	/// github-style `<repo>-<sha>/` prefix).49	#[allow(clippy::significant_drop_tightening, reason = "false-positive")]50	pub fn read(&self, name: &SubDir) -> Result<Option<Vec<u8>>> {51		let prefixed = self52			.prefix53			.join(name)54			.expect("prefix and name are both subdirs");55		let mut archive = self.archive.lock().expect("not poisoned");56		let mut v = match archive.by_name(prefixed.as_str()) {57			Ok(v) => v,58			Err(ZipError::FileNotFound) => return Ok(None),59			Err(e) => return Err(e.into()),60		};61		if !v.is_file() {62			return Ok(None);63		}64		let mut out = Vec::new();65		v.read_to_end(&mut out).map_err(Error::ZipIo)?;66		Ok(Some(out))67	}68	#[allow(clippy::significant_drop_tightening, reason = "false-positive")]69	#[allow(70		clippy::iter_not_returning_iterator,71		reason = "idk for a better name, it is still inner iteration"72	)]73	pub fn iter<E>(74		&self,75		subdir: &SubDir,76		cb: &mut dyn FnMut(SubDir, AccessorEntry) -> Result<(), E>,77	) -> Result<(), E>78	where79		E: From<Error>,80	{81		let mut archive = self.archive.lock().expect("not poisoned");82		let len = archive.len();8384		let mut found = false;85		for i in 0..len {86			let mut entry = archive.by_index(i).map_err(Error::from)?;87			let raw = entry.name();88			let Ok(full_name) = SubDir::from_str(raw) else {89				warn!("invalid zip entry name: {raw}");90				continue;91			};92			// Peel off the github-archive top-level `<repo>-<sha>/` prefix.93			let Some(in_repo) = full_name.strip_prefix(&self.prefix) else {94				continue;95			};96			let Some(name) = in_repo.strip_prefix(subdir) else {97				continue;98			};99			found = true;100			if name.is_empty() && entry.is_dir() {101				continue;102			}103104			cb(105				name.clone(),106				if entry.is_dir() {107					AccessorEntry::Dir108				} else if entry.is_file() {109					let mut data = Vec::new();110					entry.read_to_end(&mut data).map_err(Error::ZipIo)?;111					AccessorEntry::File(data)112				} else {113					// TODO: Symlinks?114					panic!("unknown accessor entry type: {name:?}")115				},116			)?;117		}118119		if !found {120			return Err(Error::SubDirNotFound(subdir.clone()).into());121		}122123		Ok(())124	}125	pub fn len(&self) -> usize {126		self.archive.lock().expect("not poisoned").len()127	}128	pub fn is_empty(&self) -> bool {129		self.len() == 0130	}131}132133pub enum AccessorEntry {134	Dir,135	File(Vec<u8>),136}
modifiedcrates/jrsonnet-pkg/src/install/github.rsdiffbeforeafterboth
--- a/crates/jrsonnet-pkg/src/install/github.rs
+++ b/crates/jrsonnet-pkg/src/install/github.rs
@@ -7,8 +7,9 @@
 	path::{Path, PathBuf},
 };
 
+use camino::Utf8PathBuf;
 use reqwest::{blocking::Response, header};
-use tracing::{debug, info};
+use tracing::{debug, info, warn};
 
 use super::{
 	Error, LocalExtraction, ResolveResult, Result, VendorSource,
@@ -85,9 +86,27 @@
 	)?)
 }
 
+#[cfg(unix)]
+fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {
+	std::os::unix::fs::symlink(target, link)
+}
+
+#[cfg(windows)]
+fn make_symlink(target: &str, link: &Path) -> std::io::Result<()> {
+	std::os::windows::fs::symlink_file(target, link)
+}
+
+#[cfg(not(any(unix, windows)))]
+fn make_symlink(_target: &str, _link: &Path) -> std::io::Result<()> {
+	Err(std::io::Error::new(
+		std::io::ErrorKind::Unsupported,
+		"symlinks are not supported on this platform",
+	))
+}
+
 fn extract_subdir(archive: &ZipFileAccessor, subdir: &SubDir, dest: &Path) -> Result<()> {
 	archive.iter(subdir, &mut |name, entry| {
-		let target = dest.join(name);
+		let target = dest.join(&name);
 		match entry {
 			AccessorEntry::Dir => {
 				fs::create_dir_all(&target).map_err(|e| Error::Io(target, e))?;
@@ -98,6 +117,24 @@
 				}
 				fs::write(&target, &data).map_err(|e| Error::Io(target, e))?;
 			}
+			AccessorEntry::Symlink(link_target) => {
+				let symlink_parent = name
+					.as_path()
+					.parent()
+					.map(|p| SubDir::try_from(Utf8PathBuf::from(p)))
+					.transpose()
+					.expect("parent of a SubDir is a SubDir")
+					.unwrap_or_else(SubDir::empty);
+				if link_target.resolve_under(&symlink_parent).is_err() {
+					warn!("symlink {name} -> {link_target} escapes extraction; skipping");
+					return Ok(());
+				}
+				if let Some(parent) = target.parent() {
+					fs::create_dir_all(parent).map_err(|e| Error::Io(parent.to_owned(), e))?;
+				}
+				make_symlink(&link_target.to_string(), &target)
+					.map_err(|e| Error::Io(target, e))?;
+			}
 		}
 		Ok(())
 	})