[Перевод] Введение в детерминированные сборки на С/С++. Часть 1

Перевод статьи подготовлен специально для студентов курса «Разработчик С++».

pej05eo-w-5tdwrp0yrlv7lbedy.png


Что такое детерминированная сборка?


Детерминированная сборка — это процесс сборки одного и того же исходного кода с одной и той же средой и инструкциями сборки, при котором создаются одни и те же двоичные файлы в любом случае, даже если они сделаны на разных машинах, в разных каталогах и с разными именами. Такие сборки также иногда называют воспроизводимыми или герметичными сборками, если гарантируется, что они будут создавать одни и те же двоичные файлы даже при компиляции из разных папок.

Детерминированные сборки не являются чем-то, что происходит само-собой. Они не создаются в обычных проектах, и причины, по которым этого не происходит, могут быть разными для каждой операционной системы или компилятора.

Детерминированные сборки должны быть гарантированы для заданной среды сборки. Это означает, что некоторые переменные, такие как операционная система, версии системы сборки и целевая архитектура, предположительно остаются одинаковыми в разных сборках.

В последние годы различные организации, такие как Chromium, Reproducible builds или Yocto, предпринимали много усилий для достижения детерминированных сборок.

Важность детерминированных сборок


Существуют две основные причины, почему детерминированные сборки так важны:

  • Безопасность. Изменение двоичных файлов вместо исходного кода может сделать изменения невидимыми для оригинальных авторов. Это может быть фатальным в критических для безопасности условиях, таких как медицина, авиация и космос. Потенциально идентичные результаты для данных материалов позволяют третьим сторонам прийти к консенсусу относительно правильного результата.
  • Отслеживаемость и двоичное управление. Если вы хотите иметь репозиторий для хранения ваших двоичных файлов, то скорее всего вы не хотите создавать двоичные файлы со случайными контрольными суммами из источников в одной и той же ревизии. Это может привести к тому, что система репозитория будет хранить разные двоичные файлы как разные версии, когда они должны быть одинаковыми. Например, если вы работаете в Windows или MacOS, самая простая библиотека будет вести двоичные файлы с разными контрольными суммами из-за временных меток, включенных в форматы библиотеки для этих операционных систем.

Двоичные файлы, участвующие в процессе сборки в C/C++

Существуют различные типы двоичных файлов, которые создаются в процессе сборки в C/C++ в зависимости от операционной системы.

Microsoft Windows. Наиболее важными являются файлы с расширениями .obj, .lib, .dll и .exe. Все они соответствуют спецификации формата переносимых исполняемых файлов (Portable Executable, PE). Эти файлы могут быть проанализированы с помощью таких инструментов, как dumpbin.
Linux. Файлы с расширениями .o, .a, .so и none (для исполняемых двоичных файлов) соответствуют формату исполнимых и компонуемых файлов (Executable and Linkable Format, ELF). Содержимое ELF-файлов можно проанализировать с помощью readelf.
Mac OS. Файлы с расширениями .o, .a, .dylib и none (для исполняемых двоичных файлов) соответствуют спецификации формата Mach-O. Эти файлы можно проверить с помощью приложения otool, которое является частью набора инструментов XCode в MacOS.

Источники вариаций


Много разных факторов могут сделать ваши сборки недетерминированными. Факторы будут различаться для разных операционных систем и компиляторов. Каждый компилятор имеет определенные параметры для исправления источников вариаций. На сегодняшний день gcc и clang — это те компиляторы, которые содержат больше опций для исправления. Для msvc есть несколько недокументированных опций, которые вы можете попробовать, но, в конце концов, вам, вероятно, придется исправлять двоичные файлы, чтобы получить детерминированные сборки.

Временные метки, добавленные компилятором/компоновщиком


Есть две основные причины, из-за которых наши двоичные файлы могут содержать информацию о времени, которая сделает их невоспроизводимыми:

  • Использование макросов __DATE__ или __TIME__ в исходниках.
  • Когда формат файла заставляет хранить информацию о времени в объектных файлах. Это случай формата Portable Executable в Windows и Mach-O в MacOS. В Linux ELF-файлы не кодируют какие-либо временные метки.

Давайте рассмотрим пример, где эта информация заканчивается компиляцией статической библиотеки базового проекта hello world в MacOS.

.
├── CMakeLists.txt
├── hello_world.cpp
├── hello_world.hpp
├── main.cpp
└── run_build.sh

Библиотека выводит сообщение в терминале:

#include "hello_world.hpp"
#include 
void HelloWorld::PrintMessage(const std::string & message)
{
    std::cout << message << std::endl;
}

А приложение будет использовать это, чтобы вывести сообщение «Hello World!»:

#include 
#include "hello_world.hpp"
int main(int argc, char** argv)
{
    HelloWorld hello;
    hello.PrintMessage("Hello World!");
    return 0;
}

Мы будем использовать CMake для сборки проекта:

cmake_minimum_required(VERSION 3.0)
project(HelloWorld)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(HelloLibA hello_world.cpp)
add_library(HelloLibB hello_world.cpp)
add_executable(helloA main.cpp)
add_executable(helloB main.cpp)
target_link_libraries(helloA HelloLibA)
target_link_libraries(helloB HelloLibB)

Мы создадим две разные библиотеки с одинаковыми исходными кодами, а также два двоичных файла с одинаковыми источниками. Соберем проект и выполним md5sum, чтобы посмотреть контрольные суммы всех двоичных файлов:

mkdir build && cd build
cmake ..
make
md5sum helloA
md5sum helloB
md5sum CMakeFiles/HelloLibA.dir/hello_world.cpp.o
md5sum CMakeFiles/HelloLibB.dir/hello_world.cpp.o
md5sum libHelloLibA.a
md5sum libHelloLibB.a

Получаем вывод, наподобие этого:

b5dce09c593658ee348fd0f7fae22c94  helloA
b5dce09c593658ee348fd0f7fae22c94  helloB
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibA.dir/hello_world.cpp.o
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibB.dir/hello_world.cpp.o
adb80234a61bb66bdc5a3b4b7191eac7  libHelloLibA.a
5ac3c70d28d9fdd9c6571e077131545e  libHelloLibB.a

Это интересно, поскольку исполняемые файлы helloA и helloB имеют одинаковые контрольные суммы, также как и промежуточные объектные файлы Mach-O hello_world.cpp.o, но этого нельзя сказать про файлы с расширением .a. Это потому, что они хранят информацию о промежуточных объектных файлах в архивном формате. Заголовок этого формата включает поле с именем st_time, устанавливаемое системным вызовом stat. Проверим libHelloLibA.a и libHelloLibB.a, используя otool, чтобы показать заголовки:

> otool -a libHelloLibA.a   
Archive : libHelloLibA.a
0100644 503/20    612 1566927276 #1/20
0100644 503/20  13036 1566927271 #1/28
> otool -a libHelloLibB.a   
Archive : libHelloLibB.a
0100644 503/20    612 1566927277 #1/20
0100644 503/20  13036 1566927272 #1/28

Мы видим, что файл содержит несколько временных полей, которые делают нашу сборку недетерминированной. Отметим, что эти поля не распространяются на конечный исполняемый файл, поскольку они имеют одинаковую контрольную сумму. Эта проблема также может возникнуть при сборке в Windows с Visual Studio, но с PE файлом вместо Mach-O.

На этом этапе мы можем попытаться сделать все еще хуже и заставить наши двоичные файлы быть также недетерминированными. Изменим файл main.cpp, таким образом, чтобы он включал макрос __TIME__:

#include 
#include "hello_world.hpp"
int main(int argc, char** argv)
{
    HelloWorld hello;
    hello.PrintMessage("Hello World!");
    std::cout << "At time: " << __TIME__ << std::endl;
    return 0;
}

Проверяем контрольные суммы файлов еще раз:

625ecc7296e15d41e292f67b57b04f15  helloA
20f92d2771a7d2f9866c002de918c4da  helloB
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibA.dir/hello_world.cpp.o
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibB.dir/hello_world.cpp.o
b7801c60d3bc4f83640cadc1183f43b3  libHelloLibA.a
4ef6cae3657f2a13ed77830953b0aee8  libHelloLibB.a

Мы видим, что теперь у нас есть разные двоичные файлы. Мы могли бы проанализировать исполняемый файл с помощью такого инструмента, как diffoscope, который показывает разницу между двумя двоичными файлами:

> diffoscope helloA helloB
--- helloA
+++ helloB
├── otool -arch x86_64 -tdvV {}
│┄ Code for architecture x86_64
│ @@ -16,15 +16,15 @@
│  00000001000018da	jmp	0x1000018df
│  00000001000018df	leaq	-0x30(%rbp), %rdi
│  00000001000018e3	callq	0x100002d54 ## symbol stub for: __ZNSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEED1Ev
│  00000001000018e8	movq	0x1721(%rip), %rdi ## literal pool symbol address: __ZNSt3__14coutE
│  00000001000018ef	leaq	0x162f(%rip), %rsi ## literal pool for: "At time: "
│  00000001000018f6	callq	0x100002d8a ## symbol stub for: __ZNSt3__1lsINS_11char_traitsIcEEEERNS_13basic_ostreamIcT_EES6_PKc
│  00000001000018fb	movq	%rax, %rdi
│ -00000001000018fe	leaq	0x162a(%rip), %rsi ## literal pool for: "19:40:47"
│ +00000001000018fe	leaq	0x162a(%rip), %rsi ## literal pool for: "19:40:48"
│  0000000100001905	callq	0x100002d8a ## symbol stub for: __ZNSt3__1lsINS_11char_traitsIcEEEERNS_13basic_ostreamIcT_EES6_PKc
│  000000010000190a	movq	%rax, %rdi
│  000000010000190d	leaq	__ZNSt3__1L4endlIcNS_11char_traitsIcEEEERNS_13basic_ostreamIT_T0_EES7_(%rip), %rsi #

Он показывает, что информация __TIME__ была вставлена в двоичный файл, что делает его недетерминированным. Давайте посмотрим, что можно сделать, чтобы избежать этого.

Возможные решения для Microsoft Visual Studio


Microsoft Visual Studio имеет флаг компоновщика /Brepro который не документирован Microsoft. Этот флаг устанавливает временные метки из формата Portable Executable в значение -1, как видно на рисунке ниже.

ohdsca2wvgdmv_j4zdfgbyc5xmq.png

Чтобы активировать этот флаг с помощью CMake, мы должны добавить следующие строки при создании .exe:

add_link_options("/Brepro")

или эти строки для .lib

set_target_properties(
    TARGET
    PROPERTIES STATIC_LIBRARY_OPTIONS "/Brepro"
)

Проблема в том, что этот флаг делает двоичные файлы воспроизводимыми (относительно временных меток в формате файла) в нашем конечном двоичном файле .exe, но не удаляет все временные метки из .lib (та же проблема, что и с объектными файлами Mach-O, о которой мы говорили выше). Поле TimeDateStamp из заголовочного файла COFF для файлов .lib останется. Единственный способ удалить эту информацию из двоичного файла .lib — это исправить .lib, заменив байты, соответствующие полю TimeDateStamp, на любое известное значение.

Возможные решения для GCC и CLANG


  • gcc обнаруживает существование переменной среды SOURCE_DATE_EPOCH. Если эта переменная установлена, ее значение указывает метку времени UNIX, которая будет использоваться для замены текущей даты и времени в макросах __DATE__ и __TIME__, чтобы встроенные метки времени стали воспроизводимыми. Значение может быть установлено на известную временную метку, такую как время последнего изменения исходных файлов или пакета.
  • clang использует ZERO_AR_DATE, который, если установлен, сбрасывает метку времени, представленную в архивных файлах, устанавливая ее на 0. Учтите, что это не исправит макросы __DATE__ или __TIME__. Если мы хотим исправить эффект этого макроса, мы должны либо исправить двоичные файлы, либо подделать системное время.

Давайте продолжим с нашим примером проекта для MacOS и посмотрим, каковы будут результаты при установке переменной среды ZERO_AR_DATE.

export ZERO_AR_DATE=1

Теперь, если мы соберем наш исполняемый файл и библиотеки (убирая макрос __DATE__ в исходниках), мы получим:

b5dce09c593658ee348fd0f7fae22c94  helloA
b5dce09c593658ee348fd0f7fae22c94  helloB
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibA.dir/hello_world.cpp.o
0a4a0de3df8cc7f053f2fcb6d8b75e6d  CMakeFiles/HelloLibB.dir/hello_world.cpp.o
9f9a9af4bb3e220e7a22fb58d708e1e5  libHelloLibA.a
9f9a9af4bb3e220e7a22fb58d708e1e5  libHelloLibB.a

Все контрольные суммы теперь одинаковы. Проанализируем заголовки файлов с расширением .a:

> otool -a libHelloLibA.a
Archive : libHelloLibA.a
0100644 503/20    612 0 #1/20
0100644 503/20  13036 0 #1/28
> otool -a libHelloLibB.a
Archive : libHelloLibB.a
0100644 503/20    612 0 #1/20
0100644 503/20  13036 0 #1/28

Мы можем увидеть, что поле timestamp заголовка библиотеки было установлено равным нулю.

Мы плавно подошли к концу первой части статьи. Продолжение материала будет опубликовано в ближайшие дни, а сейчас мы приглашаем всех на день открытых дверей по курсу, который пройдет уже 19 числа.

© Habrahabr.ru