Skip to main content

ver_stub/
lib.rs

1//! Runtime access to version data injected via a link section.
2//!
3//! This crate provides a way to access build-time information that has been
4//! injected into the binary via a link section
5//! (`ver_stub` on ELF/COFF, `__TEXT,ver_stub` on Mach-O).
6//!
7//! Use its functions
8//!
9//! ```ignore
10//! fn git_sha() -> Option<&str>;
11//! fn git_describe() -> Option<&str>;
12//! fn build_timestamp() -> Option<&str>;
13//! ...
14//! ```
15//!
16//! to read fields from the section if they are present.
17//!
18//! Then use `ver-stub-build` or `ver-stub-tool` to write the link section into the
19//! binary at the end of your build.
20//!
21//! ## Details
22//!
23//! The section format is:
24//! - First byte: number of members in the section (for forward compatibility)
25//! - Next `num_members * 2` bytes: array of end offsets (u16, little-endian, relative to header)
26//! - Remaining bytes: concatenated string data
27//!
28//! Header size = 1 + num_members * 2
29//!
30//! For member N:
31//! - start = header_size + end[N-1] if N > 0, else header_size
32//! - end = header_size + end[N]
33//! - If start == end, the member is not present.
34//! - If N >= num_members (from first byte), the member is not present.
35//!
36//! Using relative offsets means a zero-initialized buffer reads as "all members absent".
37//! The num_members byte enables forward and backwards compatibility: old sections can be read by new code
38//! which has more members added in the future, and new sections can be read by old code as well,
39//! as long as we never change the index of any existing member.
40
41#![no_std]
42
43// Size of the version data buffer in bytes.
44// Can be overridden by setting VER_STUB_BUFFER_SIZE env var at compile time.
45// Parsed as u16 since offsets in the header are u16 (max buffer size is 65535).
46#[doc(hidden)]
47pub const BUFFER_SIZE: usize = match option_env!("VER_STUB_BUFFER_SIZE") {
48    Some(s) => match u16::from_str_radix(s, 10) {
49        Ok(n) => n as usize,
50        Err(_) => panic!("VER_STUB_BUFFER_SIZE must be a valid u16 integer (0-65535)"),
51    },
52    None => 512,
53};
54
55// Calculate header size for a given number of members.
56// Header = 1 byte (num_members) + 2 bytes per member (end offsets).
57#[doc(hidden)]
58pub const fn header_size(num_members: usize) -> usize {
59    1 + num_members * 2
60}
61
62// Compile-time checks for buffer size validity.
63// We use 32 as a minimum threshold because:
64// - The header must fit (currently 19 bytes for 9 members)
65// - There must be room for actual data
66// - Anything smaller than 32 bytes is impractical
67// - We want to give clear error messages, so a simpler condition is better.
68const _: () = assert!(
69    header_size(Member::COUNT) <= 32,
70    "header_size(Member::COUNT) exceeds 32, these asserts must be updated"
71);
72const _: () = assert!(
73    BUFFER_SIZE > 32,
74    "VER_STUB_BUFFER_SIZE must be greater than 32"
75);
76
77/// The section name used for version data (platform-specific).
78///
79/// On ELF (Linux, etc.) and COFF (Windows): `ver_stub`
80/// On Mach-O (macOS, iOS): `__TEXT,ver_stub`
81///
82/// This is useful for scripts that need to use `cargo objcopy` directly.
83#[cfg(any(target_os = "macos", target_os = "ios"))]
84pub const SECTION_NAME: &str = "__TEXT,ver_stub";
85
86/// The section name used for version data (platform-specific).
87///
88/// On ELF (Linux, etc.) and COFF (Windows): `ver_stub`
89/// On Mach-O (macOS, iOS): `__TEXT,ver_stub`
90///
91/// This is useful for scripts that need to use `cargo objcopy` directly.
92#[cfg(not(any(target_os = "macos", target_os = "ios")))]
93pub const SECTION_NAME: &str = "ver_stub";
94
95/// Static buffer for version data, placed in a custom link section.
96//
97// Note: We use "links" in the cargo toml for this crate to try to ensure that
98// only one version of this crate appears in the build graph, and so only one
99// version of the BUFFER exists, and BUFFER_SIZE = section size.
100#[cfg_attr(
101    any(target_os = "macos", target_os = "ios"),
102    unsafe(link_section = "__TEXT,ver_stub")
103)]
104#[cfg_attr(
105    not(any(target_os = "macos", target_os = "ios")),
106    unsafe(link_section = "ver_stub")
107)]
108#[used]
109static BUFFER: [u8; BUFFER_SIZE] = [0u8; BUFFER_SIZE];
110
111// Members that can be stored in the version data.
112#[doc(hidden)]
113#[repr(u16)]
114#[derive(Clone, Copy)]
115pub enum Member {
116    GitSha = 0,
117    GitDescribe = 1,
118    GitBranch = 2,
119    GitCommitTimestamp = 3,
120    GitCommitDate = 4,
121    GitCommitMsg = 5,
122    BuildTimestamp = 6,
123    BuildDate = 7,
124    Custom = 8,
125}
126
127impl Member {
128    /// Number of members in the version data.
129    #[doc(hidden)]
130    pub const COUNT: usize = 9;
131
132    // Reads a member from the version buffer.
133    //
134    // Returns:
135    // - `None` if the member is not present (start == end, or member >= actual num_members)
136    // - `Some(&str)` containing the member's string data
137    //
138    // Panics:
139    // - If the data is not valid UTF-8
140    // - If the section is malformed: end < start (invalid range), end > BUFFER_SIZE (out of bounds)
141    #[doc(hidden)]
142    pub fn get_from_buffer<'a>(&self, buffer: &'a [u8; BUFFER_SIZE]) -> Option<&'a str> {
143        let idx = *self as usize;
144
145        Self::get_idx_from_buffer(idx, buffer)
146    }
147
148    // Takes usize instead of Member, to allow easy iteration in tests
149    #[doc(hidden)]
150    pub fn get_idx_from_buffer(idx: usize, buffer: &[u8; BUFFER_SIZE]) -> Option<&str> {
151        // Read the actual number of members from the first byte
152        let actual_num_members = Self::read_buffer_byte(buffer, 0) as usize;
153
154        // If first byte is 0, section is uninitialized (all zeros)
155        if actual_num_members == 0 {
156            return None;
157        }
158
159        // Forward compatibility: if requested member >= actual num_members, return None
160        if idx >= actual_num_members {
161            return None;
162        }
163
164        // Compute header size based on actual number of members in the section
165        let actual_header_size = header_size(actual_num_members);
166
167        // Read end offset for this member (stored at byte 1 + idx * 2, relative to header)
168        let end_offset_pos = 1 + idx * 2;
169        let end = actual_header_size + Self::read_buffer_u16(buffer, end_offset_pos) as usize;
170
171        // Calculate start: header_size + previous member's end, or header_size for member 0
172        let start = if idx == 0 {
173            actual_header_size
174        } else {
175            let prev_end_pos = 1 + (idx - 1) * 2;
176            actual_header_size + Self::read_buffer_u16(buffer, prev_end_pos) as usize
177        };
178
179        // If start == end, member is not present
180        if start == end {
181            return None;
182        }
183
184        // Validate range
185        if end < start {
186            panic!(
187                "ver-stub: invalid range for {:?}: start={}, end={}",
188                idx, start, end
189            );
190        }
191        if end > BUFFER_SIZE {
192            panic!(
193                "ver-stub: end offset {} exceeds buffer size {} for {:?}",
194                end, BUFFER_SIZE, idx
195            );
196        }
197
198        // Get the slice and convert to UTF-8.
199        // Use black_box to prevent the compiler from optimizing away the read,
200        // since the buffer is initialized to zeros at compile time, but changed at link time.
201        let bytes = core::hint::black_box(&buffer[start..end]);
202        match core::str::from_utf8(bytes) {
203            Ok(s) => Some(s),
204            Err(e) => panic!("ver-stub: invalid UTF-8 for {:?}: {:?}", idx, e),
205        }
206    }
207
208    // Reads a u16 from the buffer at the given offset (little-endian).
209    fn read_buffer_u16(buffer: &[u8; BUFFER_SIZE], offset: usize) -> u16 {
210        let lo = Self::read_buffer_byte(buffer, offset) as u16;
211        let hi = Self::read_buffer_byte(buffer, offset + 1) as u16;
212        lo | (hi << 8)
213    }
214
215    // Reads a byte from the buffer using volatile read to prevent optimization.
216    // This is necessary because the compiler would otherwise inline the zeros
217    // since the buffer is initialized to all zeros at compile time, and it isn't
218    // aware of the linker stuff that happens after.
219    #[inline(never)]
220    fn read_buffer_byte(buffer: &[u8; BUFFER_SIZE], offset: usize) -> u8 {
221        assert!(
222            offset < BUFFER_SIZE,
223            "ver-stub: invalid section data, {offset} >= {BUFFER_SIZE} is out of bounds"
224        );
225        // SAFETY: offset is bounds-checked by assert
226        unsafe { core::ptr::read_volatile(buffer.as_ptr().add(offset)) }
227    }
228}
229
230/// Returns the git SHA, if present.
231///
232/// This is the full SHA from `git rev-parse HEAD`.
233pub fn git_sha() -> Option<&'static str> {
234    Member::GitSha.get_from_buffer(&BUFFER)
235}
236
237/// Returns the git describe output, if present.
238///
239/// This is the output of `git describe --always --dirty`, which includes:
240/// - The most recent tag (if any)
241/// - Number of commits since that tag
242/// - Abbreviated commit hash
243/// - `-dirty` suffix if there are uncommitted changes
244pub fn git_describe() -> Option<&'static str> {
245    Member::GitDescribe.get_from_buffer(&BUFFER)
246}
247
248/// Returns the git branch name, if present.
249///
250/// This is the output of `git rev-parse --abbrev-ref HEAD`.
251pub fn git_branch() -> Option<&'static str> {
252    Member::GitBranch.get_from_buffer(&BUFFER)
253}
254
255/// Returns the git commit timestamp, if present.
256///
257/// This is the author date of HEAD formatted as RFC 3339
258/// (e.g., `2024-01-15T10:30:00+00:00`).
259pub fn git_commit_timestamp() -> Option<&'static str> {
260    Member::GitCommitTimestamp.get_from_buffer(&BUFFER)
261}
262
263/// Returns the git commit date, if present.
264///
265/// This is the author date of HEAD formatted as a date only
266/// (e.g., `2024-01-15`).
267pub fn git_commit_date() -> Option<&'static str> {
268    Member::GitCommitDate.get_from_buffer(&BUFFER)
269}
270
271/// Returns the git commit message, if present.
272///
273/// This is the first line of the commit message (subject line),
274/// truncated to at most 100 characters.
275pub fn git_commit_msg() -> Option<&'static str> {
276    Member::GitCommitMsg.get_from_buffer(&BUFFER)
277}
278
279/// Returns the build timestamp, if present.
280///
281/// This is the time the binary was built, formatted as RFC 3339
282/// (e.g., `2024-01-15T10:30:00Z`).
283pub fn build_timestamp() -> Option<&'static str> {
284    Member::BuildTimestamp.get_from_buffer(&BUFFER)
285}
286
287/// Returns the build date, if present.
288///
289/// This is the date the binary was built, formatted as YYYY-MM-DD
290/// (e.g., `2024-01-15`).
291pub fn build_date() -> Option<&'static str> {
292    Member::BuildDate.get_from_buffer(&BUFFER)
293}
294
295/// Returns the custom application-specific string, if present.
296///
297/// This can be any string your application wants to embed into the binary.
298/// Set it using `LinkSection::with_custom()` in your build script.
299pub fn custom() -> Option<&'static str> {
300    Member::Custom.get_from_buffer(&BUFFER)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_zeroes() {
309        let buffer = [0u8; BUFFER_SIZE];
310        for idx in 0..Member::COUNT {
311            assert!(Member::get_idx_from_buffer(idx, &buffer).is_none());
312        }
313    }
314
315    // Note: if buffer size is smaller, this should return invalid section data
316    #[test]
317    #[should_panic = "exceeds buffer size"]
318    fn test_ones() {
319        let buffer = [255u8; BUFFER_SIZE];
320        Member::get_idx_from_buffer(0, &buffer);
321    }
322
323    #[test]
324    fn test_one_element() {
325        let mut buffer = [0u8; BUFFER_SIZE];
326        buffer[0..7].copy_from_slice(&[1u8, 4u8, 0u8, b'a', b's', b'd', b'f']);
327
328        assert_eq!(Member::GitSha.get_from_buffer(&buffer).unwrap(), "asdf");
329        for idx in 1..Member::COUNT {
330            assert!(Member::get_idx_from_buffer(idx, &buffer).is_none());
331        }
332
333        // Try with more than one actual num members:
334        buffer[0..11].copy_from_slice(&[3u8, 4u8, 0u8, 4u8, 0u8, 4u8, 0u8, b'a', b's', b'd', b'f']);
335
336        assert_eq!(Member::GitSha.get_from_buffer(&buffer).unwrap(), "asdf");
337        for idx in 1..Member::COUNT {
338            assert!(Member::get_idx_from_buffer(idx, &buffer).is_none());
339        }
340    }
341
342    #[test]
343    #[should_panic = "invalid range"]
344    fn test_invalid_range() {
345        let mut buffer = [0u8; BUFFER_SIZE];
346
347        buffer[0..9].copy_from_slice(&[2u8, 4u8, 0u8, 0u8, 0u8, b'a', b's', b'd', b'f']);
348
349        Member::GitDescribe.get_from_buffer(&buffer);
350    }
351
352    #[test]
353    fn test_two_elements() {
354        let mut buffer = [0u8; BUFFER_SIZE];
355        buffer[0..17].copy_from_slice(&[
356            3u8, 4u8, 0u8, 4u8, 0u8, 10u8, 0u8, b'a', b's', b'd', b'f', b'm', b'a', b's', b't',
357            b'e', b'r',
358        ]);
359
360        assert_eq!(Member::GitSha.get_from_buffer(&buffer).unwrap(), "asdf");
361        assert!(Member::GitDescribe.get_from_buffer(&buffer).is_none());
362        assert_eq!(
363            Member::GitBranch.get_from_buffer(&buffer).unwrap(),
364            "master"
365        );
366        for idx in 3..Member::COUNT {
367            assert!(Member::get_idx_from_buffer(idx, &buffer).is_none());
368        }
369
370        // Move first character of 3rd elem to the 2nd elem (currently none)
371        buffer[3] = 5u8;
372
373        assert_eq!(Member::GitSha.get_from_buffer(&buffer).unwrap(), "asdf");
374        assert_eq!(Member::GitDescribe.get_from_buffer(&buffer).unwrap(), "m");
375        assert_eq!(Member::GitBranch.get_from_buffer(&buffer).unwrap(), "aster");
376        for idx in 3..Member::COUNT {
377            assert!(Member::get_idx_from_buffer(idx, &buffer).is_none());
378        }
379    }
380
381    #[test]
382    #[should_panic = "exceeds buffer size"]
383    fn test_127s() {
384        let buffer = [127u8; BUFFER_SIZE];
385
386        Member::GitSha.get_from_buffer(&buffer);
387    }
388
389    #[test]
390    #[should_panic = "invalid UTF-8"]
391    fn test_invalid_utf8() {
392        let mut buffer = [0u8; BUFFER_SIZE];
393        buffer[0..5].copy_from_slice(&[1u8, 2u8, 0u8, 255u8, 255u8]);
394
395        Member::GitSha.get_from_buffer(&buffer);
396    }
397}