#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
use std::path::{Path, PathBuf};
use anyhow::{anyhow, bail, Context, Result};
#[derive(Clone, Debug)]
pub struct OpaqueType {
pub rust_path: String,
pub opaque_name: String,
}
impl OpaqueType {
pub fn new(rust_path: impl Into<String>, opaque_name: impl Into<String>) -> Self {
Self {
rust_path: rust_path.into(),
opaque_name: opaque_name.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct OpaqueTypes {
source_manifest_dir: PathBuf,
features: Vec<String>,
no_default_features: bool,
types: Vec<OpaqueType>,
cargo_lock: Option<PathBuf>,
build_dir: Option<PathBuf>,
}
impl OpaqueTypes {
pub fn new(source_manifest_dir: impl Into<PathBuf>) -> Self {
let build_dir = std::env::var_os("OUT_DIR").map(|o| PathBuf::from(o).join("opaque_probe"));
Self {
source_manifest_dir: source_manifest_dir.into(),
features: Vec::new(),
no_default_features: false,
types: Vec::new(),
cargo_lock: None,
build_dir,
}
}
pub fn features<I, S>(mut self, features: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.features = features.into_iter().map(Into::into).collect();
self
}
pub fn default_features(mut self, enabled: bool) -> Self {
self.no_default_features = !enabled;
self
}
pub fn add(mut self, rust_type: syn::Type, opaque_type: syn::Type) -> Self {
use quote::ToTokens;
self.types.push(OpaqueType::new(
rust_type.to_token_stream().to_string(),
opaque_type.to_token_stream().to_string(),
));
self
}
pub fn cargo_lock(mut self, path: impl Into<PathBuf>) -> Self {
self.cargo_lock = Some(path.into());
self
}
pub fn build_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.build_dir = Some(path.into());
self
}
pub fn generate(&self, destination: impl AsRef<Path>) -> Result<()> {
if self.types.is_empty() {
bail!("no opaque types were requested");
}
let build_dir = self
.build_dir
.clone()
.ok_or_else(|| anyhow!("build_dir not set and OUT_DIR is unavailable"))?;
let target = std::env::var("TARGET").unwrap_or_default();
write_probe_crate(self, &build_dir)?;
let rlib = build_probe(self, &build_dir, &target)?;
let data = std::fs::read(&rlib).with_context(|| format!("reading {}", rlib.display()))?;
validate_types(&self.types)?;
let mut out = String::from("// @generated by opaque-types — do not edit.\n\n");
for t in &self.types {
let size = read_symbol_usize(&data, &sym_name("SIZE", &t.opaque_name))
.with_context(|| format!("probing size of `{}`", t.rust_path))?;
let align = read_symbol_usize(&data, &sym_name("ALIGN", &t.opaque_name))
.with_context(|| format!("probing align of `{}`", t.rust_path))?;
out.push_str(&render_opaque(&t.opaque_name, size, align));
}
let destination = destination.as_ref();
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("creating destination directory {}", parent.display()))?;
}
std::fs::write(destination, out)
.with_context(|| format!("writing {}", destination.display()))?;
Ok(())
}
}
const PROBE_CRATE: &str = "opaque_types_probe";
fn sym_name(kind: &str, opaque_name: &str) -> String {
format!("OPAQUE_TYPES_{kind}_{opaque_name}")
}
fn validate_types(types: &[OpaqueType]) -> Result<()> {
for mapping in types {
syn::parse_str::<syn::Type>(&mapping.rust_path)
.with_context(|| format!("invalid Rust type expression `{}`", mapping.rust_path))?;
syn::parse_str::<syn::Ident>(&mapping.opaque_name).with_context(|| {
format!(
"invalid opaque struct name `{}`: expected one Rust identifier",
mapping.opaque_name
)
})?;
}
Ok(())
}
pub fn render_probe_lib(types: &[OpaqueType]) -> String {
let mut s = String::from(
"// @generated probe crate for opaque-types — do not edit.\n\
#![allow(non_upper_case_globals, dead_code)]\n",
);
for t in types {
let size_sym = sym_name("SIZE", &t.opaque_name);
let align_sym = sym_name("ALIGN", &t.opaque_name);
let path = &t.rust_path;
s.push_str(&format!(
"#[no_mangle]\n#[used]\npub static {size_sym}: usize = ::core::mem::size_of::<{path}>();\n\
#[no_mangle]\n#[used]\npub static {align_sym}: usize = ::core::mem::align_of::<{path}>();\n",
));
}
s
}
pub fn render_opaque(opaque_name: &str, size: usize, align: usize) -> String {
format!(
"#[repr(C, align({align}))]\n#[allow(non_camel_case_types)]\n\
pub struct {opaque_name} {{\n pub _0: [u8; {size}],\n}}\n\n"
)
}
fn default_cargo_lock() -> PathBuf {
get_cargo_lock::get_cargo_lock()
}
fn read_package_name(manifest_dir: &Path) -> Result<String> {
let manifest_path = manifest_dir.join("Cargo.toml");
let text = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("reading {}", manifest_path.display()))?;
let table: toml::Table = text
.parse()
.with_context(|| format!("parsing {}", manifest_path.display()))?;
table
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(str::to_string)
.ok_or_else(|| {
anyhow!(
"no `[package].name` (string) in {}",
manifest_path.display()
)
})
}
fn write_probe_crate(b: &OpaqueTypes, build_dir: &Path) -> Result<()> {
let src = build_dir.join("src");
std::fs::create_dir_all(&src)
.with_context(|| format!("creating probe src dir {}", src.display()))?;
let source_manifest_dir = b.source_manifest_dir.canonicalize().with_context(|| {
format!(
"resolving source manifest directory {}",
b.source_manifest_dir.display()
)
})?;
let package = read_package_name(&source_manifest_dir)?;
let source_path = source_manifest_dir.to_str().ok_or_else(|| {
anyhow!(
"source manifest directory is not valid UTF-8: {}",
source_manifest_dir.display()
)
})?;
let features_toml = if b.features.is_empty() {
String::new()
} else {
let list = b
.features
.iter()
.map(|feature| toml::Value::String(feature.clone()).to_string())
.collect::<Vec<_>>()
.join(", ");
format!(", features = [{list}]")
};
let manifest = format!(
"[package]\nname = \"{PROBE_CRATE}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
publish = false\n\n[lib]\ncrate-type = [\"lib\"]\n\n[dependencies]\n\
{pkg} = {{ path = {path}, default-features = {dflt}{features} }}\n\n\
[workspace]\n",
pkg = toml::Value::String(package),
path = toml::Value::String(source_path.to_owned()),
dflt = !b.no_default_features,
features = features_toml,
);
std::fs::write(build_dir.join("Cargo.toml"), manifest)?;
std::fs::write(src.join("lib.rs"), render_probe_lib(&b.types))?;
let cargo_lock = b.cargo_lock.clone().unwrap_or_else(default_cargo_lock);
std::fs::copy(&cargo_lock, build_dir.join("Cargo.lock"))
.with_context(|| format!("copying lockfile {} into probe", cargo_lock.display()))?;
Ok(())
}
fn build_probe(_b: &OpaqueTypes, build_dir: &Path, target: &str) -> Result<PathBuf> {
let mut cmd = std::process::Command::new(std::env::var("CARGO").unwrap_or("cargo".into()));
cmd.current_dir(build_dir)
.arg("build")
.arg("--offline")
.arg("--message-format=json-render-diagnostics")
.arg("--manifest-path")
.arg(build_dir.join("Cargo.toml"));
if !target.is_empty() {
cmd.arg("--target").arg(target);
}
cmd.arg("--target-dir").arg(build_dir.join("target"));
let out = cmd.output().context("spawning cargo for the probe crate")?;
if !out.status.success() {
bail!(
"probe build failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}
let stdout = String::from_utf8_lossy(&out.stdout);
let mut rlib: Option<PathBuf> = None;
for line in stdout.lines() {
if line.contains("\"compiler-artifact\"") && line.contains(PROBE_CRATE) {
if let Some(p) = extract_first_rlib(line) {
rlib = Some(PathBuf::from(p));
}
}
}
rlib.ok_or_else(|| anyhow!("probe rlib artifact not found in cargo output"))
}
fn extract_first_rlib(line: &str) -> Option<String> {
let idx = line.find("\"filenames\"")?;
let rest = &line[idx..];
for tok in rest.split('"') {
if tok.ends_with(".rlib") {
return Some(json_unescape(tok));
}
}
None
}
fn json_unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some('/') => out.push('/'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(c);
}
}
out
}
pub fn read_symbol_usize(artifact: &[u8], sym: &str) -> Result<usize> {
use object::{Object, ObjectSection, ObjectSymbol};
let with_underscore = format!("_{sym}");
let matches = |name: &str| name == sym || name == with_underscore;
let read_from = |obj: &object::File| -> Option<usize> {
let ptr_bytes = if obj.is_64() { 8 } else { 4 };
let s = obj
.symbols()
.find(|s| s.name().map(matches).unwrap_or(false))?;
let sec = obj.section_by_index(s.section_index()?).ok()?;
let data = sec.data().ok()?;
let off = s.address().checked_sub(sec.address())? as usize;
let bytes = data.get(off..off + ptr_bytes)?;
let value = match (ptr_bytes, obj.is_little_endian()) {
(4, true) => u32::from_le_bytes(bytes.try_into().ok()?) as u64,
(4, false) => u32::from_be_bytes(bytes.try_into().ok()?) as u64,
(8, true) => u64::from_le_bytes(bytes.try_into().ok()?),
(8, false) => u64::from_be_bytes(bytes.try_into().ok()?),
_ => return None,
};
usize::try_from(value).ok()
};
if let Ok(archive) = object::read::archive::ArchiveFile::parse(artifact) {
for member in archive.members() {
let member = member.map_err(|e| anyhow!("archive member: {e}"))?;
let data = member
.data(artifact)
.map_err(|e| anyhow!("archive member data: {e}"))?;
if let Ok(obj) = object::File::parse(data) {
if let Some(v) = read_from(&obj) {
return Ok(v);
}
}
}
} else if let Ok(obj) = object::File::parse(artifact) {
if let Some(v) = read_from(&obj) {
return Ok(v);
}
}
bail!("symbol `{sym}` not found in probe artifact")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_lib_emits_size_and_align_statics() {
let types = vec![OpaqueType::new("model::Message", "message_t")];
let s = render_probe_lib(&types);
assert!(s.contains(
"pub static OPAQUE_TYPES_SIZE_message_t: usize = ::core::mem::size_of::<model::Message>();"
));
assert!(s.contains(
"pub static OPAQUE_TYPES_ALIGN_message_t: usize = ::core::mem::align_of::<model::Message>();"
));
assert!(s.contains("#[no_mangle]") && s.contains("#[used]"));
}
#[test]
fn features_are_explicit_and_independent_of_defaults() {
let b = OpaqueTypes::new("source")
.features(["unstable", "shared-memory"])
.add(
syn::parse_quote!(model::Message),
syn::parse_quote!(message_t),
);
assert_eq!(b.features, vec!["unstable", "shared-memory"]);
assert!(!b.no_default_features);
assert_eq!(b.types.len(), 1);
assert_eq!(b.types[0].opaque_name, "message_t");
}
#[test]
fn invalid_mapping_is_rejected() {
let mappings = [OpaqueType::new("not a type!", "not::an::identifier")];
let error = validate_types(&mappings).unwrap_err().to_string();
assert!(error.contains("invalid Rust type expression"));
}
#[test]
fn opaque_struct_renders_repr_c_align() {
let s = render_opaque("z_zbytes_t", 32, 8);
assert!(s.contains("#[repr(C, align(8))]"));
assert!(s.contains("pub struct z_zbytes_t"));
assert!(s.contains("pub _0: [u8; 32]"));
}
#[test]
fn rlib_artifact_path_parsed_from_cargo_json() {
let line = r#"{"reason":"compiler-artifact","package_id":"opaque_types_probe 0.0.0","filenames":["/tmp/t/target/debug/deps/libopaque_types_probe-abc.rlib"],"executable":null}"#;
assert_eq!(
extract_first_rlib(line).as_deref(),
Some("/tmp/t/target/debug/deps/libopaque_types_probe-abc.rlib")
);
}
#[test]
fn rlib_artifact_path_windows_backslashes_unescaped() {
let line = r#"{"reason":"compiler-artifact","filenames":["C:\\proj\\target\\debug\\deps\\libopaque_types_probe-abc.rlib"]}"#;
assert_eq!(
extract_first_rlib(line).as_deref(),
Some(r"C:\proj\target\debug\deps\libopaque_types_probe-abc.rlib")
);
}
#[test]
fn generates_layout_from_a_temporary_source_package() -> Result<()> {
let temporary = tempfile::tempdir()?;
let source = temporary.path().join("model");
std::fs::create_dir_all(source.join("src"))?;
std::fs::write(
source.join("Cargo.toml"),
"[package]\nname = \"layout-model\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)?;
std::fs::write(
source.join("src/lib.rs"),
"#[repr(C, align(16))]\npub struct Value(pub [u8; 3]);\n",
)?;
let lockfile = source.join("Cargo.lock");
std::fs::write(&lockfile, "version = 4\n")?;
let destination = temporary.path().join("generated/opaque_types.rs");
OpaqueTypes::new(&source)
.cargo_lock(lockfile)
.build_dir(temporary.path().join("probe"))
.add(
syn::parse_quote!(layout_model::Value),
syn::parse_quote!(opaque_value_t),
)
.generate(&destination)?;
let generated = std::fs::read_to_string(destination)?;
assert!(generated.contains("#[repr(C, align(16))]"));
assert!(generated.contains("pub struct opaque_value_t"));
assert!(generated.contains("pub _0: [u8; 16]"));
Ok(())
}
#[test]
fn failed_probe_does_not_replace_destination() -> Result<()> {
let temporary = tempfile::tempdir()?;
let source = temporary.path().join("model");
std::fs::create_dir_all(source.join("src"))?;
std::fs::write(
source.join("Cargo.toml"),
"[package]\nname = \"layout-model\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
)?;
std::fs::write(source.join("src/lib.rs"), "pub struct Value;\n")?;
let lockfile = source.join("Cargo.lock");
std::fs::write(&lockfile, "version = 4\n")?;
let destination = temporary.path().join("opaque_types.rs");
std::fs::write(&destination, "existing output\n")?;
let result = OpaqueTypes::new(&source)
.cargo_lock(lockfile)
.build_dir(temporary.path().join("probe"))
.add(
syn::parse_quote!(layout_model::Value),
syn::parse_quote!(opaque_value_t),
)
.add(
syn::parse_quote!(layout_model::Missing),
syn::parse_quote!(missing_t),
)
.generate(&destination);
assert!(result.is_err());
assert_eq!(std::fs::read_to_string(destination)?, "existing output\n");
Ok(())
}
}