Writing

Feed Software, technology, sysadmin war stories, and more.

Monday, February 5, 2024

Stamping production binaries with build info

As my assortment of dumb little home-grown utility programs grew over the years, I found myself needing to know when a given binary was built. Sometimes things exist outside the realm of a packaging system, and so the binary itself needs to convey that metadata from build time.

I've seen some places solve for this by having giant placeholder strings baked into their binaries that they then reach in and "stamp" later, turning the "XXXXXXXX" or whatever into "Built by foo@foo.blah.evilcorp on ...". While that approach mostly worked, it was too spooky for me and I decided to stay away from it.

My system is something that uses a little nuance of C++ that I've mentioned a couple of times already. It's not the cleanest thing and it does involve a bit of groaning, but it works. In case anyone else wants to try it in their projects, here's how I set it up.

First, I have this buildinfo/base.h, and in it, I define a struct called Details, and it has all of the fields I care about - times, hostnames, usernames, commit hashes, that kind of thing.

There's also this:

extern std::optional<Details> details_;

Yes, that is globally visible, but it's inside a namespace so the crap factor is reduced somewhat. It's a necessary evil.

I also have a buildinfo/base.cc and it actually creates that variable:

std::optional<Details> details_;

There's also a GetBuildDetails function which will return the value of details_ if one exists, or a suitable error if not.

Now, you might be saying "it'll never have a value, so it'll always be an error", and you're mostly right. Just from what I've described so far, that in fact is the case. buildinfo/base.{cc,h} rolls up into buildinfo/base.o, and that gets linked into my programs during a normal development type build. If one of those programs calls the GetBuildDetails() function, then yes, they get the "sorry, nobody home" error response.

But, I have a way to inject the build info when I do a "production" build. This kind of build has slightly different config settings in my build system, and one of them tells it to "stamp" the binary.

The way this works is where the evil starts slipping in. On stamped builds, my build system writes out a file called buildinfo/overlay.cc. This file #includes "buildinfo/base.h" to pick up the definition of Details and the 'extern' for details_ itself. Then it rattles off a bunch of variables and their values (build time, build host, build user, ...) then it defines a class called Overlay.

Overlay's constructor has one job: it reaches into details_ and populates a bunch of fields with the values from those earlier variables.

Then the thing that actually makes it run shows up, and here's the "spooky action at a distance":

static Overlay overlay;

Just by having that line in the file, it will cause the program to create an instance of that class shortly before it reaches main() as long as it's linked into the final binary. When that constructor runs, it will populate details_, and then any code run from the rest of the program will see the build info.

This is convoluted, so I'll restate it here for clarity: it's the difference between linking "a.o", "b.o" and "c.o" into "prog", or "a.o", "b.o", "c.o" *and* "overlay.o" into "prog". If you don't link in that extra object, the sneaky stuff never happens, and it stays unpopulated. Using a std::optional wrapper saves us from the jankiness of using a bare pointer... or worse.

There are some bonuses from using the intermediate variables instead of just having a bunch of .field = "val" type things in the part where details_ gets initialized. For one, if those variables are not set to static, then they'll be visible to things like debuggers and certain other tools. Then you can do something like this:

$ gdb lavalamp_server -q
Reading symbols from lavalamp_server...
(gdb) print buildinfo::kBuildTime
$1 = 1707186971
(gdb) 

That's pretty neat, right? Analysis of a binary at rest? You can even do this without going through a debugger if you really want to.

Finally, how does the build tool handle this? It's a bit more of the special-case stuff for something that isn't just an ordinary build. If "stamping" of binaries has been requested for the active build type, then it generates a fresh overlay.cc and compiles it to overlay.o.

Then, in the link stage, if it's in "stamp mode", it inserts that object file as just another dependency of the build target as if it had been discovered by way of a '#include "some_dir/some_lib.h"' or whatever. This adds it to the list of objects passed to the linker, and a fresh binary pops out a few moments later.

I'm a fan of this technique since it only really adds two places in the build system where things go off and act a little strangely: the init sequence of the build tool when it's first built, and the link sequence of the target(s) to make sure it gets "injected".

For anyone who's worrying about "repeatable builds" or somesuch, I will point out that nothing's stopping you from having yet another build type which is otherwise as described above but which puts known placeholder data into the details_ variable. In that world, you should be able to go through the entire process and still get the same output, even on a different date, on a different box, and as another username.

I no longer wonder about which version of a binary is in "prod".