opaque-types 0.1.0

Generate target-layout-compatible opaque Rust types at build time
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]

use std::path::{Path, PathBuf};

use anyhow::{anyhow, bail, Context, Result};

/// One source type and the opaque struct identifier generated for it.
#[derive(Clone, Debug)]
pub struct OpaqueType {
    /// Rust type expression as visible from the probe crate.
    pub rust_path: String,
    /// Emitted opaque struct identifier.
    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(),
        }
    }
}

/// Builder for generating layout-compatible opaque structs.
///
/// See the [crate-level documentation](crate) for exact parameter formats and
/// workspace setup.
#[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 {
    /// Creates a generator for the package whose `Cargo.toml` is inside
    /// `source_manifest_dir`.
    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,
        }
    }

    /// Sets explicit, unqualified Cargo feature names for the source dependency.
    ///
    /// Each item must match a key in the source package's `[features]` table.
    /// This setting is independent of [`Self::default_features`].
    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
    }

    /// Controls whether the source dependency's default features are enabled.
    /// Defaults to `true`.
    pub fn default_features(mut self, enabled: bool) -> Self {
        self.no_default_features = !enabled;
        self
    }

    /// Adds a source Rust type and its generated opaque struct type.
    ///
    /// `rust_type` must be resolvable from the probe crate. `opaque_type` must
    /// contain one unqualified Rust identifier.
    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
    }

    /// Override the `Cargo.lock` copied into the probe crate (default: the
    /// destination workspace's lock, located via
    /// [`get-cargo-lock`](https://crates.io/crates/get-cargo-lock)).
    pub fn cargo_lock(mut self, path: impl Into<PathBuf>) -> Self {
        self.cargo_lock = Some(path.into());
        self
    }

    /// Override the probe build directory (default: `$OUT_DIR/opaque_probe`).
    pub fn build_dir(mut self, path: impl Into<PathBuf>) -> Self {
        self.build_dir = Some(path.into());
        self
    }

    /// Probes every requested type and writes the generated Rust source to
    /// `destination`.
    ///
    /// The destination is written only after every requested type's size and
    /// alignment has been read successfully. Failure to build or inspect any
    /// requested type returns an error and leaves an existing destination file
    /// unchanged.
    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";

/// Symbol name for a probed quantity (`"SIZE"` / `"ALIGN"`) of an opaque type.
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(())
}

/// Render the probe crate's `lib.rs`: one `#[no_mangle] static usize` per quantity.
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
}

/// Renders one `#[repr(C, align)]` opaque storage struct.
///
/// The result has the supplied size and alignment but defines no conversion
/// behavior or representation invariant.
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"
    )
}

/// The consuming project's `Cargo.lock`, so the probe uses the same resolution.
///
/// The path comes from [`get_cargo_lock::get_cargo_lock`]. This dependency is
/// patched to the proxy installed in the destination workspace, allowing this
/// externally developed crate to obtain that workspace's lockfile. The consumer
/// must have run `cargo get-cargo-lock install` (otherwise `get_cargo_lock`
/// panics with guidance — there is intentionally no silent fallback).
fn default_cargo_lock() -> PathBuf {
    get_cargo_lock::get_cargo_lock()
}

/// Read the `[package].name` of the crate whose manifest dir is `manifest_dir`.
/// It is the `[dependencies]` key the probe must use for a path dependency.
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()
            )
        })
}

/// Write the probe crate (`Cargo.toml` + `src/lib.rs`, and `Cargo.lock` if given).
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(())
}

/// Build the probe crate for `$TARGET` and return the path to its rlib.
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);
    }
    // Isolate the probe's target dir from the consumer's (avoid lock contention).
    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),
        );
    }
    // Parse cargo JSON for the probe crate's rlib artifact.
    let stdout = String::from_utf8_lossy(&out.stdout);
    let mut rlib: Option<PathBuf> = None;
    for line in stdout.lines() {
        // Minimal JSON scan (avoid a serde dep): look for the probe's artifact line.
        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"))
}

/// Pull the first `.rlib` path out of a cargo `compiler-artifact` JSON line.
fn extract_first_rlib(line: &str) -> Option<String> {
    // `"filenames":["...rlib", ...]` — find the first quoted token ending in .rlib.
    // Cargo emits JSON, so on Windows the path's backslashes arrive escaped
    // (`C:\\…\\libprobe.rlib`); unescape the standard JSON sequences before use.
    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
}

/// Minimal JSON string unescaping for the path tokens cargo emits (`\\`, `\"`,
/// `\/`). Sufficient for filesystem paths; not a general JSON unescaper.
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
}

/// Read the `usize` value of a `#[no_mangle] static` named `sym` from a compiled
/// rlib/object (an `ar` archive of object files). Tries the bare name and the
/// Mach-O `_`-prefixed variant.
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()
    };

    // rlib / .a is an ar archive of object members; plain .o parses directly.
    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() {
        // Cargo JSON escapes Windows backslashes as `\\`.
        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(())
    }
}