use miette::{Context, IntoDiagnostic, Result, miette};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct ResolvedPatch {
pub key: String,
#[allow(dead_code)]
pub name: String,
#[allow(dead_code)]
pub version: String,
#[allow(dead_code)]
pub path: PathBuf,
pub content: String,
}
impl ResolvedPatch {
pub fn content_hash(&self) -> String {
let mut h = Sha256::new();
h.update(self.content.as_bytes());
hex::encode(h.finalize())
}
}
fn is_safe_patch_rel(rel: &str) -> bool {
if rel.is_empty() || rel.contains('\0') {
return false;
}
let p = Path::new(rel);
if p.is_absolute() || p.has_root() {
return false;
}
if rel.len() >= 2 && rel.as_bytes()[1] == b':' {
return false;
}
p.components().all(|c| {
matches!(
c,
std::path::Component::Normal(_) | std::path::Component::CurDir
)
})
}
pub fn split_patch_key(key: &str) -> Result<(String, String)> {
let (name, ver) = if let Some(rest) = key.strip_prefix('@') {
let slash = rest
.find('/')
.ok_or_else(|| miette!("invalid patch key {key:?}: scoped name missing slash"))?;
let after = &rest[slash + 1..];
let at = after
.find('@')
.ok_or_else(|| miette!("invalid patch key {key:?}: missing version"))?;
let split = 1 + slash + 1 + at;
(&key[..split], &key[split + 1..])
} else {
let at = key
.find('@')
.ok_or_else(|| miette!("invalid patch key {key:?}: missing version"))?;
(&key[..at], &key[at + 1..])
};
if name.is_empty() || ver.is_empty() {
return Err(miette!("invalid patch key {key:?}"));
}
Ok((name.to_string(), ver.to_string()))
}
pub fn load_patches(cwd: &Path) -> Result<BTreeMap<String, ResolvedPatch>> {
let mut entries: BTreeMap<String, String> = BTreeMap::new();
let manifest_path = cwd.join("package.json");
if manifest_path.exists() {
let manifest = aube_manifest::PackageJson::from_path(&manifest_path)
.map_err(miette::Report::new)
.wrap_err("failed to read package.json")?;
entries.extend(manifest.pnpm_patched_dependencies());
}
let ws_config = aube_manifest::workspace::WorkspaceConfig::load(cwd)
.map_err(miette::Report::new)
.wrap_err("failed to read pnpm-workspace.yaml")?;
entries.extend(ws_config.patched_dependencies);
let mut out = BTreeMap::new();
for (key, rel) in entries {
let (name, version) = split_patch_key(&key)?;
if !is_safe_patch_rel(&rel) {
return Err(miette!(
"refusing unsafe patch path for {key}: {rel:?} (absolute, UNC, or contains `..`)"
));
}
let path = cwd.join(&rel);
let content = std::fs::read_to_string(&path)
.into_diagnostic()
.map_err(|e| {
miette!(
"failed to read patch file {} for {key}: {e}",
path.display()
)
})?;
out.insert(
key.clone(),
ResolvedPatch {
key,
name,
version,
path,
content,
},
);
}
Ok(out)
}
pub fn upsert_patched_dependency(cwd: &Path, key: &str, rel_patch_path: &str) -> Result<()> {
edit_patched_dependencies(cwd, |map| {
map.insert(
key.to_string(),
serde_json::Value::String(rel_patch_path.to_string()),
);
})
}
pub fn remove_patched_dependency(cwd: &Path, key: &str) -> Result<bool> {
let mut existed = false;
edit_patched_dependencies(cwd, |map| {
existed = map.remove(key).is_some();
})?;
Ok(existed)
}
pub fn read_patched_dependencies(cwd: &Path) -> Result<BTreeMap<String, String>> {
let manifest_path = cwd.join("package.json");
if !manifest_path.exists() {
return Ok(BTreeMap::new());
}
let manifest = aube_manifest::PackageJson::from_path(&manifest_path)
.map_err(miette::Report::new)
.wrap_err("failed to read package.json")?;
Ok(manifest.pnpm_patched_dependencies())
}
fn edit_patched_dependencies<F>(cwd: &Path, f: F) -> Result<()>
where
F: FnOnce(&mut serde_json::Map<String, serde_json::Value>),
{
let manifest_path = cwd.join("package.json");
let raw = std::fs::read_to_string(&manifest_path)
.into_diagnostic()
.map_err(|e| miette!("failed to read package.json: {e}"))?;
let mut value = aube_manifest::parse_json::<serde_json::Value>(&manifest_path, raw)
.map_err(miette::Report::new)
.wrap_err("failed to parse package.json")?;
let obj = value
.as_object_mut()
.ok_or_else(|| miette!("package.json is not an object"))?;
let pnpm = obj
.entry("pnpm".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let pnpm_obj = pnpm
.as_object_mut()
.ok_or_else(|| miette!("`pnpm` field in package.json is not an object"))?;
let patched = pnpm_obj
.entry("patchedDependencies".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
let patched_obj = patched
.as_object_mut()
.ok_or_else(|| miette!("`pnpm.patchedDependencies` is not an object"))?;
f(patched_obj);
if patched_obj.is_empty() {
pnpm_obj.remove("patchedDependencies");
}
if pnpm_obj.is_empty() {
obj.remove("pnpm");
}
let mut out = serde_json::to_string_pretty(&value)
.into_diagnostic()
.map_err(|e| miette!("failed to serialize package.json: {e}"))?;
out.push('\n');
std::fs::write(&manifest_path, out)
.into_diagnostic()
.map_err(|e| miette!("failed to write package.json: {e}"))?;
Ok(())
}
pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
std::fs::create_dir_all(dst)
.into_diagnostic()
.map_err(|e| miette!("failed to create {}: {e}", dst.display()))?;
for entry in std::fs::read_dir(src)
.into_diagnostic()
.map_err(|e| miette!("failed to read {}: {e}", src.display()))?
{
let entry = entry
.into_diagnostic()
.map_err(|e| miette!("failed to read entry under {}: {e}", src.display()))?;
let ty = entry
.file_type()
.into_diagnostic()
.map_err(|e| miette!("failed to stat {}: {e}", entry.path().display()))?;
let from = entry.path();
let to = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_all(&from, &to)?;
} else if ty.is_symlink() {
continue;
} else {
std::fs::copy(&from, &to).into_diagnostic().map_err(|e| {
miette!("failed to copy {} -> {}: {e}", from.display(), to.display())
})?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_simple() {
let (n, v) = split_patch_key("is-positive@3.1.0").unwrap();
assert_eq!(n, "is-positive");
assert_eq!(v, "3.1.0");
}
#[test]
fn split_scoped() {
let (n, v) = split_patch_key("@babel/core@7.0.0").unwrap();
assert_eq!(n, "@babel/core");
assert_eq!(v, "7.0.0");
}
#[test]
fn split_missing_version_errors() {
assert!(split_patch_key("is-positive").is_err());
assert!(split_patch_key("@babel/core").is_err());
}
}