Skip to content

Commit 6019401

Browse files
committed
fdroid: make reproducible
1 parent 4a1681a commit 6019401

12 files changed

Lines changed: 339 additions & 98 deletions

ci/Jenkinsfile.fdroid

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
#!/usr/bin/env groovy
2-
library 'status-jenkins-lib@v1.9.40'
2+
library 'status-jenkins-lib@fdroid-apk-signing'
33

44
/* Options section can't access functions in objects. */
55
def isPRBuild = utils.isPRBuild()
@@ -93,6 +93,12 @@ pipeline {
9393
}
9494
}
9595

96+
stage('Sign') {
97+
steps { script {
98+
status.signFdroidApk(env.STATUS_FDROID_APK)
99+
} }
100+
}
101+
96102
stage('Upload') {
97103
steps {
98104
script {

fdroid/build-qt.sh

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,64 @@
11
#!/usr/bin/env bash
22
set -eo pipefail
33

4+
# Reproducibility env, applied to every cmake / ninja / qmlcachegen / moc /
5+
# rcc invocation below. Defense-in-depth against the three known classes of
6+
# Qt non-determinism that can affect a subset of modules:
7+
# LC_ALL/LANG=C - stable locale-dependent sort order (linker symbol
8+
# ordering, CMake file globs, ninja work-item order)
9+
# SOURCE_DATE_EPOCH - honored by gcc/clang for __DATE__/__TIME__ and by
10+
# qmlcachegen / qrc for any embedded timestamp
11+
# The matching -DCMAKE_UNITY_BUILD=OFF and -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF
12+
# below kill the other two: Unity Build's per-target source batching, and any
13+
# future Qt update silently enabling IPO/LTO.
14+
export LC_ALL=C
15+
export LANG=C
16+
export SOURCE_DATE_EPOCH="$(git -C "$BUILD_DIR" log -1 --pretty=%ct 2>/dev/null || echo 0)"
17+
418
QT_VERSION="${QT_VERSION:-6.9.2}"
519

620
QT_MODULES=qtbase,qtdeclarative,qt5compat,qtmultimedia,qtshadertools,qtimageformats,qtwebview,qtscxml,qtsvg,qtconnectivity,qtwebsockets,qtpositioning,qtlottie,qtwebchannel
721
(cd "$QT_SRCDIR" && perl init-repository --module-subset="$QT_MODULES")
822

23+
# Reproducibility:
24+
# add_link_options: strips .note.gnu.build-id from every .so
25+
# add_compile_options: make sure that paths dont leak into final build.
26+
QT5_CMAKELISTS="$QT_SRCDIR/CMakeLists.txt"
27+
if ! grep -q 'build-id=none' "$QT5_CMAKELISTS"; then
28+
sed -i '/^project(Qt$/,/^)$/{/^)$/a\
29+
add_link_options("LINKER:--build-id=none")\
30+
add_compile_options("-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.")\
31+
add_compile_options("-ffile-prefix-map=${CMAKE_BINARY_DIR}=.")\
32+
add_compile_options("-ffile-prefix-map=$ENV{HOME}=.")
33+
}' "$QT5_CMAKELISTS"
34+
fi
35+
36+
# Reproducibility: force qmlcachegen --only-bytecode for every Qt-internal QML
37+
# module. qmlcachegen's AOT C++ codegen (AOTCompiledContext) makes per-call
38+
# decisions whose heuristics walk QHash containers; iteration order depends on
39+
# per-process memory layout, so different runs emit different amounts of AOT
40+
# code for the same QML inputs. Disassembly of two back-to-back builds showed
41+
# +800 bytes of AOT code in one libQt6QuickControls2Fusion vs the other.
42+
# --only-bytecode keeps QML bytecode compilation (deterministic) and skips AOT.
43+
QT_QML_MACROS="$QT_SRCDIR/qtdeclarative/src/qml/Qt6QmlMacros.cmake"
44+
if [ -f "$QT_QML_MACROS" ] && ! grep -q 'REPRODUCIBILITY_ONLY_BYTECODE' "$QT_QML_MACROS"; then
45+
sed -i '/MATCHES "-NOTFOUND\$"/,/endforeach()/{
46+
/endforeach()/a\
47+
# REPRODUCIBILITY_ONLY_BYTECODE: pin every Qt-internal QML module to\
48+
# qmlcachegen --only-bytecode so AOT C++ codegen does not introduce\
49+
# per-build non-determinism in compiled .so files.\
50+
get_target_property(_qmlcg_args ${target} QT_QMLCACHEGEN_ARGUMENTS)\
51+
if(NOT "--only-bytecode" IN_LIST _qmlcg_args)\
52+
list(APPEND _qmlcg_args "--only-bytecode")\
53+
set_target_properties(${target} PROPERTIES QT_QMLCACHEGEN_ARGUMENTS "${_qmlcg_args}")\
54+
endif()
55+
}' "$QT_QML_MACROS"
56+
fi
57+
58+
# Reproducibility: rewrite absolute build/install paths embedded in compiled
59+
# objects (debug strings, __FILE__, etc.) so they match across rebuilders.
60+
QT_REPRO_CFLAGS="-ffile-prefix-map=${QT_SRCDIR}=. -ffile-prefix-map=${BUILD_DIR}=. -ffile-prefix-map=${HOME}=."
61+
962
# Build Qt for host (required as cross-compilation toolchain for Android)
1063
mkdir -p build_qt_host && cd build_qt_host
1164

@@ -18,6 +71,10 @@ mkdir -p build_qt_host && cd build_qt_host
1871
-nomake tests \
1972
-- \
2073
-DCMAKE_BUILD_TYPE=Release \
74+
-DCMAKE_C_FLAGS_INIT="$QT_REPRO_CFLAGS" \
75+
-DCMAKE_CXX_FLAGS_INIT="$QT_REPRO_CFLAGS" \
76+
-DCMAKE_UNITY_BUILD=OFF \
77+
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF \
2178
-DCMAKE_MESSAGE_LOG_LEVEL=WARNING \
2279
-Wno-dev
2380

@@ -50,6 +107,10 @@ mkdir -p build_qt_android && cd build_qt_android
50107
-DFFMPEG_DIR="$FFMPEG_DIR" \
51108
-DFEATURE_ffmpeg=ON \
52109
-DQT_DEFAULT_MEDIA_BACKEND=ffmpeg \
110+
-DCMAKE_C_FLAGS_INIT="$QT_REPRO_CFLAGS" \
111+
-DCMAKE_CXX_FLAGS_INIT="$QT_REPRO_CFLAGS" \
112+
-DCMAKE_UNITY_BUILD=OFF \
113+
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=OFF \
53114
-DCMAKE_MESSAGE_LOG_LEVEL=WARNING \
54115
-Wno-dev
55116

fdroid/generate-keystore.sh

Lines changed: 0 additions & 36 deletions
This file was deleted.

fdroid/sign-apk.sh

Lines changed: 0 additions & 44 deletions
This file was deleted.

mobile/Makefile

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ $(STATUS_GO_LIB):
3333
@echo "Building status-go mobile library"
3434
@mkdir -p $(LIB_PATH)
3535
ifeq ($(OS),android)
36+
# Reproducibility env for status-go. F-Droid byte-verifies the APK
37+
# against a reference build, so libstatus.so must come out identical
38+
# every time.
39+
#
40+
# -toolexec wraps every cgo invocation and substitutes -importpath with a
41+
# content-derived value. cgo's per-package hash prefix (the X in
42+
# _cgo_X_Cfunc_*) is sha256(importPath)[:6]; without this wrapper, Go's
43+
# build system passes import paths that contain $WORK temp-dir components
44+
# which change per invocation, cascading into a ~32-byte pclntab drift in
45+
# .data.rel.ro that no -trimpath / -buildvcs / -ldflags flag can address.
46+
GOFLAGS="-trimpath -buildvcs=false -toolexec=$(abspath $(SCRIPTS_PATH)/go-toolexec-wrapper.sh)" \
47+
GOMODCACHE="$(BUILD_PATH)/.gomodcache" \
48+
CGO_CFLAGS="-ffile-prefix-map=$(HOME)=. -ffile-prefix-map=$(BUILD_PATH)=. -ffile-prefix-map=$(ROOT_DIR)=. -ffile-prefix-map=$(ANDROID_NDK_ROOT)=." \
49+
CGO_CXXFLAGS="-ffile-prefix-map=$(HOME)=. -ffile-prefix-map=$(BUILD_PATH)=. -ffile-prefix-map=$(ROOT_DIR)=. -ffile-prefix-map=$(ANDROID_NDK_ROOT)=." \
50+
CGO_LDFLAGS="-Wl,--build-id=none" \
3651
CC="$(CC)" $(MAKE) -C ../vendor/status-go statusgo-android-library \
3752
ARCH=$(ARCH) \
3853
ANDROID_NDK_ROOT="$(ANDROID_NDK_ROOT)" \
@@ -42,7 +57,21 @@ ifeq ($(OS),android)
4257
USE_SYSTEM_NIM=$(USE_SYSTEM_NIM) \
4358
GO_GENERATE_CMD="go generate" \
4459
SHELL=/bin/sh
60+
# Belt-and-suspenders: even with the toolexec wrapper above, run our
61+
# post-link canonicalizer to rewrite any cgo hash that might still vary
62+
# (and to verify reproducibility — a no-op pass should print
63+
# "all N hashes already canonical").
64+
@$(SCRIPTS_PATH)/canonicalize-cgo-symbols.sh \
65+
../vendor/status-go/build/bin/libstatus$(LIB_EXT)
66+
@$(ANDROID_NDK_ROOT)/toolchains/llvm/prebuilt/$(HOST_OS)-x86_64/bin/llvm-objcopy \
67+
--remove-section=.note.gnu.build-id \
68+
--remove-section=.note.go.buildid \
69+
../vendor/status-go/build/bin/libstatus$(LIB_EXT)
4570
else ifeq ($(OS),ios)
71+
GOFLAGS="-trimpath -buildvcs=false" \
72+
GOMODCACHE="$(BUILD_PATH)/.gomodcache" \
73+
CGO_CFLAGS="-ffile-prefix-map=$(HOME)=." \
74+
CGO_CXXFLAGS="-ffile-prefix-map=$(HOME)=." \
4675
$(MAKE) -C ../vendor/status-go statusgo-ios-library \
4776
ARCH=$(ARCH) \
4877
IPHONE_SDK="$(IPHONE_SDK)" \

mobile/scripts/buildApp.sh

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,21 @@ if [[ "${OS}" == "android" ]]; then
4848
export BUILD_VARIANT
4949
export BUNDLE_IDENTIFIER
5050

51-
if [[ "$GRADLE_TARGETS" == *"Fdroid"* ]]; then
52-
# shellcheck source=../../fdroid/generate-keystore.sh
53-
source "$REPO_ROOT/fdroid/generate-keystore.sh" "$REPO_ROOT/status-fdroid.keystore"
54-
fi
55-
51+
# Reproducibility flags for fdroid rebuilds:
52+
# --build-id=none drops the .note.gnu.build-id section
53+
# -ffile-prefix-map remaps absolute source paths so they don't depend on $HOME or $BUILD_DIR.
54+
QMAKE_REPRO_ARGS=(
55+
"QMAKE_LFLAGS+=-Wl,--build-id=none"
56+
"QMAKE_CFLAGS+=-ffile-prefix-map=${BUILD_DIR}=."
57+
"QMAKE_CFLAGS+=-ffile-prefix-map=${REPO_ROOT}=."
58+
"QMAKE_CFLAGS+=-ffile-prefix-map=${HOME}=."
59+
"QMAKE_CXXFLAGS+=-ffile-prefix-map=${BUILD_DIR}=."
60+
"QMAKE_CXXFLAGS+=-ffile-prefix-map=${REPO_ROOT}=."
61+
"QMAKE_CXXFLAGS+=-ffile-prefix-map=${HOME}=."
62+
)
5663
"$QMAKE_BIN" "$CWD/../wrapperApp/Status.pro" "${QMAKE_CONFIG[@]}" -spec android-clang \
57-
ANDROID_ABIS="${ANDROID_ABI:-arm64-v8a}" VERSION="$VERSION" "${QMAKE_DEFINES[@]}" -after
64+
ANDROID_ABIS="${ANDROID_ABI:-arm64-v8a}" VERSION="$VERSION" "${QMAKE_DEFINES[@]}" \
65+
-after "${QMAKE_REPRO_ARGS[@]}"
5866

5967
make -j"$(nproc)" apk_install_target
6068

@@ -117,18 +125,14 @@ if [[ "${OS}" == "android" ]]; then
117125
echo "APK outputs:"
118126
find build/outputs/apk -name '*.apk' 2>/dev/null || echo "No APKs found"
119127

120-
# If Gradle produced an unsigned APK (e.g. fdroid build where signing configs
121-
# are stripped by fdroid's remove_signing_keys), sign it via the dedicated script.
122-
if [[ ! -f "$APK_OUT" && -f "$APK_OUT_UNSIGNED" && -n "${FDROID_STORE_FILE:-}" ]]; then
123-
echo "Signing unsigned APK..."
124-
"$REPO_ROOT/fdroid/sign-apk.sh"
125-
fi
126-
127128
# Copy whichever artifacts were built
128129
BUILT=""
129130
if [[ -f "$APK_OUT" ]]; then
130131
cp "$APK_OUT" "$BIN_DIR/${OUTPUT_NAME}.apk"
131132
BUILT="$BIN_DIR/${OUTPUT_NAME}.apk"
133+
elif [[ -f "$APK_OUT_UNSIGNED" ]]; then
134+
cp "$APK_OUT_UNSIGNED" "$BIN_DIR/${OUTPUT_NAME}.apk"
135+
BUILT="$BIN_DIR/${OUTPUT_NAME}.apk"
132136
fi
133137
if [[ -f "$AAB_OUT" ]]; then
134138
cp "$AAB_OUT" "$BIN_DIR/${OUTPUT_NAME}.aab"
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
#!/usr/bin/env bash
2+
# Canonicalize Go cgo's per-build symbol hashes so libstatus.so is byte-identical
3+
# across builds. `go build` regenerates these hashes on every invocation because
4+
# they derive from the build's temp dir path. No build flag fixes it; the hash
5+
# is baked into actual symbol names like _cgo_<hash>_Cfunc_free and
6+
# _cgoexp_<hash>_AcceptTerms.
7+
#
8+
# cgo emits ONE hash per cgo-using package (status-go core, libwaku, libsds,
9+
# ethereum bindings, etc.), so a c-shared library that aggregates many such
10+
# packages contains many distinct hashes. We discover them all, assign each
11+
# a unique deterministic canonical value based on its sorted order, then
12+
# rewrite both the symbol table (objcopy) and any embedded copies (perl).
13+
14+
set -euo pipefail
15+
16+
SO_PATH="${1:?Usage: $0 <path-to-libstatus.so>}"
17+
NM="${NM:-llvm-nm}"
18+
OBJCOPY="${OBJCOPY:-llvm-objcopy}"
19+
20+
# Fail loudly if our toolchain isn't on PATH — silent skip masks a broken
21+
# build (e.g., post-link stripping leaves no .symtab, then a misconfigured
22+
# nm reports "no symbols" and we never canonicalize anything).
23+
command -v "$NM" >/dev/null || { echo "[canonicalize-cgo] $NM not found on PATH" >&2; exit 1; }
24+
command -v "$OBJCOPY" >/dev/null || { echo "[canonicalize-cgo] $OBJCOPY not found on PATH" >&2; exit 1; }
25+
26+
# Read BOTH static and dynamic symbol tables (-D includes .dynsym, needed
27+
# when -s stripping has removed .symtab).
28+
list_symbols() { "$NM" -D -P "$SO_PATH"; "$NM" -P "$SO_PATH" 2>/dev/null || true; }
29+
30+
mapfile -t HASHES < <(
31+
list_symbols | awk '{print $1}' \
32+
| grep -Eo '^_cgo(exp)?_[0-9a-f]{12,16}_' \
33+
| sed -E 's/^_cgo(exp)?_([0-9a-f]+)_/\2/' \
34+
| sort -u
35+
)
36+
37+
if [[ ${#HASHES[@]} -eq 0 ]]; then
38+
echo "[canonicalize-cgo] no cgo hash symbols found in $SO_PATH" >&2
39+
echo "[canonicalize-cgo] this is unexpected for a cgo build; check that nm reads .dynsym" >&2
40+
exit 1
41+
fi
42+
43+
# Build the mapping: every discovered hash -> c0de<sha256-prefix>, where the
44+
# sha256 is computed over the sorted set of symbol names that use this hash
45+
# (with the hash itself replaced by a placeholder so the input is content-only).
46+
# Same package source → same symbol set → same canonical, every build, every
47+
# machine. Sort-order-based assignment is NOT safe here: when two builds
48+
# produce different random hashes, sorting them yields different orderings
49+
# and the same package gets a different canonical position. Content-derivation
50+
# avoids that.
51+
MAPPING=$(mktemp)
52+
PERL_SCRIPT=""
53+
trap 'rm -f "$MAPPING"' EXIT
54+
55+
# Pick a sha256 tool that's available on both macOS and Debian-derived fdroid
56+
# builders. Both ship at least one of these.
57+
if command -v sha256sum >/dev/null 2>&1; then
58+
SHA256="sha256sum"
59+
else
60+
SHA256="shasum -a 256"
61+
fi
62+
63+
rewrites=0
64+
for HASH in "${HASHES[@]}"; do
65+
LEN=${#HASH}
66+
SHA_LEN=$((LEN - 4))
67+
STABLE=$(
68+
list_symbols | awk '{print $1}' \
69+
| grep -E "_cgo(exp)?_${HASH}_" \
70+
| sed "s/${HASH}/X/g" \
71+
| sort -u \
72+
| $SHA256 | awk '{print $1}'
73+
)
74+
CANON="c0de${STABLE:0:$SHA_LEN}"
75+
76+
if [[ "$HASH" == "$CANON" ]]; then
77+
continue
78+
fi
79+
rewrites=$((rewrites + 1))
80+
81+
list_symbols | awk '{print $1}' \
82+
| grep -E "_cgo(exp)?_${HASH}_" | sort -u \
83+
| while read -r SYM; do
84+
printf '%s %s\n' "$SYM" "${SYM/$HASH/$CANON}"
85+
done \
86+
>> "$MAPPING"
87+
88+
PERL_SCRIPT+="s/_cgo_${HASH}_/_cgo_${CANON}_/g;"
89+
PERL_SCRIPT+="s/_cgoexp_${HASH}_/_cgoexp_${CANON}_/g;"
90+
done
91+
92+
if [[ $rewrites -eq 0 ]]; then
93+
echo "[canonicalize-cgo] all ${#HASHES[@]} hashes already canonical"
94+
exit 0
95+
fi
96+
97+
echo "[canonicalize-cgo] rewriting $rewrites of ${#HASHES[@]} hashes in $SO_PATH"
98+
99+
# Step 1: symbol-table rename. Maintains .gnu.hash, .dynsym, .symtab, relocs.
100+
"$OBJCOPY" --redefine-syms="$MAPPING" "$SO_PATH"
101+
102+
# Step 2: catch embedded copies (Go's .gopclntab, debug strings, rodata
103+
# literals). Same-length substitution preserves ELF layout — no relocations
104+
# need updating. One perl invocation handles all hashes at once to avoid
105+
# rewriting the 80MB file N times.
106+
perl -i -0777 -pe "$PERL_SCRIPT" "$SO_PATH"
107+
108+
echo "[canonicalize-cgo] done"

0 commit comments

Comments
 (0)