use clap::Parser;
use ic_build::cargo::{
lock_projection::WorkspaceVersionProjection,
release_metadata::{
DependencyVersionProjection, render_workspace_lock, render_workspace_manifest,
},
};
use serde::Deserialize;
use serde_json::Value;
use std::{
collections::HashMap,
error::Error,
io::Write,
num::NonZeroUsize,
path::{Component, Path, PathBuf},
};
type Result<T> = std::result::Result<T, Box<dyn Error>>;
#[derive(Parser)]
#[command(
name = "ic-build-release-metadata",
about = "Render read-only Cargo release candidates",
version
)]
struct Arguments {
#[arg(long, value_name = "max-input-bytes")]
max_input_bytes: NonZeroUsize,
#[command(subcommand)]
operation: Operation,
}
#[derive(clap::Args)]
struct Selection {
#[arg(value_name = "source")]
source: PathBuf,
#[arg(value_name = "previous")]
previous: String,
#[arg(value_name = "next")]
next: String,
}
#[derive(clap::Subcommand)]
enum Operation {
Manifest {
#[command(flatten)]
selection: Selection,
#[arg(value_name = "workspace-input-json")]
context: Option<PathBuf>,
},
Lock {
#[command(flatten)]
selection: Selection,
#[arg(value_name = "package", required = true, num_args = 1..)]
packages: Vec<String>,
},
}
pub fn run() -> Result<()> {
let arguments = Arguments::parse();
let limit = arguments.max_input_bytes.get();
let selection = match &arguments.operation {
Operation::Manifest { selection, .. } | Operation::Lock { selection, .. } => selection,
};
let Selection {
source,
previous,
next,
} = selection;
check_path(source)?;
let input = read(source, limit)?;
let output = match &arguments.operation {
Operation::Manifest { context, .. } => {
let dependencies = if let Some(context) = context {
let context = serde_json::from_str(&read(context, limit)?)?;
dependency_versions(source, &input, next, &context)?
} else {
Vec::new()
};
let projections: Vec<_> = dependencies
.iter()
.map(|(alias, previous, next)| DependencyVersionProjection {
alias,
previous,
next,
})
.collect();
render_workspace_manifest(&input, previous, next, &projections)?
}
Operation::Lock { packages, .. } => {
let projections = packages
.iter()
.map(|name| WorkspaceVersionProjection {
package: name,
previous,
next,
})
.collect::<Vec<_>>();
render_workspace_lock(&input, &projections)?
}
};
std::io::stdout().lock().write_all(output.as_bytes())?;
Ok(())
}
fn check_path(path: &Path) -> Result<()> {
if path
.as_os_str()
.as_encoded_bytes()
.iter()
.any(|byte| matches!(byte, 0 | b'\r' | b'\n'))
{
return Err("policy path contains NUL/CR/LF".into());
}
Ok(())
}
fn read(path: &Path, limit: usize) -> Result<String> {
check_path(path)?;
let absolute = if path.is_absolute() {
path.to_owned()
} else {
std::env::current_dir()?.join(path)
};
check_path(&absolute)?;
check_path(
&absolute
.parent()
.ok_or("input has no parent")?
.canonicalize()?,
)?;
Ok(String::from_utf8(ic_host_fs::read::read_file_no_follow(
path, limit,
)?)?)
}
fn identity(path: &Path) -> Result<PathBuf> {
check_path(path)?;
if !path.is_absolute() {
return Err("saved identity must be absolute".into());
}
let mut result = PathBuf::new();
for component in path.components() {
match component {
Component::RootDir | Component::Normal(_) => result.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if !result.pop() {
return Err("identity escapes filesystem root".into());
}
}
Component::Prefix(_) => return Err("unsupported path prefix".into()),
}
}
Ok(result)
}
fn requirement(previous: &str, next: &str) -> Option<String> {
let offset = usize::from(previous.starts_with(['~', '^', '=']));
let version = previous[offset..].trim_start_matches([' ', '\t']);
let prefix = &previous[..previous.len() - version.len()];
let parts: Vec<_> = version.split('.').collect();
if !(1..=3).contains(&parts.len())
|| parts
.iter()
.any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
{
return None;
}
Some(format!(
"{prefix}{}",
next.split('.')
.take(parts.len())
.collect::<Vec<_>>()
.join(".")
))
}
enum ContextRoot {
Live(PathBuf),
Saved(PathBuf),
}
impl ContextRoot {
fn owns(&self, path: &str, member: &Path) -> Result<bool> {
match self {
Self::Live(root) => Ok(root.join(path).join("Cargo.toml").canonicalize()? == member),
Self::Saved(root) => {
if identity(&root.join(path).join("Cargo.toml"))? != member {
return Err("cannot establish saved dependency ownership".into());
}
Ok(true)
}
}
}
}
#[derive(Deserialize)]
struct SourceContext {
#[serde(default, deserialize_with = "source_root")]
source_root: Option<String>,
members: Vec<ContextMember>,
#[serde(default)]
manifest: Value,
}
#[derive(Deserialize)]
struct ContextMember {
name: String,
manifest_path: String,
}
fn source_root<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Option<String>, D::Error> {
String::deserialize(deserializer).map(Some)
}
struct WorkspaceContext<'a> {
root: ContextRoot,
members: HashMap<&'a str, PathBuf>,
dependencies: Option<&'a serde_json::Map<String, Value>>,
}
impl<'a> WorkspaceContext<'a> {
fn admit(source: &Path, input: &str, context: &'a SourceContext) -> Result<Self> {
let root = if let Some(root) = &context.source_root {
ContextRoot::Saved(identity(Path::new(root))?)
} else {
ContextRoot::Live(
source
.canonicalize()?
.parent()
.ok_or("manifest has no parent")?
.to_owned(),
)
};
let mut members = HashMap::new();
for member in &context.members {
let name = member.name.as_str();
let path = identity(Path::new(&member.manifest_path))?;
if members.insert(name, path).is_some() {
return Err("duplicate member identity".into());
}
}
let manifest: toml::Value = toml::from_str(input)?;
let actual = manifest
.get("workspace")
.and_then(|value| value.get("dependencies"));
let expected = context
.manifest
.get("workspace")
.and_then(|value| value.get("dependencies"));
if serde_json::to_value(actual)? != expected.cloned().unwrap_or(Value::Null) {
return Err("saved dependency declarations differ from source".into());
}
Ok(Self {
root,
members,
dependencies: expected.and_then(Value::as_object),
})
}
}
fn dependency_versions(
source: &Path,
input: &str,
next: &str,
context: &SourceContext,
) -> Result<Vec<(String, String, String)>> {
let context = WorkspaceContext::admit(source, input, context)?;
let mut selected = Vec::new();
if let Some(dependencies) = context.dependencies {
for (alias, dependency) in dependencies {
let (Some(path), Some(previous)) = (
dependency.get("path").and_then(Value::as_str),
dependency.get("version").and_then(Value::as_str),
) else {
continue;
};
let package = dependency
.get("package")
.and_then(Value::as_str)
.unwrap_or(alias);
let Some(member) = context.members.get(package) else {
continue;
};
if context.root.owns(path, member)?
&& let Some(candidate) = requirement(previous, next)
{
selected.push((alias.clone(), previous.to_owned(), candidate));
}
}
}
Ok(selected)
}