1#![doc = include_str!("../README.md")]
2#![forbid(unsafe_code)]
3
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, bail, Context, Result};
7
8#[derive(Clone, Debug)]
10pub struct OpaqueType {
11 pub rust_path: String,
13 pub opaque_name: String,
15}
16
17impl OpaqueType {
18 pub fn new(rust_path: impl Into<String>, opaque_name: impl Into<String>) -> Self {
19 Self {
20 rust_path: rust_path.into(),
21 opaque_name: opaque_name.into(),
22 }
23 }
24}
25
26#[derive(Clone, Debug)]
31pub struct OpaqueTypes {
32 source_manifest_dir: PathBuf,
33 features: Vec<String>,
34 no_default_features: bool,
35 types: Vec<OpaqueType>,
36 cargo_lock: Option<PathBuf>,
37 build_dir: Option<PathBuf>,
38}
39
40impl OpaqueTypes {
41 pub fn new(source_manifest_dir: impl Into<PathBuf>) -> Self {
44 let build_dir = std::env::var_os("OUT_DIR").map(|o| PathBuf::from(o).join("opaque_probe"));
45 Self {
46 source_manifest_dir: source_manifest_dir.into(),
47 features: Vec::new(),
48 no_default_features: false,
49 types: Vec::new(),
50 cargo_lock: None,
51 build_dir,
52 }
53 }
54
55 pub fn features<I, S>(mut self, features: I) -> Self
60 where
61 I: IntoIterator<Item = S>,
62 S: Into<String>,
63 {
64 self.features = features.into_iter().map(Into::into).collect();
65 self
66 }
67
68 pub fn default_features(mut self, enabled: bool) -> Self {
71 self.no_default_features = !enabled;
72 self
73 }
74
75 pub fn add(mut self, rust_type: syn::Type, opaque_type: syn::Type) -> Self {
80 use quote::ToTokens;
81 self.types.push(OpaqueType::new(
82 rust_type.to_token_stream().to_string(),
83 opaque_type.to_token_stream().to_string(),
84 ));
85 self
86 }
87
88 pub fn cargo_lock(mut self, path: impl Into<PathBuf>) -> Self {
92 self.cargo_lock = Some(path.into());
93 self
94 }
95
96 pub fn build_dir(mut self, path: impl Into<PathBuf>) -> Self {
98 self.build_dir = Some(path.into());
99 self
100 }
101
102 pub fn generate(&self, destination: impl AsRef<Path>) -> Result<()> {
110 if self.types.is_empty() {
111 bail!("no opaque types were requested");
112 }
113 let build_dir = self
114 .build_dir
115 .clone()
116 .ok_or_else(|| anyhow!("build_dir not set and OUT_DIR is unavailable"))?;
117 let target = std::env::var("TARGET").unwrap_or_default();
118 write_probe_crate(self, &build_dir)?;
119 let rlib = build_probe(self, &build_dir, &target)?;
120 let data = std::fs::read(&rlib).with_context(|| format!("reading {}", rlib.display()))?;
121
122 validate_types(&self.types)?;
123 let mut out = String::from("// @generated by opaque-types — do not edit.\n\n");
124 for t in &self.types {
125 let size = read_symbol_usize(&data, &sym_name("SIZE", &t.opaque_name))
126 .with_context(|| format!("probing size of `{}`", t.rust_path))?;
127 let align = read_symbol_usize(&data, &sym_name("ALIGN", &t.opaque_name))
128 .with_context(|| format!("probing align of `{}`", t.rust_path))?;
129 out.push_str(&render_opaque(&t.opaque_name, size, align));
130 }
131 let destination = destination.as_ref();
132 if let Some(parent) = destination.parent() {
133 std::fs::create_dir_all(parent)
134 .with_context(|| format!("creating destination directory {}", parent.display()))?;
135 }
136 std::fs::write(destination, out)
137 .with_context(|| format!("writing {}", destination.display()))?;
138 Ok(())
139 }
140}
141
142const PROBE_CRATE: &str = "opaque_types_probe";
143
144fn sym_name(kind: &str, opaque_name: &str) -> String {
146 format!("OPAQUE_TYPES_{kind}_{opaque_name}")
147}
148
149fn validate_types(types: &[OpaqueType]) -> Result<()> {
150 for mapping in types {
151 syn::parse_str::<syn::Type>(&mapping.rust_path)
152 .with_context(|| format!("invalid Rust type expression `{}`", mapping.rust_path))?;
153 syn::parse_str::<syn::Ident>(&mapping.opaque_name).with_context(|| {
154 format!(
155 "invalid opaque struct name `{}`: expected one Rust identifier",
156 mapping.opaque_name
157 )
158 })?;
159 }
160 Ok(())
161}
162
163pub fn render_probe_lib(types: &[OpaqueType]) -> String {
165 let mut s = String::from(
166 "// @generated probe crate for opaque-types — do not edit.\n\
167 #![allow(non_upper_case_globals, dead_code)]\n",
168 );
169 for t in types {
170 let size_sym = sym_name("SIZE", &t.opaque_name);
171 let align_sym = sym_name("ALIGN", &t.opaque_name);
172 let path = &t.rust_path;
173 s.push_str(&format!(
174 "#[no_mangle]\n#[used]\npub static {size_sym}: usize = ::core::mem::size_of::<{path}>();\n\
175 #[no_mangle]\n#[used]\npub static {align_sym}: usize = ::core::mem::align_of::<{path}>();\n",
176 ));
177 }
178 s
179}
180
181pub fn render_opaque(opaque_name: &str, size: usize, align: usize) -> String {
186 format!(
187 "#[repr(C, align({align}))]\n#[allow(non_camel_case_types)]\n\
188 pub struct {opaque_name} {{\n pub _0: [u8; {size}],\n}}\n\n"
189 )
190}
191
192fn default_cargo_lock() -> PathBuf {
200 get_cargo_lock::get_cargo_lock()
201}
202
203fn read_package_name(manifest_dir: &Path) -> Result<String> {
206 let manifest_path = manifest_dir.join("Cargo.toml");
207 let text = std::fs::read_to_string(&manifest_path)
208 .with_context(|| format!("reading {}", manifest_path.display()))?;
209 let table: toml::Table = text
210 .parse()
211 .with_context(|| format!("parsing {}", manifest_path.display()))?;
212 table
213 .get("package")
214 .and_then(|p| p.get("name"))
215 .and_then(|n| n.as_str())
216 .map(str::to_string)
217 .ok_or_else(|| {
218 anyhow!(
219 "no `[package].name` (string) in {}",
220 manifest_path.display()
221 )
222 })
223}
224
225fn write_probe_crate(b: &OpaqueTypes, build_dir: &Path) -> Result<()> {
227 let src = build_dir.join("src");
228 std::fs::create_dir_all(&src)
229 .with_context(|| format!("creating probe src dir {}", src.display()))?;
230 let source_manifest_dir = b.source_manifest_dir.canonicalize().with_context(|| {
231 format!(
232 "resolving source manifest directory {}",
233 b.source_manifest_dir.display()
234 )
235 })?;
236 let package = read_package_name(&source_manifest_dir)?;
237 let source_path = source_manifest_dir.to_str().ok_or_else(|| {
238 anyhow!(
239 "source manifest directory is not valid UTF-8: {}",
240 source_manifest_dir.display()
241 )
242 })?;
243
244 let features_toml = if b.features.is_empty() {
245 String::new()
246 } else {
247 let list = b
248 .features
249 .iter()
250 .map(|feature| toml::Value::String(feature.clone()).to_string())
251 .collect::<Vec<_>>()
252 .join(", ");
253 format!(", features = [{list}]")
254 };
255 let manifest = format!(
256 "[package]\nname = \"{PROBE_CRATE}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\
257 publish = false\n\n[lib]\ncrate-type = [\"lib\"]\n\n[dependencies]\n\
258 {pkg} = {{ path = {path}, default-features = {dflt}{features} }}\n\n\
259 [workspace]\n",
260 pkg = toml::Value::String(package),
261 path = toml::Value::String(source_path.to_owned()),
262 dflt = !b.no_default_features,
263 features = features_toml,
264 );
265 std::fs::write(build_dir.join("Cargo.toml"), manifest)?;
266 std::fs::write(src.join("lib.rs"), render_probe_lib(&b.types))?;
267 let cargo_lock = b.cargo_lock.clone().unwrap_or_else(default_cargo_lock);
268 std::fs::copy(&cargo_lock, build_dir.join("Cargo.lock"))
269 .with_context(|| format!("copying lockfile {} into probe", cargo_lock.display()))?;
270 Ok(())
271}
272
273fn build_probe(_b: &OpaqueTypes, build_dir: &Path, target: &str) -> Result<PathBuf> {
275 let mut cmd = std::process::Command::new(std::env::var("CARGO").unwrap_or("cargo".into()));
276 cmd.current_dir(build_dir)
277 .arg("build")
278 .arg("--offline")
279 .arg("--message-format=json-render-diagnostics")
280 .arg("--manifest-path")
281 .arg(build_dir.join("Cargo.toml"));
282 if !target.is_empty() {
283 cmd.arg("--target").arg(target);
284 }
285 cmd.arg("--target-dir").arg(build_dir.join("target"));
287 let out = cmd.output().context("spawning cargo for the probe crate")?;
288 if !out.status.success() {
289 bail!(
290 "probe build failed:\nstdout:\n{}\nstderr:\n{}",
291 String::from_utf8_lossy(&out.stdout),
292 String::from_utf8_lossy(&out.stderr),
293 );
294 }
295 let stdout = String::from_utf8_lossy(&out.stdout);
297 let mut rlib: Option<PathBuf> = None;
298 for line in stdout.lines() {
299 if line.contains("\"compiler-artifact\"") && line.contains(PROBE_CRATE) {
301 if let Some(p) = extract_first_rlib(line) {
302 rlib = Some(PathBuf::from(p));
303 }
304 }
305 }
306 rlib.ok_or_else(|| anyhow!("probe rlib artifact not found in cargo output"))
307}
308
309fn extract_first_rlib(line: &str) -> Option<String> {
311 let idx = line.find("\"filenames\"")?;
315 let rest = &line[idx..];
316 for tok in rest.split('"') {
317 if tok.ends_with(".rlib") {
318 return Some(json_unescape(tok));
319 }
320 }
321 None
322}
323
324fn json_unescape(s: &str) -> String {
327 let mut out = String::with_capacity(s.len());
328 let mut chars = s.chars();
329 while let Some(c) = chars.next() {
330 if c == '\\' {
331 match chars.next() {
332 Some('\\') => out.push('\\'),
333 Some('"') => out.push('"'),
334 Some('/') => out.push('/'),
335 Some(other) => {
336 out.push('\\');
337 out.push(other);
338 }
339 None => out.push('\\'),
340 }
341 } else {
342 out.push(c);
343 }
344 }
345 out
346}
347
348pub fn read_symbol_usize(artifact: &[u8], sym: &str) -> Result<usize> {
352 use object::{Object, ObjectSection, ObjectSymbol};
353
354 let with_underscore = format!("_{sym}");
355 let matches = |name: &str| name == sym || name == with_underscore;
356
357 let read_from = |obj: &object::File| -> Option<usize> {
358 let ptr_bytes = if obj.is_64() { 8 } else { 4 };
359 let s = obj
360 .symbols()
361 .find(|s| s.name().map(matches).unwrap_or(false))?;
362 let sec = obj.section_by_index(s.section_index()?).ok()?;
363 let data = sec.data().ok()?;
364 let off = s.address().checked_sub(sec.address())? as usize;
365 let bytes = data.get(off..off + ptr_bytes)?;
366 let value = match (ptr_bytes, obj.is_little_endian()) {
367 (4, true) => u32::from_le_bytes(bytes.try_into().ok()?) as u64,
368 (4, false) => u32::from_be_bytes(bytes.try_into().ok()?) as u64,
369 (8, true) => u64::from_le_bytes(bytes.try_into().ok()?),
370 (8, false) => u64::from_be_bytes(bytes.try_into().ok()?),
371 _ => return None,
372 };
373 usize::try_from(value).ok()
374 };
375
376 if let Ok(archive) = object::read::archive::ArchiveFile::parse(artifact) {
378 for member in archive.members() {
379 let member = member.map_err(|e| anyhow!("archive member: {e}"))?;
380 let data = member
381 .data(artifact)
382 .map_err(|e| anyhow!("archive member data: {e}"))?;
383 if let Ok(obj) = object::File::parse(data) {
384 if let Some(v) = read_from(&obj) {
385 return Ok(v);
386 }
387 }
388 }
389 } else if let Ok(obj) = object::File::parse(artifact) {
390 if let Some(v) = read_from(&obj) {
391 return Ok(v);
392 }
393 }
394 bail!("symbol `{sym}` not found in probe artifact")
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400
401 #[test]
402 fn probe_lib_emits_size_and_align_statics() {
403 let types = vec![OpaqueType::new("model::Message", "message_t")];
404 let s = render_probe_lib(&types);
405 assert!(s.contains(
406 "pub static OPAQUE_TYPES_SIZE_message_t: usize = ::core::mem::size_of::<model::Message>();"
407 ));
408 assert!(s.contains(
409 "pub static OPAQUE_TYPES_ALIGN_message_t: usize = ::core::mem::align_of::<model::Message>();"
410 ));
411 assert!(s.contains("#[no_mangle]") && s.contains("#[used]"));
412 }
413
414 #[test]
415 fn features_are_explicit_and_independent_of_defaults() {
416 let b = OpaqueTypes::new("source")
417 .features(["unstable", "shared-memory"])
418 .add(
419 syn::parse_quote!(model::Message),
420 syn::parse_quote!(message_t),
421 );
422 assert_eq!(b.features, vec!["unstable", "shared-memory"]);
423 assert!(!b.no_default_features);
424 assert_eq!(b.types.len(), 1);
425 assert_eq!(b.types[0].opaque_name, "message_t");
426 }
427
428 #[test]
429 fn invalid_mapping_is_rejected() {
430 let mappings = [OpaqueType::new("not a type!", "not::an::identifier")];
431 let error = validate_types(&mappings).unwrap_err().to_string();
432 assert!(error.contains("invalid Rust type expression"));
433 }
434
435 #[test]
436 fn opaque_struct_renders_repr_c_align() {
437 let s = render_opaque("z_zbytes_t", 32, 8);
438 assert!(s.contains("#[repr(C, align(8))]"));
439 assert!(s.contains("pub struct z_zbytes_t"));
440 assert!(s.contains("pub _0: [u8; 32]"));
441 }
442
443 #[test]
444 fn rlib_artifact_path_parsed_from_cargo_json() {
445 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}"#;
446 assert_eq!(
447 extract_first_rlib(line).as_deref(),
448 Some("/tmp/t/target/debug/deps/libopaque_types_probe-abc.rlib")
449 );
450 }
451
452 #[test]
453 fn rlib_artifact_path_windows_backslashes_unescaped() {
454 let line = r#"{"reason":"compiler-artifact","filenames":["C:\\proj\\target\\debug\\deps\\libopaque_types_probe-abc.rlib"]}"#;
456 assert_eq!(
457 extract_first_rlib(line).as_deref(),
458 Some(r"C:\proj\target\debug\deps\libopaque_types_probe-abc.rlib")
459 );
460 }
461
462 #[test]
463 fn generates_layout_from_a_temporary_source_package() -> Result<()> {
464 let temporary = tempfile::tempdir()?;
465 let source = temporary.path().join("model");
466 std::fs::create_dir_all(source.join("src"))?;
467 std::fs::write(
468 source.join("Cargo.toml"),
469 "[package]\nname = \"layout-model\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
470 )?;
471 std::fs::write(
472 source.join("src/lib.rs"),
473 "#[repr(C, align(16))]\npub struct Value(pub [u8; 3]);\n",
474 )?;
475 let lockfile = source.join("Cargo.lock");
476 std::fs::write(&lockfile, "version = 4\n")?;
477
478 let destination = temporary.path().join("generated/opaque_types.rs");
479 OpaqueTypes::new(&source)
480 .cargo_lock(lockfile)
481 .build_dir(temporary.path().join("probe"))
482 .add(
483 syn::parse_quote!(layout_model::Value),
484 syn::parse_quote!(opaque_value_t),
485 )
486 .generate(&destination)?;
487
488 let generated = std::fs::read_to_string(destination)?;
489 assert!(generated.contains("#[repr(C, align(16))]"));
490 assert!(generated.contains("pub struct opaque_value_t"));
491 assert!(generated.contains("pub _0: [u8; 16]"));
492 Ok(())
493 }
494
495 #[test]
496 fn failed_probe_does_not_replace_destination() -> Result<()> {
497 let temporary = tempfile::tempdir()?;
498 let source = temporary.path().join("model");
499 std::fs::create_dir_all(source.join("src"))?;
500 std::fs::write(
501 source.join("Cargo.toml"),
502 "[package]\nname = \"layout-model\"\nversion = \"0.0.0\"\nedition = \"2021\"\n",
503 )?;
504 std::fs::write(source.join("src/lib.rs"), "pub struct Value;\n")?;
505 let lockfile = source.join("Cargo.lock");
506 std::fs::write(&lockfile, "version = 4\n")?;
507 let destination = temporary.path().join("opaque_types.rs");
508 std::fs::write(&destination, "existing output\n")?;
509
510 let result = OpaqueTypes::new(&source)
511 .cargo_lock(lockfile)
512 .build_dir(temporary.path().join("probe"))
513 .add(
514 syn::parse_quote!(layout_model::Value),
515 syn::parse_quote!(opaque_value_t),
516 )
517 .add(
518 syn::parse_quote!(layout_model::Missing),
519 syn::parse_quote!(missing_t),
520 )
521 .generate(&destination);
522
523 assert!(result.is_err());
524 assert_eq!(std::fs::read_to_string(destination)?, "existing output\n");
525 Ok(())
526 }
527}