Skip to main content

ver_shim_build/
lib.rs

1//! Build script helper for injecting version data into binaries.
2//!
3//! This crate provides utilities for use in `build.rs` scripts to inject
4//! git version information into artifact dependency binaries:
5//!
6//! - Git SHA (`git rev-parse HEAD`)
7//! - Git describe (`git describe --always --dirty`)
8//! - Git branch (`git rev-parse --abbrev-ref HEAD`)
9//!
10//! # Requirements
11//!
12//! This crate requires Cargo's unstable [artifact dependencies] feature (bindeps).
13//! You must use nightly Cargo and enable it in `.cargo/config.toml`:
14//!
15//! ```toml
16//! [unstable]
17//! bindeps = true
18//! ```
19//!
20//! [artifact dependencies]: https://doc.rust-lang.org/cargo/reference/unstable.html#artifact-dependencies
21//!
22//! # Example
23//!
24//! In your `build.rs`:
25//! ```ignore
26//! use ver_shim_build::LinkSection;
27//!
28//! fn main() {
29//!     // Patch an artifact dependency binary (uses CARGO_BIN_FILE_* env vars)
30//!     LinkSection::new()
31//!         .with_all_git()
32//!         .patch_into_bin_dep("my-dep", "my-bin")
33//!         .write_to_target_profile_dir();
34//!
35//!     // Or patch a binary at a specific path
36//!     LinkSection::new()
37//!         .with_all_git()
38//!         .patch_into("/path/to/binary")
39//!         .write_to_target_profile_dir();
40//!
41//!     // Or with a custom output name
42//!     LinkSection::new()
43//!         .with_all_git()
44//!         .patch_into_bin_dep("my-dep", "my-bin")
45//!         .with_filename("my-custom-name")
46//!         .write_to_target_profile_dir();
47//!
48//!     // Or just write the section data file (for use with cargo-objcopy)
49//!     LinkSection::new()
50//!         .with_all_git()
51//!         .write_to_out_dir();
52//! }
53//! ```
54
55/// Cargo build script helper functions.
56mod cargo_helpers;
57
58/// LLVM tools wrapper for section manipulation.
59mod llvm_tools;
60
61/// Helper to find LLVM tools, based on code in cargo-binutils.
62mod rustc;
63
64/// Update section command for patching artifact dependency binaries.
65mod update_section;
66
67pub use llvm_tools::LlvmTools;
68pub use update_section::UpdateSectionCommand;
69
70use chrono::{DateTime, FixedOffset, TimeZone, Utc};
71use std::fs;
72use std::path::{Path, PathBuf};
73use std::process::Command;
74use ver_shim::{BUFFER_SIZE, Member, header_size};
75
76use cargo_helpers::{cargo_rerun_if, cargo_warning};
77
78/// Builder for configuring which git information to include in version sections.
79///
80/// Use this to select which git info to collect, then either:
81/// - Call `write_to()` or `write_to_out_dir()` to just write the section data file
82/// - Call `patch_into()` to get an `UpdateSectionCommand` for patching a binary
83#[derive(Default)]
84#[must_use]
85pub struct LinkSection {
86    include_git_sha: bool,
87    include_git_describe: bool,
88    include_git_branch: bool,
89    include_git_commit_timestamp: bool,
90    include_git_commit_date: bool,
91    include_git_commit_msg: bool,
92    include_build_timestamp: bool,
93    include_build_date: bool,
94    fail_on_error: bool,
95    custom: Option<String>,
96    buffer_size: Option<usize>,
97}
98
99impl LinkSection {
100    /// Creates a new empty `LinkSection`
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Includes the git SHA (`git rev-parse HEAD`) in the section data.
106    pub fn with_git_sha(mut self) -> Self {
107        self.include_git_sha = true;
108        self
109    }
110
111    /// Includes the git describe output (`git describe --always --dirty`) in the section data.
112    pub fn with_git_describe(mut self) -> Self {
113        self.include_git_describe = true;
114        self
115    }
116
117    /// Includes the git branch name (`git rev-parse --abbrev-ref HEAD`) in the section data.
118    pub fn with_git_branch(mut self) -> Self {
119        self.include_git_branch = true;
120        self
121    }
122
123    /// Includes the git commit timestamp (RFC 3339 format) in the section data.
124    pub fn with_git_commit_timestamp(mut self) -> Self {
125        self.include_git_commit_timestamp = true;
126        self
127    }
128
129    /// Includes the git commit date (YYYY-MM-DD format) in the section data.
130    pub fn with_git_commit_date(mut self) -> Self {
131        self.include_git_commit_date = true;
132        self
133    }
134
135    /// Includes the git commit message (first line, max 100 chars) in the section data.
136    pub fn with_git_commit_msg(mut self) -> Self {
137        self.include_git_commit_msg = true;
138        self
139    }
140
141    /// Includes all git information in the section data.
142    pub fn with_all_git(mut self) -> Self {
143        self.include_git_sha = true;
144        self.include_git_describe = true;
145        self.include_git_branch = true;
146        self.include_git_commit_timestamp = true;
147        self.include_git_commit_date = true;
148        self.include_git_commit_msg = true;
149        self
150    }
151
152    /// Includes the build timestamp (RFC 3339 format, UTC) in the section data.
153    pub fn with_build_timestamp(mut self) -> Self {
154        self.include_build_timestamp = true;
155        self
156    }
157
158    /// Includes the build date (YYYY-MM-DD format, UTC) in the section data.
159    pub fn with_build_date(mut self) -> Self {
160        self.include_build_date = true;
161        self
162    }
163
164    /// Includes all build time information (timestamp and date) in the section data.
165    pub fn with_all_build_time(mut self) -> Self {
166        self.include_build_timestamp = true;
167        self.include_build_date = true;
168        self
169    }
170
171    /// Enables fail-on-error mode.
172    ///
173    /// By default, if git commands fail (e.g., `git` not found, not in a git repository,
174    /// building from a source tarball without `.git`), a `cargo:warning` is emitted and
175    /// the corresponding data is skipped. This allows builds to succeed even without git.
176    ///
177    /// When `fail_on_error()` is called, git failures will instead cause a panic,
178    /// failing the build.
179    pub fn fail_on_error(mut self) -> Self {
180        self.fail_on_error = true;
181        self
182    }
183
184    /// Sets a custom application-specific string to embed in the binary.
185    ///
186    /// This can be any string your application wants to store. The total size of all
187    /// data (including git info, timestamps, and custom string) must fit within the
188    /// buffer size (default 512 bytes). If you need more space, set the
189    /// `VER_SHIM_BUFFER_SIZE` environment variable when building.
190    ///
191    /// As with any build script, you must emit `cargo:rerun-if-...` directives as
192    /// needed if you read files or environment variables to build your custom string.
193    ///
194    /// Access this at runtime with `ver_shim::custom()`.
195    pub fn with_custom(mut self, s: impl Into<String>) -> Self {
196        self.custom = Some(s.into());
197        self
198    }
199
200    /// Sets the buffer size for the section data.
201    ///
202    /// This should match the buffer size used when building the target binary.
203    /// If not set, falls back to:
204    /// 1. `VER_SHIM_BUFFER_SIZE` environment variable (at runtime)
205    /// 2. The `BUFFER_SIZE` constant from ver-shim (default 512)
206    pub fn with_buffer_size(mut self, size: usize) -> Self {
207        self.buffer_size = Some(size);
208        self
209    }
210
211    /// Gets the effective buffer size to use.
212    fn effective_buffer_size(&self) -> usize {
213        self.buffer_size
214            .or_else(|| {
215                std::env::var("VER_SHIM_BUFFER_SIZE")
216                    .ok()
217                    .and_then(|s| s.parse().ok())
218            })
219            .unwrap_or(BUFFER_SIZE)
220    }
221
222    /// Builds the section data as bytes.
223    ///
224    /// This collects all enabled version info and builds the binary section data.
225    /// Does not write to any file.
226    pub fn build_section_bytes(self) -> Vec<u8> {
227        self.check_enabled();
228
229        // Emit rerun-if-changed directives for git state (only if git data requested)
230        if self.any_git_enabled() {
231            emit_git_rerun_if_changed();
232        }
233
234        // Collect the data for each member
235        let mut member_data: [Option<String>; Member::COUNT] = Default::default();
236
237        if self.include_git_sha
238            && let Some(git_sha) = get_git_sha(self.fail_on_error)
239        {
240            eprintln!("ver-shim-build: git SHA = {}", git_sha);
241            member_data[Member::GitSha as usize] = Some(git_sha);
242        }
243
244        if self.include_git_describe
245            && let Some(git_describe) = get_git_describe(self.fail_on_error)
246        {
247            eprintln!("ver-shim-build: git describe = {}", git_describe);
248            member_data[Member::GitDescribe as usize] = Some(git_describe);
249        }
250
251        if self.include_git_branch
252            && let Some(git_branch) = get_git_branch(self.fail_on_error)
253        {
254            eprintln!("ver-shim-build: git branch = {}", git_branch);
255            member_data[Member::GitBranch as usize] = Some(git_branch);
256        }
257
258        if (self.include_git_commit_timestamp || self.include_git_commit_date)
259            && let Some(timestamp) = get_git_commit_timestamp(self.fail_on_error)
260        {
261            if self.include_git_commit_timestamp {
262                let rfc3339 = timestamp.to_rfc3339();
263                eprintln!("ver-shim-build: git commit timestamp = {}", rfc3339);
264                member_data[Member::GitCommitTimestamp as usize] = Some(rfc3339);
265            }
266            if self.include_git_commit_date {
267                let date = timestamp.date_naive().to_string();
268                eprintln!("ver-shim-build: git commit date = {}", date);
269                member_data[Member::GitCommitDate as usize] = Some(date);
270            }
271        }
272
273        if self.include_git_commit_msg
274            && let Some(msg) = get_git_commit_msg(self.fail_on_error)
275        {
276            eprintln!("ver-shim-build: git commit msg = {}", msg);
277            member_data[Member::GitCommitMsg as usize] = Some(msg);
278        }
279
280        if self.any_build_time_enabled() {
281            // Emit rerun-if-env-changed for reproducible build options
282            cargo_rerun_if("env-changed=VER_SHIM_IDEMPOTENT");
283            cargo_rerun_if("env-changed=VER_SHIM_BUILD_TIME");
284
285            // VER_SHIM_IDEMPOTENT takes precedence: if set, never include build time
286            if std::env::var("VER_SHIM_IDEMPOTENT").is_ok() {
287                eprintln!("ver-shim-build: VER_SHIM_IDEMPOTENT is set, skipping build timestamp/date");
288            } else {
289                let build_time = get_build_time();
290                if self.include_build_timestamp {
291                    let rfc3339 = build_time.to_rfc3339();
292                    eprintln!("ver-shim-build: build timestamp = {}", rfc3339);
293                    member_data[Member::BuildTimestamp as usize] = Some(rfc3339);
294                }
295                if self.include_build_date {
296                    let date = build_time.date_naive().to_string();
297                    eprintln!("ver-shim-build: build date = {}", date);
298                    member_data[Member::BuildDate as usize] = Some(date);
299                }
300            }
301        }
302
303        if let Some(ref custom) = self.custom {
304            eprintln!("ver-shim-build: custom = {}", custom);
305            member_data[Member::Custom as usize] = Some(custom.clone());
306        }
307
308        // Build the section buffer
309        let buffer_size = self.effective_buffer_size();
310        build_section_buffer(&member_data, buffer_size)
311    }
312    /// Writes the section data file to the specified path.
313    ///
314    /// If the path is a directory, writes to `{path}/ver_shim_data`.
315    /// Otherwise writes directly to the path.
316    ///
317    /// This is useful for `cargo objcopy` workflows where you want to manually
318    /// run objcopy with the generated section file.
319    ///
320    /// Returns the path to the written file.
321    pub fn write_to(self, path: impl AsRef<Path>) -> PathBuf {
322        self.write_section_to_path(path.as_ref())
323    }
324
325    /// Writes the section data file to `OUT_DIR/ver_shim_data`.
326    ///
327    /// This is a convenience method for use in build scripts.
328    ///
329    /// Returns the path to the written file.
330    pub fn write_to_out_dir(self) -> PathBuf {
331        let out_dir = cargo_helpers::out_dir();
332        self.write_section_to_path(&out_dir)
333    }
334
335    /// Writes the section data file to the `target/` directory.
336    /// Returns the path to the written file (e.g., `target/ver_shim_data`).
337    ///
338    /// This is useful for `cargo objcopy` workflows where you want to run:
339    /// ```bash
340    /// cargo objcopy --release --bin my_bin -- --update-section .ver_shim_data=target/ver_shim_data my_bin.bin
341    /// ```
342    ///
343    /// The target directory is determined by checking `CARGO_TARGET_DIR` first,
344    /// then inferring from `OUT_DIR`. The result should typically be `target/ver_shim_data`.
345    ///
346    /// When cross-compiling, it might end up in `target/<triple>/ver_shim_data`, due to
347    /// how the inference works.
348    ///
349    /// To adjust this, you can set `CARGO_TARGET_DIR` in `.cargo/config.toml`:
350    /// ```toml
351    /// [env]
352    /// CARGO_TARGET_DIR = { value = "target", relative = true }
353    /// ```
354    pub fn write_to_target_dir(self) -> PathBuf {
355        let target_dir = cargo_helpers::target_dir();
356        self.write_section_to_path(&target_dir)
357    }
358
359    /// Transitions to an `UpdateSectionCommand` for patching a binary at the given path.
360    ///
361    /// # Arguments
362    /// * `binary_path` - Path to the binary to patch
363    pub fn patch_into(self, binary_path: impl AsRef<Path>) -> UpdateSectionCommand {
364        UpdateSectionCommand {
365            link_section: self,
366            bin_path: binary_path.as_ref().to_path_buf(),
367            new_name: None,
368        }
369    }
370
371    /// Transitions to an `UpdateSectionCommand` for patching an artifact dependency binary.
372    ///
373    /// This is a convenience method for use with Cargo's artifact dependencies feature.
374    /// It finds the binary using the `CARGO_BIN_FILE_<DEP>_<NAME>` environment variables
375    /// that Cargo sets for artifact dependencies.
376    ///
377    /// # Arguments
378    /// * `dep_name` - The name of the dependency as specified in Cargo.toml
379    /// * `bin_name` - The name of the binary within the dependency
380    pub fn patch_into_bin_dep(self, dep_name: &str, bin_name: &str) -> UpdateSectionCommand {
381        let bin_path = cargo_helpers::find_artifact_binary(dep_name, bin_name);
382        self.patch_into(bin_path)
383    }
384
385    fn any_git_enabled(&self) -> bool {
386        self.include_git_sha
387            || self.include_git_describe
388            || self.include_git_branch
389            || self.include_git_commit_timestamp
390            || self.include_git_commit_date
391            || self.include_git_commit_msg
392    }
393
394    fn any_build_time_enabled(&self) -> bool {
395        self.include_build_timestamp || self.include_build_date
396    }
397
398    fn check_enabled(&self) {
399        if !self.any_git_enabled() && !self.any_build_time_enabled() && self.custom.is_none() {
400            panic!(
401                "ver-shim-build: no version info enabled. Call with_git_sha(), with_git_describe(), \
402                 with_git_branch(), with_git_commit_timestamp(), with_git_commit_date(), \
403                 with_git_commit_msg(), with_all_git(), with_build_timestamp(), with_build_date(), \
404                 or with_custom() before writing."
405            );
406        }
407    }
408
409    pub(crate) fn write_section_to_path(self, path: &Path) -> PathBuf {
410        let buffer = self.build_section_bytes();
411
412        // Write to file - if path is a directory, append ver_shim_data
413        let output_path = if path.is_dir() {
414            path.join("ver_shim_data")
415        } else {
416            path.to_path_buf()
417        };
418        fs::write(&output_path, &buffer).expect("ver-shim-build: failed to write section file");
419
420        output_path
421    }
422}
423
424/// Builds the section buffer from member data.
425///
426/// Format:
427/// - First byte: number of members (Member::COUNT) for forward compatibility
428/// - Next `Member::COUNT * 2` bytes: header with end offsets (u16, little-endian, relative to header)
429/// - Remaining bytes: concatenated string data
430///
431/// Header size = 1 + Member::COUNT * 2
432///
433/// For member N:
434/// - start = header_size + end[N-1] if N > 0, else header_size
435/// - end = header_size + end[N]
436/// - If start == end, the member is not present.
437///
438/// Using relative offsets means a zero-initialized buffer reads as "all members absent".
439/// The num_members byte enables forward compatibility: old sections can be read by new code.
440fn build_section_buffer(member_data: &[Option<String>; Member::COUNT], buffer_size: usize) -> Vec<u8> {
441    let mut buffer = vec![0u8; buffer_size];
442    let header_sz = header_size(Member::COUNT);
443
444    // First byte: number of members
445    buffer[0] = Member::COUNT as u8;
446
447    // Data starts after the header; track position relative to header_size
448    let mut relative_offset: usize = 0;
449
450    for (idx, data) in member_data.iter().enumerate() {
451        if let Some(s) = data {
452            let bytes = s.as_bytes();
453            let absolute_start = header_sz + relative_offset;
454            let absolute_end = absolute_start + bytes.len();
455
456            if absolute_end > buffer_size {
457                panic!(
458                    "ver-shim-build: section data too large ({} bytes, max {}). \
459                     Use with_buffer_size() or set VER_SHIM_BUFFER_SIZE env var to increase.",
460                    absolute_end, buffer_size
461                );
462            }
463
464            // Write the data
465            buffer[absolute_start..absolute_end].copy_from_slice(bytes);
466
467            relative_offset += bytes.len();
468        }
469
470        // Write the end offset for this member (relative to header_size)
471        // If member is not present, end == previous end, so start == end indicates "not present"
472        // Offset positions start at byte 1 (after the num_members byte)
473        let header_offset = 1 + idx * 2;
474        buffer[header_offset..header_offset + 2]
475            .copy_from_slice(&(relative_offset as u16).to_le_bytes());
476    }
477
478    buffer
479}
480
481// ============================================================================
482// Helper functions
483// ============================================================================
484
485/// Emits cargo rerun-if-changed directives for git state files.
486/// This ensures the build script reruns when the git HEAD or refs change.
487/// Matches vergen's behavior: watches .git/HEAD and .git/<ref_path>.
488///
489/// See: https://doc.rust-lang.org/cargo/reference/build-scripts.html#rerun-if-changed
490fn emit_git_rerun_if_changed() {
491    // Find the git directory
492    let git_dir = match find_git_dir() {
493        Some(dir) => dir,
494        None => return,
495    };
496
497    // Always watch .git/HEAD
498    let head_path = git_dir.join("HEAD");
499    if head_path.exists() {
500        cargo_rerun_if(&format!("changed={}", head_path.display()));
501
502        // If HEAD points to a ref, also watch that ref file
503        if let Ok(head_contents) = fs::read_to_string(&head_path) {
504            let head_contents = head_contents.trim();
505            if let Some(ref_path) = head_contents.strip_prefix("ref: ") {
506                let ref_file = git_dir.join(ref_path);
507                if ref_file.exists() {
508                    cargo_rerun_if(&format!("changed={}", ref_file.display()));
509                }
510            }
511        }
512    }
513}
514
515/// Finds the .git directory by walking up from the current directory.
516fn find_git_dir() -> Option<PathBuf> {
517    let mut dir = std::env::current_dir().ok()?;
518    loop {
519        let git_dir = dir.join(".git");
520        if git_dir.is_dir() {
521            return Some(git_dir);
522        }
523        if !dir.pop() {
524            return None;
525        }
526    }
527}
528
529/// Gets the current git SHA using `git rev-parse HEAD`.
530fn get_git_sha(fail_on_error: bool) -> Option<String> {
531    run_git_command(&["rev-parse", "HEAD"], fail_on_error)
532}
533
534/// Gets the git describe output using `git describe --always --dirty`.
535fn get_git_describe(fail_on_error: bool) -> Option<String> {
536    run_git_command(&["describe", "--always", "--dirty"], fail_on_error)
537}
538
539/// Gets the current git branch using `git rev-parse --abbrev-ref HEAD`.
540fn get_git_branch(fail_on_error: bool) -> Option<String> {
541    run_git_command(&["rev-parse", "--abbrev-ref", "HEAD"], fail_on_error)
542}
543
544/// Gets the git commit timestamp as a chrono DateTime.
545fn get_git_commit_timestamp(fail_on_error: bool) -> Option<DateTime<FixedOffset>> {
546    // Get the author date in ISO 8601 strict format
547    let timestamp_str = run_git_command(&["log", "-1", "--format=%aI"], fail_on_error)?;
548    match DateTime::parse_from_rfc3339(&timestamp_str) {
549        Ok(dt) => Some(dt),
550        Err(e) => {
551            let msg = format!(
552                "ver-shim-build: failed to parse git timestamp '{}': {}",
553                timestamp_str, e
554            );
555            if fail_on_error {
556                panic!("{}", msg);
557            } else {
558                cargo_warning(&msg);
559                None
560            }
561        }
562    }
563}
564
565/// Gets the first line of the git commit message, truncated to 100 chars.
566fn get_git_commit_msg(fail_on_error: bool) -> Option<String> {
567    let msg = run_git_command(&["log", "-1", "--format=%s"], fail_on_error)?;
568    // Truncate to 100 chars to leave room in the buffer
569    Some(if msg.len() > 100 {
570        let mut end = 100;
571        while !msg.is_char_boundary(end) && end > 0 {
572            end -= 1;
573        }
574        msg[..end].to_string()
575    } else {
576        msg
577    })
578}
579
580/// Gets the build time, either from VER_SHIM_BUILD_TIME env var or Utc::now().
581///
582/// If VER_SHIM_BUILD_TIME is set, it tries to parse it as:
583/// 1. An integer (unix timestamp in seconds)
584/// 2. An RFC 3339 datetime string
585///
586/// This supports reproducible builds by allowing a fixed build time.
587fn get_build_time() -> DateTime<Utc> {
588    if let Ok(val) = std::env::var("VER_SHIM_BUILD_TIME") {
589        // Try parsing as unix timestamp (integer) first
590        if let Ok(ts) = val.parse::<i64>() {
591            let dt = Utc.timestamp_opt(ts, 0).single().unwrap_or_else(|| {
592                panic!(
593                    "ver-shim-build: VER_SHIM_BUILD_TIME '{}' is not a valid unix timestamp",
594                    val
595                )
596            });
597            eprintln!(
598                "ver-shim-build: using VER_SHIM_BUILD_TIME={} (unix timestamp), overriding Utc::now()",
599                val
600            );
601            return dt;
602        }
603
604        // Try parsing as RFC 3339
605        if let Ok(dt) = DateTime::parse_from_rfc3339(&val) {
606            eprintln!(
607                "ver-shim-build: using VER_SHIM_BUILD_TIME={} (RFC 3339), overriding Utc::now()",
608                val
609            );
610            return dt.with_timezone(&Utc);
611        }
612
613        panic!(
614            "ver-shim-build: VER_SHIM_BUILD_TIME '{}' is not a valid unix timestamp or RFC 3339 datetime",
615            val
616        );
617    }
618
619    Utc::now()
620}
621
622/// Runs a git command and returns stdout as a trimmed string.
623///
624/// If `fail_on_error` is true, panics on failure. Otherwise, emits a cargo warning
625/// and returns None, allowing builds to succeed without git.
626fn run_git_command(args: &[&str], fail_on_error: bool) -> Option<String> {
627    let cmd = format!("git {}", args.join(" "));
628    let output = match Command::new("git").args(args).output() {
629        Ok(output) => output,
630        Err(e) => {
631            let msg = format!("ver-shim-build: failed to execute '{}': {}", cmd, e);
632            if fail_on_error {
633                panic!("{}", msg);
634            } else {
635                cargo_warning(&msg);
636                return None;
637            }
638        }
639    };
640
641    if !output.status.success() {
642        let stderr = String::from_utf8_lossy(&output.stderr);
643        let msg = format!(
644            "ver-shim-build: '{}' failed with status {}: {}",
645            cmd,
646            output.status,
647            stderr.trim()
648        );
649        if fail_on_error {
650            panic!("{}", msg);
651        } else {
652            cargo_warning(&msg);
653            return None;
654        }
655    }
656
657    match String::from_utf8(output.stdout) {
658        Ok(s) => Some(s.trim().to_string()),
659        Err(_) => {
660            let msg = format!("ver-shim-build: '{}' output is not valid UTF-8", cmd);
661            if fail_on_error {
662                panic!("{}", msg);
663            } else {
664                cargo_warning(&msg);
665                None
666            }
667        }
668    }
669}