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(×tamp_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}