release: restore ZUPT and harden source-only 5.2.2
This commit is contained in:
parent
74e393ba3e
commit
ff99770bd0
205 changed files with 19627 additions and 13215 deletions
863
scripts/check-source-only.sh
Executable file
863
scripts/check-source-only.sh
Executable file
|
|
@ -0,0 +1,863 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Audit ZUPT source trees and archives for compiled or unsafe content.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
PROGRAM=${0##*/}
|
||||
ROOT=
|
||||
ROOT_REQUESTED=0
|
||||
DATA_MANIFEST=
|
||||
REPOSITORY_AUDIT=1
|
||||
HAVE_EXTERNAL_TARGET=0
|
||||
declare -a TAGS=()
|
||||
declare -a ARCHIVES=()
|
||||
declare -a TREES=()
|
||||
|
||||
FAILURES=0
|
||||
SCANNED=0
|
||||
ARCHIVES_SCANNED=0
|
||||
MAX_ARCHIVE_DEPTH=${SOURCE_AUDIT_MAX_DEPTH:-5}
|
||||
MAX_ARCHIVES=${SOURCE_AUDIT_MAX_ARCHIVES:-1000}
|
||||
MAX_ARCHIVE_MEMBERS=${SOURCE_AUDIT_MAX_MEMBERS:-10000}
|
||||
MAX_ARCHIVE_LIST_KIB=${SOURCE_AUDIT_MAX_LIST_KIB:-16384}
|
||||
MAX_ARCHIVE_KIB=${SOURCE_AUDIT_MAX_KIB:-524288}
|
||||
MAX_TOTAL_ARCHIVE_KIB=${SOURCE_AUDIT_MAX_TOTAL_KIB:-1048576}
|
||||
ARCHIVE_TIMEOUT_SECONDS=${SOURCE_AUDIT_ARCHIVE_SECONDS:-60}
|
||||
FORCE_PORTABLE_WATCHDOG=${SOURCE_AUDIT_FORCE_WATCHDOG:-0}
|
||||
TOTAL_ARCHIVE_BYTES=0
|
||||
|
||||
for limit in "$MAX_ARCHIVE_DEPTH" "$MAX_ARCHIVES" "$MAX_ARCHIVE_MEMBERS" \
|
||||
"$MAX_ARCHIVE_LIST_KIB" "$MAX_ARCHIVE_KIB" \
|
||||
"$MAX_TOTAL_ARCHIVE_KIB" "$ARCHIVE_TIMEOUT_SECONDS"; do
|
||||
[[ $limit =~ ^[0-9]+$ ]] || {
|
||||
printf 'ERROR: source-audit limits must be non-negative integers\n' >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
[[ $FORCE_PORTABLE_WATCHDOG == 0 || $FORCE_PORTABLE_WATCHDOG == 1 ]] || {
|
||||
printf 'ERROR: SOURCE_AUDIT_FORCE_WATCHDOG must be 0 or 1\n' >&2
|
||||
exit 2
|
||||
}
|
||||
((ARCHIVE_TIMEOUT_SECONDS > 0)) || {
|
||||
printf 'ERROR: source-audit archive timeout must be positive\n' >&2
|
||||
exit 2
|
||||
}
|
||||
((MAX_ARCHIVE_LIST_KIB <= 2147483647 && MAX_ARCHIVE_KIB <= 2147483647 &&
|
||||
MAX_TOTAL_ARCHIVE_KIB <= 2147483647)) || {
|
||||
printf 'ERROR: source-audit KiB limits are too large for safe accounting\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
AUDIT_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-source-audit.XXXXXXXX")
|
||||
# shellcheck disable=SC2317 # Invoked indirectly by trap.
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
rm -rf -- "$AUDIT_TMP"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
for required_tool in file od tr grep find head wc awk; do
|
||||
if ! command -v "$required_tool" >/dev/null 2>&1; then
|
||||
printf 'ERROR: source audit requires %s\n' "$required_tool" >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
unset required_tool
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $PROGRAM [--root DIR] [--tag REV] [--archive FILE] [--tree DIR]
|
||||
[--data-manifest FILE]
|
||||
|
||||
Without an external target, audit the Git index, the complete working tree,
|
||||
and git archive HEAD. --tag adds an immutable Git revision archive to that
|
||||
repository audit. --archive and --tree audit standalone inputs and do not
|
||||
require a Git repository unless --tag is also supplied. A data manifest may
|
||||
allow a necessary .bin fixture using four tab-separated fields per record:
|
||||
path, purpose, provenance, and SPDX license. Magic-byte checks still apply.
|
||||
EOF
|
||||
}
|
||||
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--root)
|
||||
(($# >= 2)) || { printf 'ERROR: --root requires a directory\n' >&2; exit 2; }
|
||||
ROOT=$2
|
||||
ROOT_REQUESTED=1
|
||||
shift 2
|
||||
;;
|
||||
--tag)
|
||||
(($# >= 2)) || { printf 'ERROR: --tag requires a revision\n' >&2; exit 2; }
|
||||
TAGS+=("$2")
|
||||
shift 2
|
||||
;;
|
||||
--archive)
|
||||
(($# >= 2)) || { printf 'ERROR: --archive requires a file\n' >&2; exit 2; }
|
||||
ARCHIVES+=("$2")
|
||||
HAVE_EXTERNAL_TARGET=1
|
||||
shift 2
|
||||
;;
|
||||
--tree)
|
||||
(($# >= 2)) || { printf 'ERROR: --tree requires a directory\n' >&2; exit 2; }
|
||||
TREES+=("$2")
|
||||
HAVE_EXTERNAL_TARGET=1
|
||||
shift 2
|
||||
;;
|
||||
--data-manifest)
|
||||
(($# >= 2)) || { printf 'ERROR: --data-manifest requires a file\n' >&2; exit 2; }
|
||||
DATA_MANIFEST=$2
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
(($# == 0)) || { printf 'ERROR: unexpected operand\n' >&2; exit 2; }
|
||||
;;
|
||||
*)
|
||||
printf 'ERROR: unknown option: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ((HAVE_EXTERNAL_TARGET)) && ((ROOT_REQUESTED == 0)) && ((${#TAGS[@]} == 0)); then
|
||||
REPOSITORY_AUDIT=0
|
||||
fi
|
||||
|
||||
unicode_format_control() {
|
||||
local codepoint=$1
|
||||
((codepoint == 0x00ad ||
|
||||
(codepoint >= 0x0600 && codepoint <= 0x0605) ||
|
||||
codepoint == 0x061c || codepoint == 0x06dd || codepoint == 0x070f ||
|
||||
(codepoint >= 0x0890 && codepoint <= 0x0891) ||
|
||||
codepoint == 0x08e2 || codepoint == 0x180e ||
|
||||
(codepoint >= 0x200b && codepoint <= 0x200f) ||
|
||||
(codepoint >= 0x202a && codepoint <= 0x202e) ||
|
||||
(codepoint >= 0x2060 && codepoint <= 0x206f) ||
|
||||
codepoint == 0xfeff ||
|
||||
(codepoint >= 0xfff9 && codepoint <= 0xfffb) ||
|
||||
codepoint == 0x110bd || codepoint == 0x110cd ||
|
||||
(codepoint >= 0x13430 && codepoint <= 0x1343f) ||
|
||||
(codepoint >= 0x1bca0 && codepoint <= 0x1bca3) ||
|
||||
(codepoint >= 0x1d173 && codepoint <= 0x1d17a) ||
|
||||
codepoint == 0xe0001 ||
|
||||
(codepoint >= 0xe0020 && codepoint <= 0xe007f)))
|
||||
}
|
||||
|
||||
safe_path_for_output() {
|
||||
local path=$1
|
||||
local output='' character='' sequence='' escaped=''
|
||||
local LC_ALL=C byte byte2 byte3 byte4 codepoint index length
|
||||
length=${#path}
|
||||
for ((index = 0; index < length; index++)); do
|
||||
character=${path:index:1}
|
||||
printf -v byte '%d' "'$character"
|
||||
|
||||
if ((byte < 0x20 || byte == 0x7f)); then
|
||||
printf -v escaped '\\x%02x' "$byte"
|
||||
output+=$escaped
|
||||
continue
|
||||
fi
|
||||
if ((byte < 0x80)); then
|
||||
if [[ $character == \\ ]]; then
|
||||
output+="${character}${character}"
|
||||
else
|
||||
output+=$character
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
codepoint=0
|
||||
sequence=
|
||||
if ((byte >= 0xc2 && byte <= 0xdf && index + 1 < length)); then
|
||||
character=${path:index+1:1}
|
||||
printf -v byte2 '%d' "'$character"
|
||||
if ((byte2 >= 0x80 && byte2 <= 0xbf)); then
|
||||
codepoint=$(((byte & 0x1f) << 6 | (byte2 & 0x3f)))
|
||||
sequence=${path:index:2}
|
||||
fi
|
||||
elif ((byte >= 0xe0 && byte <= 0xef && index + 2 < length)); then
|
||||
character=${path:index+1:1}
|
||||
printf -v byte2 '%d' "'$character"
|
||||
character=${path:index+2:1}
|
||||
printf -v byte3 '%d' "'$character"
|
||||
if ((byte3 >= 0x80 && byte3 <= 0xbf &&
|
||||
((byte == 0xe0 && byte2 >= 0xa0 && byte2 <= 0xbf) ||
|
||||
(byte >= 0xe1 && byte <= 0xec && byte2 >= 0x80 && byte2 <= 0xbf) ||
|
||||
(byte == 0xed && byte2 >= 0x80 && byte2 <= 0x9f) ||
|
||||
(byte >= 0xee && byte <= 0xef && byte2 >= 0x80 && byte2 <= 0xbf)))); then
|
||||
codepoint=$(((byte & 0x0f) << 12 | (byte2 & 0x3f) << 6 |
|
||||
(byte3 & 0x3f)))
|
||||
sequence=${path:index:3}
|
||||
fi
|
||||
elif ((byte >= 0xf0 && byte <= 0xf4 && index + 3 < length)); then
|
||||
character=${path:index+1:1}
|
||||
printf -v byte2 '%d' "'$character"
|
||||
character=${path:index+2:1}
|
||||
printf -v byte3 '%d' "'$character"
|
||||
character=${path:index+3:1}
|
||||
printf -v byte4 '%d' "'$character"
|
||||
if ((byte3 >= 0x80 && byte3 <= 0xbf &&
|
||||
byte4 >= 0x80 && byte4 <= 0xbf &&
|
||||
((byte == 0xf0 && byte2 >= 0x90 && byte2 <= 0xbf) ||
|
||||
(byte >= 0xf1 && byte <= 0xf3 && byte2 >= 0x80 && byte2 <= 0xbf) ||
|
||||
(byte == 0xf4 && byte2 >= 0x80 && byte2 <= 0x8f)))); then
|
||||
codepoint=$(((byte & 0x07) << 18 | (byte2 & 0x3f) << 12 |
|
||||
(byte3 & 0x3f) << 6 | (byte4 & 0x3f)))
|
||||
sequence=${path:index:4}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z $sequence ]]; then
|
||||
printf -v escaped '\\x%02x' "$byte"
|
||||
output+=$escaped
|
||||
elif ((codepoint >= 0x80 && codepoint <= 0x9f)) ||
|
||||
((codepoint >= 0x2028 && codepoint <= 0x2029)) ||
|
||||
unicode_format_control "$codepoint"; then
|
||||
if ((codepoint <= 0xffff)); then
|
||||
printf -v escaped '\\u%04x' "$codepoint"
|
||||
else
|
||||
printf -v escaped '\\U%08x' "$codepoint"
|
||||
fi
|
||||
output+=$escaped
|
||||
index=$((index + ${#sequence} - 1))
|
||||
else
|
||||
output+=$sequence
|
||||
index=$((index + ${#sequence} - 1))
|
||||
fi
|
||||
done
|
||||
printf '%s' "$output"
|
||||
}
|
||||
|
||||
canonicalize_allow_missing() {
|
||||
local path=$1
|
||||
if realpath -m -- / >/dev/null 2>&1; then
|
||||
realpath -m -- "$path"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$path" <<'PY'
|
||||
import os
|
||||
import sys
|
||||
print(os.path.realpath(sys.argv[1]))
|
||||
PY
|
||||
else
|
||||
printf 'ERROR: canonical path checking needs GNU realpath or python3\n' >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
fail_path() {
|
||||
local scope=$1 path=$2 reason=$3
|
||||
FAILURES=$((FAILURES + 1))
|
||||
printf 'FAIL [%s] %s (%s)\n' "$scope" "$(safe_path_for_output "$path")" "$reason"
|
||||
}
|
||||
|
||||
path_stays_below_root() {
|
||||
local candidate=${1//\\//}
|
||||
local component
|
||||
local depth=0
|
||||
local IFS=/
|
||||
local -a components=()
|
||||
|
||||
[[ $candidate != /* && $candidate != //* ]] || return 1
|
||||
[[ ! $candidate =~ ^[[:alpha:]]: ]] || return 1
|
||||
read -r -a components <<< "$candidate"
|
||||
for component in "${components[@]}"; do
|
||||
case $component in
|
||||
''|.) ;;
|
||||
..)
|
||||
((depth > 0)) || return 1
|
||||
depth=$((depth - 1))
|
||||
;;
|
||||
*) depth=$((depth + 1)) ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
check_link_target() {
|
||||
local entry=$1 target=$2 scope=$3 display=${4:-$1}
|
||||
local parent combined
|
||||
|
||||
[[ $target != /* && $target != //* && ! $target =~ ^[[:alpha:]]: ]] || {
|
||||
fail_path "$scope" "$display" 'absolute symlink target'
|
||||
return
|
||||
}
|
||||
parent=${entry%/*}
|
||||
[[ $parent != "$entry" ]] || parent=.
|
||||
combined=$parent/$target
|
||||
if ! path_stays_below_root "$combined"; then
|
||||
fail_path "$scope" "$display" 'symlink escapes audit root'
|
||||
fi
|
||||
}
|
||||
|
||||
forbidden_extension() {
|
||||
local path=$1 lower
|
||||
lower=$(LC_ALL=C printf '%s' "${path##*/}" | tr '[:upper:]' '[:lower:]')
|
||||
case $lower in
|
||||
*.o|*.obj|*.so|*.so.*|*.a|*.la|*.dll|*.dylib|*.exe|*.com|\
|
||||
*.class|*.jar|*.war|*.wasm|*.pyc|*.pyo|*.rpm|*.deb|*.appimage|\
|
||||
*.msi|*.apk|*.ipa|*.dmg|*.elf|*.ko|*.mod|*.lib|*.pdb|*.out)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
is_declared_binary_data() {
|
||||
local logical=$1 candidate line path purpose provenance license extra
|
||||
[[ -n $DATA_MANIFEST && -r $DATA_MANIFEST ]] || return 1
|
||||
candidate=${logical##*!}
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
[[ -n $line && ${line:0:1} != '#' ]] || continue
|
||||
IFS=$'\t' read -r path purpose provenance license extra <<< "$line"
|
||||
if [[ $path == "$candidate" && -n $purpose && -n $provenance &&
|
||||
-n $license && -z ${extra:-} ]]; then
|
||||
return 0
|
||||
fi
|
||||
done < "$DATA_MANIFEST"
|
||||
return 1
|
||||
}
|
||||
|
||||
magic_kind() {
|
||||
local file=$1 hex machine sections flags
|
||||
hex=$(LC_ALL=C od -An -v -tx1 -N 512 "$file" 2>/dev/null | tr -d '[:space:]') || return 1
|
||||
[[ -n $hex ]] || return 1
|
||||
|
||||
if [[ $hex == 7f454c46* && ${hex:16:6} =~ ^4149(01|02)$ ]]; then
|
||||
printf 'AppImage executable'
|
||||
return 0
|
||||
fi
|
||||
case $hex in
|
||||
7f454c46*) printf 'ELF executable or object'; return 0 ;;
|
||||
4d5a*) printf 'PE/MZ executable'; return 0 ;;
|
||||
feedface*|cefaedfe*|feedfacf*|cffaedfe*|cafebabe*|bebafeca*|cafebabf*|bfbafeca*)
|
||||
printf 'Mach-O, universal binary, or Java class'; return 0 ;;
|
||||
213c617263683e0a64656269616e2d62696e617279*) printf 'Debian package'; return 0 ;;
|
||||
213c617263683e0a*) printf 'ar archive or static library'; return 0 ;;
|
||||
213c7468696e3e0a*) printf 'GNU thin archive or static library'; return 0 ;;
|
||||
edabeedb*) printf 'RPM package'; return 0 ;;
|
||||
0061736d*) printf 'WebAssembly bytecode'; return 0 ;;
|
||||
6465780a*) printf 'Dalvik bytecode'; return 0 ;;
|
||||
1b4c7561*) printf 'Lua bytecode'; return 0 ;;
|
||||
4243c0de*) printf 'LLVM bitcode'; return 0 ;;
|
||||
esac
|
||||
|
||||
# CPython bytecode starts with a version magic ending in CRLF, followed by
|
||||
# a small flags word. Requiring the complete 16-byte header avoids treating
|
||||
# ordinary text beginning with CRLF as bytecode.
|
||||
if ((${#hex} >= 32)) && [[ ${hex:4:4} == 0d0a ]] &&
|
||||
[[ ${hex:8:8} =~ ^(00000000|01000000|02000000|03000000)$ ]]; then
|
||||
printf 'Python bytecode'
|
||||
return 0
|
||||
fi
|
||||
|
||||
# A COFF object starts with a known machine identifier and a non-zero,
|
||||
# reasonably bounded section count in its fixed-size 20-byte header.
|
||||
if ((${#hex} >= 40)); then
|
||||
machine=${hex:0:4}
|
||||
sections=${hex:4:4}
|
||||
flags=${hex:32:8}
|
||||
case $machine in
|
||||
4c01|6486|c001|c201|c401|64aa|6601|f001|f701|bc0e|5001|d301)
|
||||
if [[ $sections != 0000 && $sections != 00000000 && $flags =~ ^[[:xdigit:]]{8}$ ]]; then
|
||||
printf 'COFF object'
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
file_utility_kind() {
|
||||
local file=$1 description mime
|
||||
command -v file >/dev/null 2>&1 || return 1
|
||||
description=$(LC_ALL=C file -b "$file" 2>/dev/null) || return 1
|
||||
mime=$(LC_ALL=C file -b --mime-type "$file" 2>/dev/null) || mime=
|
||||
case $description in
|
||||
*ELF*) printf 'ELF executable or object'; return 0 ;;
|
||||
*PE32*|*MS-DOS\ executable*) printf 'PE/MZ executable'; return 0 ;;
|
||||
*Mach-O*|*COFF*) printf 'Mach-O or COFF compiled code'; return 0 ;;
|
||||
*RPM*package*|*Debian\ binary\ package*) printf 'binary distribution package'; return 0 ;;
|
||||
*current\ ar\ archive*|*thin\ archive*)
|
||||
printf 'ar archive or static library'; return 0 ;;
|
||||
esac
|
||||
case $mime in
|
||||
application/x-executable|application/x-pie-executable|application/x-sharedlib|\
|
||||
application/x-object|application/x-archive|application/x-dosexec|\
|
||||
application/x-rpm|application/vnd.debian.binary-package|application/wasm|\
|
||||
application/java-vm)
|
||||
printf 'compiled code or binary package'
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
looks_like_archive() {
|
||||
local file=$1 logical=$2 hex lower
|
||||
lower=$(LC_ALL=C printf '%s' "$logical" | tr '[:upper:]' '[:lower:]')
|
||||
case $lower in
|
||||
*.tar|*.tar.gz|*.tgz|*.tar.xz|*.txz|*.tar.bz2|*.tbz|*.tbz2|\
|
||||
*.tar.zst|*.tzst|*.zip|*.jar|*.war|*.deb|*.apk|*.ipa|*.cpio)
|
||||
return 0
|
||||
;;
|
||||
*.7z|*.rar)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
hex=$(LC_ALL=C od -An -v -tx1 -N 512 "$file" 2>/dev/null | tr -d '[:space:]') || return 1
|
||||
case $hex in
|
||||
504b0304*|504b0506*|504b0708*|1f8b*|425a68*|fd377a585a00*|\
|
||||
28b52ffd*|213c617263683e0a*|213c7468696e3e0a*|edabeedb*|3037303730*|\
|
||||
377abcaf271c*|526172211a0700*|526172211a070100*) return 0 ;;
|
||||
esac
|
||||
[[ ${hex:514:10} == 7573746172 ]]
|
||||
}
|
||||
|
||||
is_reference_source() {
|
||||
local logical=$1 base=${1##*/}
|
||||
case $logical in
|
||||
*scripts/check-source-only.sh|*tests/test_source_only.sh|\
|
||||
*packaging/opensuse/source-audit.sh)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
case $base in
|
||||
Makefile|makefile|GNUmakefile|CMakeLists.txt|*.mk|*.cmake|*.sh|*.bash|\
|
||||
*.c|*.h|*.cc|*.hh|*.cpp|*.hpp|*.py|*.pl|*.rb|*.spec|*.service|\
|
||||
*.yml|*.yaml|Dockerfile|Containerfile)
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
check_removed_library_reference() {
|
||||
local file=$1 logical=$2 scope=$3
|
||||
is_reference_source "$logical" || return 0
|
||||
LC_ALL=C grep -Iq . "$file" 2>/dev/null || return 0
|
||||
if LC_ALL=C grep -Eaq -- \
|
||||
'libvuptsdk[.]so|vendor/(vuptsdk|pqvaptvupt)/[^[:space:]"'"'"'`]*[.](so([.][0-9A-Za-z._-]+)?|a|o)([^0-9A-Za-z._-]|$)' \
|
||||
"$file" 2>/dev/null; then
|
||||
fail_path "$scope" "$logical" 'reference to removed vendored library'
|
||||
fi
|
||||
}
|
||||
|
||||
archive_tool() {
|
||||
if command -v bsdtar >/dev/null 2>&1; then
|
||||
printf 'bsdtar'
|
||||
elif command -v tar >/dev/null 2>&1; then
|
||||
printf 'tar'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_archive_command() {
|
||||
local command_pid watchdog_pid status
|
||||
if [[ $FORCE_PORTABLE_WATCHDOG == 0 ]] && \
|
||||
command -v timeout >/dev/null 2>&1 && \
|
||||
timeout --help 2>&1 | grep -F -- '--kill-after' >/dev/null; then
|
||||
timeout --kill-after=2 "${ARCHIVE_TIMEOUT_SECONDS}s" "$@"
|
||||
else
|
||||
"$@" &
|
||||
command_pid=$!
|
||||
(
|
||||
local elapsed=0
|
||||
while kill -0 "$command_pid" 2>/dev/null; do
|
||||
if ((elapsed >= ARCHIVE_TIMEOUT_SECONDS)); then
|
||||
kill -TERM "$command_pid" 2>/dev/null || exit 0
|
||||
sleep 1
|
||||
kill -KILL "$command_pid" 2>/dev/null || true
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
) &
|
||||
watchdog_pid=$!
|
||||
if wait "$command_pid"; then status=0; else status=$?; fi
|
||||
kill "$watchdog_pid" 2>/dev/null || true
|
||||
wait "$watchdog_pid" 2>/dev/null || true
|
||||
return "$status"
|
||||
fi
|
||||
}
|
||||
|
||||
archive_declared_bytes() {
|
||||
local tool=$1 verbose=$2 size_field=3
|
||||
if "$tool" --version 2>/dev/null | grep -Eqi 'bsdtar|libarchive'; then
|
||||
size_field=5
|
||||
fi
|
||||
awk -v field="$size_field" -v max_kib="$MAX_ARCHIVE_KIB" '
|
||||
BEGIN { total = 0; status = 0; max = max_kib * 1024 }
|
||||
{
|
||||
if (NF < field || $field !~ /^[0-9]+$/) {
|
||||
status = 2
|
||||
exit
|
||||
}
|
||||
size = $field + 0
|
||||
if (size > max - total) {
|
||||
status = 3
|
||||
exit
|
||||
}
|
||||
total += size
|
||||
}
|
||||
END {
|
||||
if (status == 0) printf "%.0f\n", total
|
||||
exit status
|
||||
}
|
||||
' "$verbose"
|
||||
}
|
||||
|
||||
extracted_regular_bytes() {
|
||||
local root=$1 file size total=0 max_bytes=$((MAX_ARCHIVE_KIB * 1024))
|
||||
while IFS= read -r -d '' file; do
|
||||
size=$(LC_ALL=C wc -c <"$file" | tr -d '[:space:]')
|
||||
[[ $size =~ ^[0-9]+$ ]] || return 2
|
||||
((size <= max_bytes - total)) || return 3
|
||||
total=$((total + size))
|
||||
done < <(find -P "$root" -type f -print0)
|
||||
printf '%s\n' "$total"
|
||||
}
|
||||
|
||||
scan_archive() {
|
||||
local archive=$1 logical=$2 scope=$3 depth=$4
|
||||
local tool archive_dir list verbose extract_dir member vline target count validation_start
|
||||
local list_limit_marker verbose_limit_marker declared_bytes actual_bytes
|
||||
local file_limit_blocks status limit_reason
|
||||
local max_total_bytes
|
||||
|
||||
if ((depth > MAX_ARCHIVE_DEPTH)); then
|
||||
fail_path "$scope" "$logical" 'nested archive depth limit exceeded'
|
||||
return
|
||||
fi
|
||||
if ! tool=$(archive_tool); then
|
||||
fail_path "$scope" "$logical" 'no supported archive inspection tool'
|
||||
return
|
||||
fi
|
||||
|
||||
if ((ARCHIVES_SCANNED >= MAX_ARCHIVES)); then
|
||||
fail_path "$scope" "$logical" 'global archive count limit exceeded'
|
||||
return
|
||||
fi
|
||||
ARCHIVES_SCANNED=$((ARCHIVES_SCANNED + 1))
|
||||
archive_dir=$(mktemp -d "$AUDIT_TMP/archive.XXXXXXXX")
|
||||
list=$archive_dir/list
|
||||
verbose=$archive_dir/verbose
|
||||
extract_dir=$archive_dir/root
|
||||
list_limit_marker=$archive_dir/member-limit
|
||||
verbose_limit_marker=$archive_dir/metadata-limit
|
||||
mkdir -p "$extract_dir"
|
||||
|
||||
if ! run_archive_command "$tool" -tf "$archive" 2>/dev/null | \
|
||||
head -c "$((MAX_ARCHIVE_LIST_KIB * 1024 + 1))" | awk \
|
||||
-v max="$MAX_ARCHIVE_MEMBERS" \
|
||||
-v max_bytes="$((MAX_ARCHIVE_LIST_KIB * 1024))" \
|
||||
-v marker="$list_limit_marker" '
|
||||
{ bytes += length($0) + 1 }
|
||||
bytes > max_bytes {
|
||||
print "archive member-name budget exceeded" > marker
|
||||
exit 43
|
||||
}
|
||||
NR > max {
|
||||
print "archive member limit exceeded" > marker
|
||||
exit 42
|
||||
}
|
||||
{ print }
|
||||
' >"$list"; then
|
||||
if [[ -s $list_limit_marker ]]; then
|
||||
limit_reason=$(<"$list_limit_marker")
|
||||
fail_path "$scope" "$logical" "$limit_reason"
|
||||
else
|
||||
fail_path "$scope" "$logical" 'archive cannot be listed safely'
|
||||
fi
|
||||
return
|
||||
fi
|
||||
count=$(LC_ALL=C wc -l <"$list" | tr -d '[:space:]')
|
||||
if ((count == 0)); then
|
||||
fail_path "$scope" "$logical" 'archive has no inspectable members'
|
||||
return
|
||||
fi
|
||||
if ((count > MAX_ARCHIVE_MEMBERS)); then
|
||||
fail_path "$scope" "$logical" 'archive member limit exceeded'
|
||||
return
|
||||
fi
|
||||
|
||||
validation_start=$FAILURES
|
||||
while IFS= read -r member || [[ -n $member ]]; do
|
||||
if ! path_stays_below_root "$member"; then
|
||||
fail_path "$scope" "$logical!$member" 'archive member escapes extraction root'
|
||||
fi
|
||||
done <"$list"
|
||||
|
||||
if ! run_archive_command "$tool" -tvf "$archive" 2>/dev/null | \
|
||||
head -c "$((MAX_ARCHIVE_LIST_KIB * 2048 + 1))" | awk \
|
||||
-v max="$count" -v max_bytes="$((MAX_ARCHIVE_LIST_KIB * 2048))" \
|
||||
-v marker="$verbose_limit_marker" '
|
||||
{ bytes += length($0) + 1 }
|
||||
bytes > max_bytes || NR > max {
|
||||
print "archive metadata output limit exceeded" > marker
|
||||
exit 44
|
||||
}
|
||||
{ print }
|
||||
' >"$verbose"; then
|
||||
if [[ -s $verbose_limit_marker ]]; then
|
||||
limit_reason=$(<"$verbose_limit_marker")
|
||||
fail_path "$scope" "$logical" "$limit_reason"
|
||||
else
|
||||
fail_path "$scope" "$logical" 'archive metadata cannot be inspected safely'
|
||||
fi
|
||||
return
|
||||
fi
|
||||
if [[ $(wc -l <"$verbose" | tr -d '[:space:]') != "$count" ]]; then
|
||||
fail_path "$scope" "$logical" 'archive metadata does not match member list'
|
||||
return
|
||||
fi
|
||||
if declared_bytes=$(archive_declared_bytes "$tool" "$verbose"); then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
if ((status == 3)); then
|
||||
fail_path "$scope" "$logical" 'archive declared-size limit exceeded before extraction'
|
||||
else
|
||||
fail_path "$scope" "$logical" 'archive member sizes cannot be accounted safely'
|
||||
fi
|
||||
return
|
||||
fi
|
||||
max_total_bytes=$((MAX_TOTAL_ARCHIVE_KIB * 1024))
|
||||
if ((declared_bytes > max_total_bytes - TOTAL_ARCHIVE_BYTES)); then
|
||||
fail_path "$scope" "$logical" 'global archive declared-size budget exceeded'
|
||||
return
|
||||
fi
|
||||
TOTAL_ARCHIVE_BYTES=$((TOTAL_ARCHIVE_BYTES + declared_bytes))
|
||||
exec 3<"$list" 4<"$verbose"
|
||||
while IFS= read -r member <&3 || [[ -n $member ]]; do
|
||||
IFS= read -r vline <&4 || vline=
|
||||
case $vline in
|
||||
l*' -> '*)
|
||||
target=${vline##* -> }
|
||||
check_link_target "$member" "$target" "$scope" "$logical!$member"
|
||||
;;
|
||||
h*' link to '*)
|
||||
target=${vline##* link to }
|
||||
if ! path_stays_below_root "$target"; then
|
||||
fail_path "$scope" "$logical!$member" 'hardlink escapes extraction root'
|
||||
fi
|
||||
;;
|
||||
b*|c*|p*|s*)
|
||||
fail_path "$scope" "$logical!$member" 'special archive member is not source data'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
exec 3<&- 4<&-
|
||||
|
||||
# Keep validation ahead of mutation when presented with hostile input.
|
||||
if ((FAILURES > validation_start)); then
|
||||
return
|
||||
fi
|
||||
|
||||
# POSIX file-size limits use 512-byte blocks; twice the KiB limit is a
|
||||
# conservative per-file ceiling. The declared total above remains tighter.
|
||||
file_limit_blocks=$((MAX_ARCHIVE_KIB * 2 + 2))
|
||||
if ! (
|
||||
ulimit -f "$file_limit_blocks" 2>/dev/null || true
|
||||
run_archive_command "$tool" --no-same-owner --no-same-permissions \
|
||||
-xf "$archive" \
|
||||
-C "$extract_dir" > /dev/null 2>&1
|
||||
); then
|
||||
fail_path "$scope" "$logical" 'archive cannot be extracted for inspection'
|
||||
return
|
||||
fi
|
||||
if actual_bytes=$(extracted_regular_bytes "$extract_dir"); then
|
||||
:
|
||||
else
|
||||
fail_path "$scope" "$logical" 'archive expanded-size limit exceeded'
|
||||
return
|
||||
fi
|
||||
if ((actual_bytes > declared_bytes)); then
|
||||
fail_path "$scope" "$logical" 'archive expanded beyond its declared member sizes'
|
||||
return
|
||||
fi
|
||||
scan_tree "$extract_dir" "$scope" "$depth" "$logical"
|
||||
}
|
||||
|
||||
scan_regular() {
|
||||
local file=$1 logical=$2 scope=$3 depth=$4 kind lower
|
||||
SCANNED=$((SCANNED + 1))
|
||||
|
||||
if [[ ! -r $file ]]; then
|
||||
fail_path "$scope" "$logical" 'file cannot be read for audit'
|
||||
return
|
||||
fi
|
||||
|
||||
if forbidden_extension "$logical"; then
|
||||
fail_path "$scope" "$logical" 'forbidden compiled/package extension'
|
||||
fi
|
||||
lower=$(LC_ALL=C printf '%s' "$logical" | tr '[:upper:]' '[:lower:]')
|
||||
case $lower in
|
||||
*.bin)
|
||||
if ! is_declared_binary_data "$logical"; then
|
||||
fail_path "$scope" "$logical" \
|
||||
'undeclared .bin data (manifest needs purpose, provenance, and SPDX license)'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
if LC_ALL=C grep -Eaqm1 '^version https://git-lfs[.]github[.]com/spec/v1\r?$' "$file" 2>/dev/null; then
|
||||
fail_path "$scope" "$logical" 'unresolved Git LFS pointer'
|
||||
fi
|
||||
if kind=$(magic_kind "$file"); then
|
||||
fail_path "$scope" "$logical" "$kind"
|
||||
elif kind=$(file_utility_kind "$file"); then
|
||||
fail_path "$scope" "$logical" "$kind"
|
||||
fi
|
||||
check_removed_library_reference "$file" "$logical" "$scope"
|
||||
|
||||
if looks_like_archive "$file" "$logical"; then
|
||||
scan_archive "$file" "$logical" "$scope" "$((depth + 1))"
|
||||
fi
|
||||
}
|
||||
|
||||
scan_tree() {
|
||||
local tree=$1 scope=$2 depth=${3:-0} prefix=${4:-}
|
||||
local path relative logical target resolved canonical_tree
|
||||
|
||||
if [[ ! -d $tree ]]; then
|
||||
fail_path "$scope" "$tree" 'tree does not exist'
|
||||
return
|
||||
fi
|
||||
canonical_tree=$(canonicalize_allow_missing "$tree") || {
|
||||
fail_path "$scope" "$tree" 'cannot canonicalize audit root'
|
||||
return
|
||||
}
|
||||
while IFS= read -r -d '' path; do
|
||||
relative=${path#"$tree"/}
|
||||
logical=$relative
|
||||
[[ -z $prefix ]] || logical=$prefix!$relative
|
||||
if [[ -L $path ]]; then
|
||||
target=$(readlink "$path")
|
||||
check_link_target "$relative" "$target" "$scope" "$logical"
|
||||
resolved=$(canonicalize_allow_missing "$path") || {
|
||||
fail_path "$scope" "$logical" 'cannot canonicalize symlink'
|
||||
continue
|
||||
}
|
||||
case $resolved in
|
||||
"$canonical_tree"|"$canonical_tree"/*) ;;
|
||||
*) fail_path "$scope" "$logical" 'symlink resolves outside audit root' ;;
|
||||
esac
|
||||
elif [[ -f $path ]]; then
|
||||
scan_regular "$path" "$logical" "$scope" "$depth"
|
||||
else
|
||||
fail_path "$scope" "$logical" 'unsupported special filesystem entry'
|
||||
fi
|
||||
done < <(find -P "$tree" -path "$tree/.git" -prune -o \
|
||||
\( -type f -o -type l -o \( ! -type d \) \) -print0)
|
||||
}
|
||||
|
||||
scan_index() {
|
||||
local repo=$1 record metadata logical mode object stage blob target
|
||||
local serial=0
|
||||
|
||||
while IFS= read -r -d '' record; do
|
||||
metadata=${record%%$'\t'*}
|
||||
logical=${record#*$'\t'}
|
||||
read -r mode object stage <<< "$metadata"
|
||||
[[ $stage == 0 ]] || continue
|
||||
case $mode in
|
||||
100*)
|
||||
serial=$((serial + 1))
|
||||
blob=$AUDIT_TMP/index.$serial
|
||||
if git -C "$repo" cat-file blob "$object" >"$blob" 2>/dev/null; then
|
||||
scan_regular "$blob" "$logical" tracked 0
|
||||
else
|
||||
fail_path tracked "$logical" 'cannot read indexed blob'
|
||||
fi
|
||||
;;
|
||||
120000)
|
||||
if target=$(git -C "$repo" cat-file blob "$object" 2>/dev/null); then
|
||||
check_link_target "$logical" "$target" tracked
|
||||
else
|
||||
fail_path tracked "$logical" 'cannot read indexed symlink'
|
||||
fi
|
||||
;;
|
||||
160000) fail_path tracked "$logical" 'Git submodule entry is not source-only' ;;
|
||||
*) fail_path tracked "$logical" 'unsupported Git index mode' ;;
|
||||
esac
|
||||
done < <(git -C "$repo" ls-files --stage -z)
|
||||
}
|
||||
|
||||
scan_git_archive() {
|
||||
local repo=$1 revision=$2 label=$3 tarball
|
||||
tarball=$(mktemp "$AUDIT_TMP/git-archive.XXXXXXXX")
|
||||
if ! git -C "$repo" archive --format=tar "$revision" >"$tarball" 2>/dev/null; then
|
||||
fail_path "$label" "$revision" 'cannot create Git source archive'
|
||||
return
|
||||
fi
|
||||
scan_archive "$tarball" "$revision.tar" "$label" 0
|
||||
}
|
||||
|
||||
if ((REPOSITORY_AUDIT)); then
|
||||
if [[ -z $ROOT ]]; then
|
||||
if ! ROOT=$(git rev-parse --show-toplevel 2>/dev/null); then
|
||||
printf 'ERROR: not inside a Git repository; use --tree or --archive\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
if ! ROOT=$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null); then
|
||||
printf 'ERROR: --root is not a Git repository\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
scan_index "$ROOT"
|
||||
scan_tree "$ROOT" working-tree 0
|
||||
if git -C "$ROOT" rev-parse --verify -q 'HEAD^{commit}' >/dev/null; then
|
||||
scan_git_archive "$ROOT" HEAD git-archive-HEAD
|
||||
else
|
||||
fail_path git-archive-HEAD HEAD 'repository has no commit'
|
||||
fi
|
||||
for target in "${TAGS[@]}"; do
|
||||
if git -C "$ROOT" rev-parse --verify -q "$target^{commit}" >/dev/null; then
|
||||
scan_git_archive "$ROOT" "$target" "git-archive-$target"
|
||||
else
|
||||
fail_path git-tag "$target" 'revision does not resolve to a commit'
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
for target in "${TREES[@]}"; do
|
||||
if [[ -d $target ]]; then
|
||||
target=$(canonicalize_allow_missing "$target") || {
|
||||
fail_path standalone-tree "$target" 'cannot canonicalize tree'
|
||||
continue
|
||||
}
|
||||
scan_tree "$target" standalone-tree 0
|
||||
else
|
||||
fail_path standalone-tree "$target" 'tree does not exist'
|
||||
fi
|
||||
done
|
||||
|
||||
for target in "${ARCHIVES[@]}"; do
|
||||
if [[ -f $target ]]; then
|
||||
target=$(canonicalize_allow_missing "$target") || {
|
||||
fail_path standalone-archive "$target" 'cannot canonicalize archive'
|
||||
continue
|
||||
}
|
||||
scan_archive "$target" "${target##*/}" standalone-archive 0
|
||||
else
|
||||
fail_path standalone-archive "$target" 'archive does not exist'
|
||||
fi
|
||||
done
|
||||
|
||||
if ((FAILURES == 0)); then
|
||||
printf 'PASS source-only: %d files, %d archives\n' "$SCANNED" "$ARCHIVES_SCANNED"
|
||||
exit 0
|
||||
fi
|
||||
printf 'FAIL source-only: %d finding(s), %d files, %d archives\n' \
|
||||
"$FAILURES" "$SCANNED" "$ARCHIVES_SCANNED"
|
||||
exit 1
|
||||
162
scripts/export-opensuse-package.sh
Executable file
162
scripts/export-opensuse-package.sh
Executable file
|
|
@ -0,0 +1,162 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
umask 077
|
||||
export LC_ALL=C
|
||||
export TZ=UTC
|
||||
|
||||
die() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
need_command() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "required command not found: $1"
|
||||
}
|
||||
|
||||
for command_name in awk basename bsdtar cat file find git grep mkdir mktemp mv rm sha256sum sort tar touch unzip xargs zip; do
|
||||
need_command "$command_name"
|
||||
done
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) ||
|
||||
die 'run this script from the ZUPT Git repository'
|
||||
|
||||
cd "$repo_root"
|
||||
|
||||
remote_urls=$(git remote -v | awk '{print $2}' | sort -u)
|
||||
grep -Eq '(^|[/:])cristiancmoises/zupt(\.git)?$' <<<"$remote_urls" ||
|
||||
die 'no configured remote identifies cristiancmoises/zupt'
|
||||
grep -Eqi 'vaptvupt-web|zupt-web' <<<"$remote_urls" &&
|
||||
die 'a configured remote points to a web project'
|
||||
|
||||
version=$(awk -F'"' '/^#define ZUPT_VERSION_STRING / { print $2; exit }' include/zupt.h)
|
||||
[[ -n "$version" ]] || die 'cannot determine version from include/zupt.h'
|
||||
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] ||
|
||||
die "source version is not a stable semantic version: $version"
|
||||
|
||||
release_tag=${1:-v$version}
|
||||
[[ "$release_tag" == "v$version" ]] ||
|
||||
die "tag $release_tag does not match source version v$version"
|
||||
|
||||
tag_ref="refs/tags/$release_tag"
|
||||
git show-ref --verify --quiet "$tag_ref" || die "tag does not exist: $release_tag"
|
||||
[[ $(git cat-file -t "$tag_ref") == tag ]] || die "tag is not annotated: $release_tag"
|
||||
|
||||
head_commit=$(git rev-parse HEAD)
|
||||
tag_commit=$(git rev-parse "$tag_ref^{commit}")
|
||||
[[ "$head_commit" == "$tag_commit" ]] ||
|
||||
die "HEAD $head_commit does not match $release_tag commit $tag_commit"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
die 'tracked working tree changes must be committed before export'
|
||||
fi
|
||||
|
||||
scanner="$repo_root/scripts/check-source-only.sh"
|
||||
[[ -f "$scanner" ]] || die 'missing scripts/check-source-only.sh'
|
||||
bash "$scanner" --tag "$release_tag"
|
||||
|
||||
git check-ignore -q --no-index dist/ ||
|
||||
die 'dist/ must be ignored before creating the handoff'
|
||||
|
||||
work_dir=$(mktemp -d "${TMPDIR:-/tmp}/zupt-opensuse-export.XXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n ${work_dir:-} && -d ${work_dir:-} ]]; then
|
||||
rm -rf -- "$work_dir"
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
bundle_name="zupt-openSUSE-source-only-$release_tag"
|
||||
bundle_root="$work_dir/$bundle_name"
|
||||
mkdir -p "$bundle_root"
|
||||
|
||||
git archive "$release_tag" \
|
||||
packaging/opensuse \
|
||||
scripts/check-source-only.sh \
|
||||
scripts/test-installed-zupt.sh \
|
||||
LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-COMMERCIAL \
|
||||
LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0 \
|
||||
NOTICE THIRD-PARTY-NOTICES.md |
|
||||
tar -xf - -C "$bundle_root"
|
||||
|
||||
handoff_legal_files=(
|
||||
LICENSE LICENSE-AGPL-3.0 LICENSE-GPL-3.0 LICENSE-COMMERCIAL
|
||||
LICENSE-BSD-2-Clause LICENSE-BSD-3-Clause LICENSE-CC0-1.0
|
||||
NOTICE THIRD-PARTY-NOTICES.md
|
||||
)
|
||||
for legal_file in "${handoff_legal_files[@]}"; do
|
||||
[[ -s $bundle_root/$legal_file ]] || \
|
||||
die "handoff legal file is missing or empty: $legal_file"
|
||||
done
|
||||
|
||||
cat >"$bundle_root/HANDOFF.md" <<EOF
|
||||
# ZUPT openSUSE source-only handoff
|
||||
|
||||
- Version: $version
|
||||
- Tag: $release_tag
|
||||
- Commit: $tag_commit
|
||||
- Source repository: https://github.com/cristiancmoises/zupt
|
||||
- Package policy: source-only; no RPM, executable, object or library included
|
||||
- Audit entry point: packaging/opensuse/source-audit.sh
|
||||
- License payload: complete public license texts, NOTICE, and third-party record
|
||||
|
||||
Release summary and the validation matrix are recorded in
|
||||
\`packaging/opensuse/zupt.changes\` and
|
||||
\`packaging/opensuse/README.md\`. Run audit commands from this handoff's
|
||||
top-level directory so the wrapper can find \`scripts/check-source-only.sh\`.
|
||||
A result marked SKIP is not a PASS.
|
||||
EOF
|
||||
|
||||
checksum_manifest="$work_dir/SHA256SUMS"
|
||||
(
|
||||
cd "$bundle_root"
|
||||
find . -type f -print0 |
|
||||
LC_ALL=C sort -z |
|
||||
xargs -0 sha256sum >"$checksum_manifest"
|
||||
)
|
||||
mv "$checksum_manifest" "$bundle_root/SHA256SUMS"
|
||||
(
|
||||
cd "$bundle_root"
|
||||
sha256sum -c SHA256SUMS
|
||||
)
|
||||
|
||||
mkdir -p "$repo_root/dist"
|
||||
zip_path="$repo_root/dist/$bundle_name.zip"
|
||||
checksum_path="$zip_path.sha256"
|
||||
[[ ! -e "$zip_path" && ! -e "$checksum_path" ]] ||
|
||||
die "handoff already exists: $zip_path"
|
||||
|
||||
source_epoch=$(git show -s --format=%ct "$release_tag^{commit}")
|
||||
[[ "$source_epoch" =~ ^[0-9]+$ ]] || die 'tag commit time is not numeric'
|
||||
find "$bundle_root" -exec touch -d "@$source_epoch" {} +
|
||||
(
|
||||
cd "$work_dir"
|
||||
find "$bundle_name" -print | LC_ALL=C sort | zip -X -q "$zip_path" -@
|
||||
)
|
||||
|
||||
unzip -t "$zip_path"
|
||||
bash "$scanner" --archive "$zip_path"
|
||||
|
||||
verify_dir="$work_dir/verified"
|
||||
mkdir -p "$verify_dir"
|
||||
unzip -q "$zip_path" -d "$verify_dir"
|
||||
extracted_root="$verify_dir/$bundle_name"
|
||||
[[ -d "$extracted_root" ]] || die 'validated ZIP did not contain the expected root'
|
||||
(
|
||||
cd "$extracted_root"
|
||||
sha256sum -c SHA256SUMS
|
||||
)
|
||||
bash "$scanner" --tree "$extracted_root"
|
||||
|
||||
(
|
||||
cd "$repo_root/dist"
|
||||
sha256sum "$(basename "$zip_path")" >"$(basename "$checksum_path")"
|
||||
sha256sum -c "$(basename "$checksum_path")"
|
||||
)
|
||||
|
||||
printf 'PASS: source-only openSUSE handoff created\n'
|
||||
printf 'ZIP: %s\n' "$zip_path"
|
||||
printf 'SHA-256: %s\n' "$checksum_path"
|
||||
printf 'Tag: %s\nCommit: %s\n' "$release_tag" "$tag_commit"
|
||||
133
scripts/test-installed-zupt.sh
Executable file
133
scripts/test-installed-zupt.sh
Executable file
|
|
@ -0,0 +1,133 @@
|
|||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
# Copyright (c) 2026 Cristian Cezar Moisés
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
umask 077
|
||||
export LC_ALL=C
|
||||
|
||||
die() {
|
||||
printf 'FAIL: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
pass() {
|
||||
printf 'PASS: %s\n' "$1"
|
||||
}
|
||||
|
||||
hash_tree() {
|
||||
local tree=$1
|
||||
(
|
||||
cd -- "$tree"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
find . -type f -exec sha256sum {} \; | LC_ALL=C sort
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
find . -type f -exec shasum -a 256 {} \; | LC_ALL=C sort
|
||||
else
|
||||
die 'sha256sum or shasum is required'
|
||||
fi
|
||||
)
|
||||
}
|
||||
|
||||
# ZUPT_BIN is the public override. VAPTVUPT_BIN remains a compatibility
|
||||
# fallback for existing automation during the package-name transition.
|
||||
candidate=${1:-${ZUPT_BIN:-${VAPTVUPT_BIN:-zupt}}}
|
||||
if [[ $candidate == */* ]]; then
|
||||
[[ -x $candidate ]] || die "executable not found: $candidate"
|
||||
binary=$(cd -- "$(dirname -- "$candidate")" && pwd -P)/$(basename -- "$candidate")
|
||||
else
|
||||
binary=$(command -v -- "$candidate" || true)
|
||||
[[ -n $binary ]] || die "executable not found on PATH: $candidate"
|
||||
fi
|
||||
|
||||
test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-installed.XXXXXX")
|
||||
trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT HUP INT TERM
|
||||
|
||||
input=$test_root/input
|
||||
plain_out=$test_root/plain-out
|
||||
password_out=$test_root/password-out
|
||||
escape_out=$test_root/escape-out
|
||||
outside=$test_root/outside
|
||||
mkdir -p "$input/subdir" "$plain_out" "$password_out" "$escape_out" "$outside"
|
||||
|
||||
printf 'ZUPT installed smoke test\nsecond line\n' > "$input/text file.txt"
|
||||
printf 'conteúdo UTF-8\n' > "$input/subdir/café-安全.txt"
|
||||
: > "$input/empty file"
|
||||
dd if=/dev/urandom of="$input/subdir/random.bin" bs=4096 count=8 2>/dev/null
|
||||
printf 'do-not-overwrite\n' > "$outside/sentinel"
|
||||
|
||||
"$binary" --version > "$test_root/version.log" 2>&1
|
||||
grep -q '^zupt ' "$test_root/version.log" || die "--version did not identify zupt"
|
||||
pass '--version'
|
||||
|
||||
"$binary" --help > "$test_root/help.log" 2>&1
|
||||
grep -q '^Usage:' "$test_root/help.log" || die "--help did not contain Usage"
|
||||
pass '--help'
|
||||
|
||||
if "$binary" --definitely-invalid-option > "$test_root/invalid.log" 2>&1; then
|
||||
die 'invalid option returned success'
|
||||
fi
|
||||
pass 'invalid option returns failure'
|
||||
|
||||
plain_archive=$test_root/plain.zupt
|
||||
"$binary" compress "$plain_archive" "$input" > "$test_root/plain-compress.log" 2>&1
|
||||
"$binary" test "$plain_archive" > "$test_root/plain-test.log" 2>&1
|
||||
"$binary" extract -o "$plain_out" "$plain_archive" > "$test_root/plain-extract.log" 2>&1
|
||||
extracted_markers=()
|
||||
while IFS= read -r -d '' marker; do
|
||||
extracted_markers[${#extracted_markers[@]}]=$marker
|
||||
done < <(find "$plain_out" -type f -name 'text file.txt' -print0)
|
||||
[[ ${#extracted_markers[@]} -eq 1 ]] || die 'extracted tree is missing or ambiguous'
|
||||
extracted_input=$(dirname -- "${extracted_markers[0]}")
|
||||
[[ -f $extracted_input/subdir/café-安全.txt && -f $extracted_input/empty\ file ]] || \
|
||||
die 'extracted tree is incomplete'
|
||||
diff -r -- "$input" "$extracted_input" > "$test_root/plain-diff.log" || die 'plain round-trip differs'
|
||||
|
||||
hash_tree "$input" > "$test_root/original.sha256"
|
||||
hash_tree "$extracted_input" > "$test_root/extracted.sha256"
|
||||
cmp -- "$test_root/original.sha256" "$test_root/extracted.sha256" || die 'round-trip SHA-256 manifests differ'
|
||||
pass 'text, random, empty, nested, spaces and UTF-8 round-trip'
|
||||
|
||||
password='ZUPT-test-password-2026!'
|
||||
password_archive=$test_root/password.zupt
|
||||
"$binary" compress -p "$password" "$password_archive" "$input/text file.txt" > "$test_root/password-compress.log" 2>&1
|
||||
"$binary" test -p "$password" "$password_archive" > "$test_root/password-test.log" 2>&1
|
||||
"$binary" extract -p "$password" -o "$password_out" "$password_archive" > "$test_root/password-extract.log" 2>&1
|
||||
password_markers=()
|
||||
while IFS= read -r -d '' marker; do
|
||||
password_markers[${#password_markers[@]}]=$marker
|
||||
done < <(find "$password_out" -type f -name 'text file.txt' -print0)
|
||||
[[ ${#password_markers[@]} -eq 1 ]] || die 'password extraction is missing or ambiguous'
|
||||
cmp -- "$input/text file.txt" "${password_markers[0]}" || die 'password round-trip differs'
|
||||
if "$binary" extract -p 'incorrect-password' -o "$test_root/wrong-password-out" "$password_archive" > "$test_root/wrong-password.log" 2>&1; then
|
||||
die 'incorrect password returned success'
|
||||
fi
|
||||
pass 'password round-trip and incorrect-password rejection'
|
||||
|
||||
archive_size=$(wc -c < "$plain_archive")
|
||||
(( archive_size > 32 )) || die 'archive unexpectedly small'
|
||||
head -c "$((archive_size - 17))" "$plain_archive" > "$test_root/corrupt.zupt"
|
||||
if "$binary" test "$test_root/corrupt.zupt" > "$test_root/corrupt.log" 2>&1; then
|
||||
die 'truncated archive returned success'
|
||||
fi
|
||||
pass 'corrupt archive rejection'
|
||||
|
||||
archive_input_rel=${extracted_input#"$plain_out"/}
|
||||
[[ $archive_input_rel != "$extracted_input" && $archive_input_rel != /* ]] || \
|
||||
die 'cannot determine the archive extraction path'
|
||||
mkdir -p -- "$escape_out/$(dirname -- "$archive_input_rel")"
|
||||
ln -s -- "$outside" "$escape_out/$archive_input_rel"
|
||||
"$binary" extract -o "$escape_out" "$plain_archive" > "$test_root/escape.log" 2>&1 || true
|
||||
[[ $(<"$outside/sentinel") == 'do-not-overwrite' ]] || die 'extraction overwrote outside sentinel'
|
||||
[[ ! -e "$outside/text file.txt" && ! -e "$outside/subdir" ]] || die 'extraction escaped through a destination symlink'
|
||||
pass 'no write outside extraction destination'
|
||||
|
||||
if [[ $(id -u) -eq 0 ]]; then
|
||||
printf 'SKIP: unprivileged execution (test process is root)\n'
|
||||
else
|
||||
[[ -r $plain_archive && -x $binary ]] || die 'unprivileged process cannot read archive or execute binary'
|
||||
pass 'execution as an unprivileged user'
|
||||
fi
|
||||
|
||||
printf 'PASS: installed ZUPT functional test suite\n'
|
||||
Loading…
Reference in a new issue