Skip to main content

opaque_types/
lib.rs

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/// One source type and the opaque struct identifier generated for it.
9#[derive(Clone, Debug)]
10pub struct OpaqueType {
11    /// Rust type expression as visible from the probe crate.
12    pub rust_path: String,
13    /// Emitted opaque struct identifier.
14    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/// Builder for generating layout-compatible opaque structs.
27///
28/// See the [crate-level documentation](crate) for exact parameter formats and
29/// workspace setup.
30#[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    /// Creates a generator for the package whose `Cargo.toml` is inside
42    /// `source_manifest_dir`.
43    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    /// Sets explicit, unqualified Cargo feature names for the source dependency.
56    ///
57    /// Each item must match a key in the source package's `[features]` table.
58    /// This setting is independent of [`Self::default_features`].
59    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    /// Controls whether the source dependency's default features are enabled.
69    /// Defaults to `true`.
70    pub fn default_features(mut self, enabled: bool) -> Self {
71        self.no_default_features = !enabled;
72        self
73    }
74
75    /// Adds a source Rust type and its generated opaque struct type.
76    ///
77    /// `rust_type` must be resolvable from the probe crate. `opaque_type` must
78    /// contain one unqualified Rust identifier.
79    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    /// Override the `Cargo.lock` copied into the probe crate (default: the
89    /// destination workspace's lock, located via
90    /// [`get-cargo-lock`](https://crates.io/crates/get-cargo-lock)).
91    pub fn cargo_lock(mut self, path: impl Into<PathBuf>) -> Self {
92        self.cargo_lock = Some(path.into());
93        self
94    }
95
96    /// Override the probe build directory (default: `$OUT_DIR/opaque_probe`).
97    pub fn build_dir(mut self, path: impl Into<PathBuf>) -> Self {
98        self.build_dir = Some(path.into());
99        self
100    }
101
102    /// Probes every requested type and writes the generated Rust source to
103    /// `destination`.
104    ///
105    /// The destination is written only after every requested type's size and
106    /// alignment has been read successfully. Failure to build or inspect any
107    /// requested type returns an error and leaves an existing destination file
108    /// unchanged.
109    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
144/// Symbol name for a probed quantity (`"SIZE"` / `"ALIGN"`) of an opaque type.
145fn 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
163/// Render the probe crate's `lib.rs`: one `#[no_mangle] static usize` per quantity.
164pub 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
181/// Renders one `#[repr(C, align)]` opaque storage struct.
182///
183/// The result has the supplied size and alignment but defines no conversion
184/// behavior or representation invariant.
185pub 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
192/// The consuming project's `Cargo.lock`, so the probe uses the same resolution.
193///
194/// The path comes from [`get_cargo_lock::get_cargo_lock`]. This dependency is
195/// patched to the proxy installed in the destination workspace, allowing this
196/// externally developed crate to obtain that workspace's lockfile. The consumer
197/// must have run `cargo get-cargo-lock install` (otherwise `get_cargo_lock`
198/// panics with guidance — there is intentionally no silent fallback).
199fn default_cargo_lock() -> PathBuf {
200    get_cargo_lock::get_cargo_lock()
201}
202
203/// Read the `[package].name` of the crate whose manifest dir is `manifest_dir`.
204/// It is the `[dependencies]` key the probe must use for a path dependency.
205fn 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
225/// Write the probe crate (`Cargo.toml` + `src/lib.rs`, and `Cargo.lock` if given).
226fn 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
273/// Build the probe crate for `$TARGET` and return the path to its rlib.
274fn 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    // Isolate the probe's target dir from the consumer's (avoid lock contention).
286    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    // Parse cargo JSON for the probe crate's rlib artifact.
296    let stdout = String::from_utf8_lossy(&out.stdout);
297    let mut rlib: Option<PathBuf> = None;
298    for line in stdout.lines() {
299        // Minimal JSON scan (avoid a serde dep): look for the probe's artifact line.
300        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
309/// Pull the first `.rlib` path out of a cargo `compiler-artifact` JSON line.
310fn extract_first_rlib(line: &str) -> Option<String> {
311    // `"filenames":["...rlib", ...]` — find the first quoted token ending in .rlib.
312    // Cargo emits JSON, so on Windows the path's backslashes arrive escaped
313    // (`C:\\…\\libprobe.rlib`); unescape the standard JSON sequences before use.
314    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
324/// Minimal JSON string unescaping for the path tokens cargo emits (`\\`, `\"`,
325/// `\/`). Sufficient for filesystem paths; not a general JSON unescaper.
326fn 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
348/// Read the `usize` value of a `#[no_mangle] static` named `sym` from a compiled
349/// rlib/object (an `ar` archive of object files). Tries the bare name and the
350/// Mach-O `_`-prefixed variant.
351pub 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    // rlib / .a is an ar archive of object members; plain .o parses directly.
377    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        // Cargo JSON escapes Windows backslashes as `\\`.
455        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}