feat(safety): add atomic WireGuard mutation and rollback engine

This commit is contained in:
2026-10-12 23:04:22 +03:00
parent e5e3064885
commit 4e3c5347f2
10 changed files with 1122 additions and 6 deletions
+8
View File
@@ -0,0 +1,8 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32; do-not-execute
+2
View File
@@ -0,0 +1,2 @@
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= 51820 0
BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB= (none) vpn.example.invalid:51820 192.0.2.2/32 1700000100 100 200 25
+10
View File
@@ -0,0 +1,10 @@
[Interface]
Address = 192.0.2.1/24
ListenPort = 51820
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
[Peer]
# wg-manager-client: fixture
PublicKey = BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=
AllowedIPs = 192.0.2.2/32
Endpoint = vpn.example.invalid:51820
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -Eeuo pipefail
command_name=${0##*/}
: "${TEST_MOCK_LOG:?TEST_MOCK_LOG must identify the isolated test log}"
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
printf '\n' >>"$TEST_MOCK_LOG"
[[ $# == 2 && $1 == -n && $2 =~ ^[0-9]+$ ]] || exit 99
if [[ -n ${MOCK_FLOCK_CHANGE_TO:-} && -z ${MOCK_FLOCK_CHANGED_MARKER:-} && -f $MOCK_FLOCK_CHANGE_TO && -n ${WG_MANAGER_CONFIG:-} ]]; then
cp -- "$MOCK_FLOCK_CHANGE_TO" "$WG_MANAGER_CONFIG"
export MOCK_FLOCK_CHANGED_MARKER=1
fi
if [[ ${MOCK_FLOCK_BUSY:-0} == 1 ]]; then exit 1; fi
if [[ ${MOCK_FLOCK_FAIL:-0} == 1 ]]; then exit 97; fi
exit 0
+44 -1
View File
@@ -6,10 +6,53 @@ allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
printf '\n' >>"$TEST_MOCK_LOG"
if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -n ${WG_MOCK_DUMP_FILE:-} && -f $WG_MOCK_DUMP_FILE ]]; then
if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -z ${WG_MOCK_DUMP_SEQUENCE_DIR:-} && -n ${WG_MOCK_DUMP_FILE:-} && -f $WG_MOCK_DUMP_FILE ]]; then
if [[ -n ${WG_MOCK_DUMP_FAIL_ON_CALL:-} ]]; then
dump_counter=${WG_MOCK_DUMP_FAIL_COUNTER_FILE:-${TEST_ROOT}/dump-counter}
dump_calls=0; [[ -f $dump_counter ]] && read -r dump_calls <"$dump_counter"; ((dump_calls += 1)); printf '%s\n' "$dump_calls" >"$dump_counter"
[[ $dump_calls != "$WG_MOCK_DUMP_FAIL_ON_CALL" ]] || exit 98
fi
cat -- "$WG_MOCK_DUMP_FILE"
exit 0
fi
if [[ $# == 3 && $1 == show && -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" && $3 == dump && -n ${WG_MOCK_DUMP_SEQUENCE_DIR:-} ]]; then
[[ ${WG_MOCK_DUMP_SEQUENCE_DIR} == "${TEST_ROOT}"/* && -d ${WG_MOCK_DUMP_SEQUENCE_DIR} && ! -L ${WG_MOCK_DUMP_SEQUENCE_DIR} ]] || exit 99
sequence_counter=${WG_MOCK_DUMP_SEQUENCE_DIR}/counter
[[ ! -L "$sequence_counter" && ( -f "$sequence_counter" || ! -e "$sequence_counter" ) ]] || exit 99
sequence_number=0
[[ -f "$sequence_counter" ]] && read -r sequence_number <"$sequence_counter"
((sequence_number += 1))
printf '%s\n' "$sequence_number" >"$sequence_counter"
sequence_file=${WG_MOCK_DUMP_SEQUENCE_DIR}/${sequence_number}
[[ -f "$sequence_file" ]] && cat -- "$sequence_file" && exit 0
printf 'Mock wg dump sequence exhausted\n' >&2
exit 98
fi
if [[ $# == 3 && $1 == syncconf ]]; then
[[ -n ${WG_MOCK_INTERFACE:-} && $2 == "$WG_MOCK_INTERFACE" ]] || { printf 'Mock wg wrong interface\n' >&2; exit 99; }
if [[ ${WG_SYNC_FAIL_ONCE_FILE:-} && ! -e ${WG_SYNC_FAIL_ONCE_FILE} ]]; then
: >"$WG_SYNC_FAIL_ONCE_FILE"
printf 'Mock wg syncconf failure once\n' >&2
exit 97
fi
if [[ -n ${WG_SYNC_FAIL_ON_CALL:-} ]]; then
sync_counter=${WG_SYNC_FAIL_COUNTER_FILE:-${TEST_ROOT}/sync-counter}
sync_calls=0; [[ -f $sync_counter ]] && read -r sync_calls <"$sync_counter"; ((sync_calls += 1)); printf '%s\n' "$sync_calls" >"$sync_counter"
[[ $sync_calls != "$WG_SYNC_FAIL_ON_CALL" ]] || { printf 'Mock wg syncconf failure\n' >&2; exit 97; }
fi
[[ -f $3 && ! -L $3 && ${WG_SYNC_FAIL:-0} != 1 && -n ${TEST_ROOT:-} && $3 == "${TEST_ROOT}"/* ]] || { printf 'Mock wg syncconf failure\n' >&2; exit 97; }
[[ $(stat -c '%a' -- "$3") == 600 ]] || { printf 'Mock wg syncconf unsafe mode\n' >&2; exit 97; }
if [[ -n ${WG_SYNC_EXPECTED_STRIP:-} ]]; then
[[ -f $WG_SYNC_EXPECTED_STRIP ]] || exit 97
cmp -s -- "$3" "$WG_SYNC_EXPECTED_STRIP" || { printf 'Mock wg syncconf unexpected stripped content\n' >&2; exit 97; }
fi
if [[ -n ${WG_SYNC_READY_FIFO:-} ]]; then
[[ ${WG_SYNC_READY_FIFO} == "${TEST_ROOT}"/* && -p ${WG_SYNC_READY_FIFO} && ${WG_SYNC_RELEASE_FIFO:-} == "${TEST_ROOT}"/* && -p ${WG_SYNC_RELEASE_FIFO} ]] || exit 99
printf 'ready\n' >"$WG_SYNC_READY_FIFO"
IFS= read -r _ <"${WG_SYNC_RELEASE_FIFO:?WG_SYNC_RELEASE_FIFO must be set}"
fi
exit 0
fi
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
exit 99
+8
View File
@@ -6,6 +6,14 @@ allow_empty=${MOCK_ALLOW_EMPTY_INVOCATION:-}
printf '%q' "$command_name" >>"$TEST_MOCK_LOG"
for argument in "$@"; do printf ' %q' "$argument" >>"$TEST_MOCK_LOG"; done
printf '\n' >>"$TEST_MOCK_LOG"
if [[ $# == 2 && $1 == strip ]]; then
[[ -f $2 && ! -L $2 && $(stat -c '%a' -- "$2") == 600 && -n ${TEST_ROOT:-} && $2 == "${TEST_ROOT}"/* ]] || { printf 'Mock wg-quick strip failure\n' >&2; exit 97; }
case ${WG_QUICK_STRIP_FAIL:-0}:${2##*/} in
1:*|staged:*.stage.*|live:wg0.conf) printf 'Mock wg-quick strip failure\n' >&2; exit 97 ;;
esac
if [[ -n ${WG_QUICK_STRIP_OUTPUT:-} && -f $WG_QUICK_STRIP_OUTPUT ]]; then cat -- "$WG_QUICK_STRIP_OUTPUT"; fi
exit 0
fi
if [[ -n "$allow_empty" && "$allow_empty" != "$command_name" ]]; then
printf 'Unexpected mock invocation: %s does not allow empty calls for %s\n' "$command_name" "$allow_empty" >&2
exit 99
+634 -2
View File
@@ -11,7 +11,7 @@ source "${PROJECT_DIR}/wg-manager.sh"
case $# in
0) ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
1) case $1 in config|parser|status|list|list-redirected|list-deterministic|transaction|rollback|locking|backups|mutation-preserves-source|secret-modes) ;; *) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;; esac ;;
*) printf 'Ошибка: неизвестная группа тестов или лишние аргументы\n' >&2; exit 64 ;;
esac
test_group=${1-}
@@ -237,7 +237,7 @@ run_config_tests() {
'triple_ipv6_dns|CLIENT_DNS|192.0.2.53,:::|Некорректный адрес DNS'
'triple_ipv6_endpoint|SERVER_ENDPOINT|:::|Некорректная конечная точка'
'invalid_ipv6_mode|USE_IPV6|2|USE_IPV6 должен'
'invalid_retention|BACKUP_RETENTION|0|Некорректное число резервных копий'
'invalid_retention|BACKUP_RETENTION|-1|Некорректное число резервных копий'
'invalid_handshake|RECENT_HANDSHAKE_SECONDS|604801|Некорректный срок handshake'
)
for semantic_case in "${semantic_cases[@]}"; do
@@ -781,6 +781,635 @@ run_m2_tests() {
done
}
transaction_builder_copy() {
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
sed -e 's/^Address = 192\.0\.2\.1\/24$/Address = 192.0.2.1\/25/' "$WG_CONF" >"$1"
}
transaction_builder_invalid() {
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
printf '[broken\n' >"$1"
}
transaction_builder_fail() {
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
return 77
}
transaction_builder_symlink() {
[[ ${TRANSACTION_IN_CRITICAL:-0} == 1 ]] || return 91
rm -- "$1"
ln -s -- "$WG_CONF" "$1"
}
run_m3_tests() {
local config_file="${TEST_ROOT}/m3.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" status output before after backup_count manifest_content parent_exit parent_int parent_term
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
cp -- "${PROJECT_DIR}/tests/fixtures/m2/runtime.dump" "$runtime_file"
write_m2_config "$config_file" "$fixture"
export WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="${TEST_ROOT}/backups" WG_MANAGER_LOG_DIR="${TEST_ROOT}/logs" WG_MANAGER_METADATA_DIR="${TEST_ROOT}/metadata" WG_MANAGER_ARCHIVE_DIR="${TEST_ROOT}/archive" WG_MANAGER_LOCK_FILE="${TEST_ROOT}/lock" WG_MANAGER_NOW=1700000100 WG_MANAGER_TEST_MODE=1 WG_MOCK_DUMP_FILE="$runtime_file" WG_MOCK_INTERFACE=wg0
unset WG_SYNC_FAIL WG_SYNC_FAIL_ONCE_FILE WG_SYNC_FAIL_ON_CALL WG_QUICK_STRIP_FAIL TRANSACTION_METADATA_COPY_FAIL TRANSACTION_BACKUP_FAIL TRANSACTION_BACKUP_DIR_CHMOD_FAIL TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL TRANSACTION_ATOMIC_REPLACE_FAIL MOCK_FLOCK_BUSY MOCK_FLOCK_FAIL WG_MOCK_DUMP_SEQUENCE_DIR WG_MOCK_DUMP_FAIL_ON_CALL
set_runtime_paths
load_config
write_alice_metadata
begin_test 'fresh process transaction discovers config before lock'
local fresh_transaction_script="${TEST_ROOT}/fresh-transaction.sh" fresh_status
printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'transaction_builder_fresh() { cp -- "$WG_CONF" "$1"; }' 'run_transaction update fresh transaction_builder_fresh "$WG_MANAGER_METADATA_DIR/alice.meta"' >"$fresh_transaction_script"
chmod 700 "$fresh_transaction_script"
set +e; /bin/bash "$fresh_transaction_script" "$PROJECT_DIR/wg-manager.sh" >/dev/null 2>&1; fresh_status=$?; set -e
assert_equal 0 "$fresh_status" 'fresh process transaction'
if [[ -n $test_group && $test_group != transaction ]]; then
case $test_group in
locking)
begin_test 'lock contention never enters the mutation callback'
: >"${TEST_ROOT}/builder-marker"
transaction_builder_marker() { : >"${TEST_ROOT}/builder-marker.entered"; return 0; }
export MOCK_FLOCK_BUSY=1
set +e; run_transaction test lock transaction_builder_marker >/dev/null 2>&1; status=$?; set -e
unset MOCK_FLOCK_BUSY
assert_equal "$EXIT_OPERATION" "$status" 'lock contention status'
assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'callback не вызван'
begin_test 'real isolated flock serializes concurrent transactions'
local flock_path="${TEST_ROOT}/flock-path" first_pid real_flock first_status
mkdir -p "$flock_path"
for command_name in bash env cp chmod mv mkdir mktemp stat date rm sed sort paste cat; do ln -s -- "$(type -P "$command_name")" "${flock_path}/${command_name}"; done
real_flock=$(PATH=/usr/bin:/bin type -P flock)
ln -s -- "$real_flock" "${flock_path}/flock"
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg" "${flock_path}/wg"
ln -s -- "${PROJECT_DIR}/tests/mocks/bin/wg-quick" "${flock_path}/wg-quick"
mkfifo "${TEST_ROOT}/builder-ready" "${TEST_ROOT}/builder-release"
transaction_builder_hold() { printf 'ready\n' >"${TEST_ROOT}/builder-ready"; IFS= read -r _ <"${TEST_ROOT}/builder-release"; cp -- "$WG_CONF" "$1"; }
local saved_path=$PATH
PATH=$flock_path
: >"${TEST_ROOT}/first-status"
run_transaction test concurrency transaction_builder_hold >"${TEST_ROOT}/first-status" 2>&1 & first_pid=$!
IFS= read -r ready <"${TEST_ROOT}/builder-ready"
assert_equal ready "$ready" 'первый builder вошёл'
set +e; run_transaction test concurrency transaction_builder_marker >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_OPERATION" "$status" 'второй transaction заблокирован'
assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'второй builder не вошёл'
printf 'release\n' >"${TEST_ROOT}/builder-release"
set +e; wait "$first_pid"; first_status=$?; set -e
assert_equal 0 "$first_status" 'первый transaction завершён успешно'
PATH=$saved_path
local env_path
env_path=$(command -v env)
for interrupt_signal in INT TERM; do
begin_test "${interrupt_signal} interruption cleans transaction and releases lock"
local interrupt_script="${TEST_ROOT}/interrupt-${interrupt_signal}.sh" interrupt_pid interrupt_status interrupt_before interrupt_attrs
mkfifo "${TEST_ROOT}/interrupt-${interrupt_signal}-ready" "${TEST_ROOT}/interrupt-${interrupt_signal}-hold"
interrupt_before=$(sha256sum "$fixture"); interrupt_attrs=$(stat -c '%a:%u:%g' "$fixture")
printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'trap - INT' 'source "$1"' 'hold_builder() { printf "%s\\n" "$BASHPID" >"$INTERRUPT_READY"; cat "$INTERRUPT_HOLD" >/dev/null; cp -- "$WG_CONF" "$1"; }' 'set +e; run_transaction update interrupt hold_builder; status=$?; exit "$status"' >"$interrupt_script"
chmod 700 "$interrupt_script"
"$env_path" --default-signal=INT --default-signal=TERM WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" INTERRUPT_READY="${TEST_ROOT}/interrupt-${interrupt_signal}-ready" INTERRUPT_HOLD="${TEST_ROOT}/interrupt-${interrupt_signal}-hold" /bin/bash "$interrupt_script" "$PROJECT_DIR/wg-manager.sh" >"${TEST_ROOT}/interrupt-${interrupt_signal}.log" 2>&1 & interrupt_pid=$!
if ! IFS= read -r -t 5 ready <"${TEST_ROOT}/interrupt-${interrupt_signal}-ready"; then
kill -TERM "$interrupt_pid" 2>/dev/null || :
set +e; wait "$interrupt_pid"; interrupt_status=$?; set -e
record_failure "${interrupt_signal} builder did not reach synchronization point: $(<"${TEST_ROOT}/interrupt-${interrupt_signal}.log")"
continue
fi
kill -"$interrupt_signal" "$ready"
printf 'release\n' >"${TEST_ROOT}/interrupt-${interrupt_signal}-hold"
kill -"$interrupt_signal" "$ready" 2>/dev/null || :
set +e; wait "$interrupt_pid"; interrupt_status=$?; set -e
assert_equal "$EXIT_OPERATION" "$interrupt_status" "${interrupt_signal} status"
assert_contains "сигналом ${interrupt_signal}" "$(<"${TEST_ROOT}/interrupt-${interrupt_signal}.log")" "${interrupt_signal} diagnostic"
assert_equal "$interrupt_before" "$(sha256sum "$fixture")" "source after ${interrupt_signal}"
assert_equal "$interrupt_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "attributes after ${interrupt_signal}"
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" "temps after ${interrupt_signal}"
run_transaction update interrupt transaction_builder_copy >/dev/null
done
begin_test 'resource change after lock is rejected before builder'
transaction_builder_marker() { : >"${TEST_ROOT}/resource-builder-entered"; return 0; }
local changed_resource_config="${TEST_ROOT}/changed-resource.conf"
cp -- "$config_file" "$changed_resource_config"
replace_config_line "$changed_resource_config" 'WG_CONF=' 'WG_CONF="/tmp/unsafe-resource.conf"'
rm -f -- "${TEST_ROOT}/resource-builder-entered"
export MOCK_FLOCK_CHANGE_TO="$changed_resource_config"
set +e; run_transaction update resource transaction_builder_marker >/dev/null 2>&1; status=$?; set -e
unset MOCK_FLOCK_CHANGE_TO
assert_equal "$EXIT_VALIDATION" "$status" 'resource change after lock rejected'
assert_file_absent "${TEST_ROOT}/resource-builder-entered" 'builder not entered after resource change'
return
;;
backups)
begin_test 'backup retention and unrelated-file safety'
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
mkdir -p "${TEST_ROOT}/backups"
printf 'unrelated\n' >"${TEST_ROOT}/backups/unrelated.txt"
local collision_index
for collision_index in {1..12}; do run_transaction update client transaction_builder_copy >/dev/null; done
backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" | wc -l)
assert_equal 1 "$backup_count" 'ограничение резервных копий'
newest_backup=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" -printf '%f\n' | LC_ALL=C sort | tail -n 1)
assert_equal 012 "${newest_backup##*-}" 'счетчик backup имеет стабильный порядок'
assert_equal "INTERFACE=wg0" "$(sed -n '1p' "$BACKUP_DIR/$newest_backup/manifest")" 'manifest interface exact'
assert_equal 600 "$(stat -c '%a' "$BACKUP_DIR/$newest_backup/manifest")" 'manifest защищен'
mkdir -p "$BACKUP_DIR/20240101T000000Z-wg0-999"
printf 'invalid\n' >"$BACKUP_DIR/20240101T000000Z-wg0-999/manifest"
assert_file_exists "$BACKUP_DIR/20240101T000000Z-wg0-999/manifest" 'некорректный backup сохранен'
assert_file_exists "${TEST_ROOT}/backups/unrelated.txt" 'чужой файл сохранён'
begin_test 'backup manifest decoys remain untouched during pruning'
local valid_backup decoy_base decoy_path
valid_backup="$BACKUP_DIR/$newest_backup"
make_decoy() { decoy_path="$BACKUP_DIR/$1"; cp -a -- "$valid_backup" "$decoy_path"; }
make_decoy 20240101T000000Z-wg9-001; local wrong_interface=$decoy_path
make_decoy 20240101T000000Z-wg0-002; local timestamp_mismatch=$decoy_path
make_decoy 20231114T221500Z-wg0-003; local unsafe_dir=$decoy_path; chmod 755 "$unsafe_dir"
make_decoy 20231114T221500Z-wg0-004; local unsafe_manifest=$decoy_path; chmod 644 "$unsafe_manifest/manifest"
make_decoy 20231114T221500Z-wg0-005; local unsafe_config=$decoy_path; chmod 644 "$unsafe_config/wg.conf"
ln -s -- "$valid_backup" "$BACKUP_DIR/20231114T221500Z-wg0-006"; local symlink_dir="$BACKUP_DIR/20231114T221500Z-wg0-006"
make_decoy 20231114T221500Z-wg0-007; local symlink_manifest=$decoy_path; rm -- "$symlink_manifest/manifest"; ln -s -- "$valid_backup/manifest" "$symlink_manifest/manifest"
make_decoy 20231114T221500Z-wg0-008; local symlink_config=$decoy_path; rm -- "$symlink_config/wg.conf"; ln -s -- "$valid_backup/wg.conf" "$symlink_config/wg.conf"
make_decoy 20231114T221500Z-wg0-009; local malformed=$decoy_path; printf 'BROKEN\n' >>"$malformed/manifest"
make_decoy 20231114T221500Z-wg0-010; local duplicate_field=$decoy_path; printf 'INTERFACE=wg0\n' >>"$duplicate_field/manifest"
make_decoy 20231114T221500Z-wg0-011; local unknown_field=$decoy_path; printf 'UNKNOWN=x\n' >>"$unknown_field/manifest"
make_decoy 20231114T221500Z-wg0-016; local missing_field=$decoy_path; sed -i '/^INTERFACE=/d' "$missing_field/manifest"
make_decoy 20231114T221500Z-wg0-017; local missing_metadata=$decoy_path; printf 'METADATA=missing.meta\n' >>"$missing_metadata/manifest"
make_decoy 20231114T221500Z-wg0-018; local bad_metadata=$decoy_path; printf 'metadata\n' >"$bad_metadata/alice.meta"; chmod 644 "$bad_metadata/alice.meta"
make_decoy 20231114T221500Z-wg0-019; local undeclared_payload=$decoy_path; printf 'payload\n' >"$undeclared_payload/extra"
mkdir -p "$BACKUP_DIR/unrelated-dir"; printf 'unrelated\n' >"$BACKUP_DIR/unrelated-file"
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
run_transaction update client transaction_builder_copy >/dev/null
for decoy_path in "$wrong_interface" "$timestamp_mismatch" "$unsafe_dir" "$unsafe_manifest" "$unsafe_config" "$symlink_dir" "$symlink_manifest" "$symlink_config" "$malformed" "$duplicate_field" "$unknown_field" "$missing_field" "$missing_metadata" "$bad_metadata" "$undeclared_payload" "$BACKUP_DIR/unrelated-dir" "$BACKUP_DIR/unrelated-file"; do
[[ -e "$decoy_path" || -L "$decoy_path" ]] && assert_equal 1 1 "decoy preserved ${decoy_path##*/}" || assert_equal 1 0 "decoy preserved ${decoy_path##*/}"
done
begin_test 'failed mutation does not prune known-good backups'
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="10"'
run_transaction update client transaction_builder_copy >/dev/null
local known_good_one=$TRANSACTION_BACKUP_PATH
run_transaction update client transaction_builder_copy >/dev/null
local known_good_two=$TRANSACTION_BACKUP_PATH
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/failed-no-prune"
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'failed mutation status'
assert_file_exists "$known_good_one/wg.conf" 'old known-good backup one preserved'
assert_file_exists "$known_good_two/wg.conf" 'old known-good backup two preserved'
begin_test 'retention zero preserves sole valid backup'
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="0"'
run_transaction update client transaction_builder_copy >/dev/null
local valid_count=0 candidate_backup
for candidate_backup in "$BACKUP_DIR"/*; do
[[ -d "$candidate_backup" ]] || continue
if validate_backup_manifest "$candidate_backup"; then ((valid_count += 1)); fi
done
assert_equal 1 "$valid_count" 'retention zero keeps one valid backup'
ln -s -- "$BACKUP_DIR" "$TEST_ROOT/backup-symlink"
assert_file_exists "$TEST_ROOT/backup-symlink" 'symlink candidate preserved'
begin_test 'metadata backup sources are constrained and required'
local metadata_before metadata_status
metadata_before=$(sha256sum "$fixture")
printf 'outside\n' >"${TEST_ROOT}/outside.meta"
set +e; run_transaction update client transaction_builder_copy "${TEST_ROOT}/outside.meta" >/dev/null 2>&1; metadata_status=$?; set -e
assert_equal "$EXIT_OPERATION" "$metadata_status" 'внешние метаданные отклонены'
assert_equal "$metadata_before" "$(sha256sum "$fixture")" 'внешняя metadata не меняет source'
ln -s -- "${METADATA_DIR}/alice.meta" "${METADATA_DIR}/linked.meta"
set +e; run_transaction update client transaction_builder_copy "${METADATA_DIR}/linked.meta" >/dev/null 2>&1; metadata_status=$?; set -e
assert_equal "$EXIT_OPERATION" "$metadata_status" 'symlink metadata отклонена'
rm -- "${METADATA_DIR}/linked.meta"
begin_test 'backup counter exhaustion fails without overwriting 999'
local exhaustion_index exhaustion_dir exhaustion_status exhaustion_before
for exhaustion_index in $(seq -w 0 999); do
exhaustion_dir="$BACKUP_DIR/20231114T221500Z-wg0-${exhaustion_index}"
if [[ ! -e "$exhaustion_dir" && ! -L "$exhaustion_dir" ]]; then mkdir -p "$exhaustion_dir"; fi
done
printf 'sentinel\n' >"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel"
exhaustion_before=$(<"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel")
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; exhaustion_status=$?; set -e
assert_equal "$EXIT_OPERATION" "$exhaustion_status" 'counter exhaustion status'
assert_equal "$exhaustion_before" "$(<"$BACKUP_DIR/20231114T221500Z-wg0-999/sentinel")" 'counter 999 sentinel preserved'
return
;;
mutation-preserves-source)
begin_test 'pre-replacement validation preserves source bytes'
before=$(sha256sum "$fixture")
export WG_QUICK_STRIP_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset WG_QUICK_STRIP_FAIL
after=$(sha256sum "$fixture")
assert_equal "$EXIT_VALIDATION" "$status" 'ошибка staged strip'
assert_equal "$before" "$after" 'байты исходника сохранены'
begin_test 'old live strip failure preserves source'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
export WG_QUICK_STRIP_FAIL=live
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset WG_QUICK_STRIP_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка live strip'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после live strip'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после live strip'
begin_test 'metadata copy failure preserves source'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
export TRANSACTION_METADATA_COPY_FAIL=1
set +e; run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_METADATA_COPY_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка копирования metadata'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после metadata failure'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после metadata failure'
begin_test 'backup creation failure preserves source'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
export TRANSACTION_BACKUP_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_BACKUP_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка создания backup'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после backup failure'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после backup failure'
begin_test 'backup directory protection failure cleans incomplete backup'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)
export TRANSACTION_BACKUP_DIR_CHMOD_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_BACKUP_DIR_CHMOD_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка chmod каталога backup'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после chmod каталога backup'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после chmod каталога backup'
assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после chmod'
begin_test 'backup manifest write failure cleans incomplete backup'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)
export TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_BACKUP_MANIFEST_WRITE_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка записи manifest'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после записи manifest'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после записи manifest'
assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после manifest'
begin_test 'backup manifest mode failure cleans incomplete backup'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
backup_count_before=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)
export TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_BACKUP_MANIFEST_CHMOD_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка chmod manifest'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после chmod manifest'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после chmod manifest'
assert_equal "$backup_count_before" "$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name '*-wg0-*' | wc -l)" 'нет incomplete backup после chmod manifest'
begin_test 'builder failure preserves source attributes'
before=$(sha256sum "$fixture"); source_attrs=$(stat -c '%a:%u:%g' "$fixture")
set +e; run_transaction update client transaction_builder_fail >/dev/null 2>&1; status=$?; set -e
assert_equal 77 "$status" 'builder failure status'
assert_equal "$before" "$(sha256sum "$fixture")" 'байты после builder failure'
assert_equal "$source_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'атрибуты после builder failure'
chmod 644 "$fixture"
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
chmod 600 "$fixture"
assert_equal "$EXIT_VALIDATION" "$status" 'небезопасный режим source отклонён'
set +e; run_transaction $'bad\noperation' client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_VALIDATION" "$status" 'unsafe operation отклонена до mutation'
begin_test 'each transaction temp allocation fails closed'
local mktemp_bin="${TEST_ROOT}/mktemp-bin" saved_path fail_kind backup_count_before
mkdir -p "$mktemp_bin"
printf '%s\n' '#!/usr/bin/env bash' 'case ${1##*/} in' ' *.stage.XXXXXX) kind=stage ;;' ' *.strip.new.XXXXXX) kind=strip.new ;;' ' *.strip.old.XXXXXX) kind=strip.old ;;' ' *.original.XXXXXX) kind=original ;;' ' *.result.XXXXXX) kind=result ;;' ' *) kind=other ;;' 'esac' '[[ ${MOCK_MKTEMP_FAIL_KIND:-} == "$kind" ]] && exit 97' 'exec /usr/bin/mktemp "$@"' >"${mktemp_bin}/mktemp"
chmod 700 "${mktemp_bin}/mktemp"
saved_path=$PATH
PATH="${mktemp_bin}:$PATH"
for fail_kind in stage strip.new strip.old original; do
before=$(sha256sum "$fixture")
export MOCK_MKTEMP_FAIL_KIND=$fail_kind
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset MOCK_MKTEMP_FAIL_KIND
assert_failure "$status" "mktemp ${fail_kind} отклонён"
assert_equal "$before" "$(sha256sum "$fixture")" "source сохранён при mktemp ${fail_kind}"
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' -type f -o -type l | wc -l)" "нет temp после mktemp ${fail_kind}"
done
PATH=$saved_path
begin_test 'each required transaction dependency fails before mutation'
local dependency_path missing_dependency dependency_status
dependency_path="${TEST_ROOT}/dependency-path"
mkdir -p "$dependency_path"
for dependency_command in mktemp stat mkdir chmod rm cp mv date; do ln -s -- "$(PATH=/usr/bin:/bin type -P "$dependency_command")" "$dependency_path/$dependency_command"; done
for missing_dependency in flock wg wg-quick; do
rm -f -- "$dependency_path/flock" "$dependency_path/wg" "$dependency_path/wg-quick"
for dependency_command in flock wg wg-quick; do
[[ $dependency_command == "$missing_dependency" ]] && continue
ln -sf -- "${PROJECT_DIR}/tests/mocks/bin/${dependency_command}" "$dependency_path/$dependency_command"
done
before=$(sha256sum "$fixture")
PATH=$dependency_path
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; dependency_status=$?; set -e
PATH=$saved_path
assert_equal "$EXIT_DEPENDENCY" "$dependency_status" "отсутствует ${missing_dependency}"
assert_equal "$before" "$(sha256sum "$fixture")" "source не меняется без ${missing_dependency}"
done
return
;;
rollback)
begin_test 'apply failure restores disk and runtime'
before=$(sha256sum "$fixture")
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once"
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
after=$(sha256sum "$fixture")
assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply с успешным rollback'
assert_equal "$before" "$after" 'байты после rollback'
begin_test 'rollback verification failure is critical'
local rollback_sequence="${TEST_ROOT}/rollback-sequence" rollback_mismatch
mkdir -p "$rollback_sequence"
rollback_mismatch="${TEST_ROOT}/rollback-mismatch.dump"
sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$rollback_mismatch"
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
cp -- "$rollback_mismatch" "${rollback_sequence}/2"
: >"$TEST_MOCK_LOG"
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence"
unset WG_MOCK_DUMP_FILE
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="$runtime_file"
assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка проверки rollback'
assert_contains 'КРИТИЧНО' "$output" 'critical rollback verification'
assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" 'два dump при rollback'
assert_equal "$before" "$(sha256sum "$fixture")" 'диск восстановлен при ошибке проверки rollback'
begin_test 'forward atomic replace failure preserves disk and skips runtime'
before=$(sha256sum "$fixture")
export TRANSACTION_ATOMIC_REPLACE_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_ATOMIC_REPLACE_FAIL
assert_equal "$EXIT_OPERATION" "$status" 'ошибка atomic replace'
assert_equal "$before" "$(sha256sum "$fixture")" 'source после atomic replace'
begin_test 'syncconf failure restores disk and runtime'
before=$(sha256sum "$fixture")
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once-2"
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE
assert_equal "$EXIT_OPERATION" "$status" 'ошибка syncconf с rollback'
assert_equal "$before" "$(sha256sum "$fixture")" 'source после syncconf'
begin_test 'forward verification command failure rolls back'
before=$(sha256sum "$fixture")
export WG_MOCK_DUMP_FAIL_ON_CALL=1 WG_MOCK_DUMP_FAIL_COUNTER_FILE="${TEST_ROOT}/dump-fail-counter"
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset WG_MOCK_DUMP_FAIL_ON_CALL WG_MOCK_DUMP_FAIL_COUNTER_FILE
assert_equal "$EXIT_OPERATION" "$status" 'ошибка команды verification'
assert_equal "$before" "$(sha256sum "$fixture")" 'source после verification command'
begin_test 'disk rollback replacement failure is critical'
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-disk"
export TRANSACTION_ROLLBACK_REPLACE_FAIL=1
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
unset WG_SYNC_FAIL_ONCE_FILE TRANSACTION_ROLLBACK_REPLACE_FAIL
assert_equal "$EXIT_ROLLBACK" "$status" 'disk rollback failure status'
assert_contains 'КРИТИЧНО' "$output" 'disk rollback critical'
begin_test 'runtime rollback syncconf failure is critical'
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
cp -- "$runtime_file" "${rollback_sequence}/2"
rm -f -- "${rollback_sequence}/counter"
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence"
export WG_SYNC_FAIL_ON_CALL=2 WG_SYNC_FAIL_COUNTER_FILE="${TEST_ROOT}/sync-fail-counter"
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
unset WG_SYNC_FAIL_ON_CALL WG_SYNC_FAIL_COUNTER_FILE WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="$runtime_file"
assert_equal "$EXIT_ROLLBACK" "$status" 'runtime rollback failure status'
assert_contains 'КРИТИЧНО' "$output" 'runtime rollback critical'
begin_test 'rollback verification command failure is critical'
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
rm -f -- "${rollback_sequence}/2"
rm -f -- "${rollback_sequence}/counter"
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence"
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="$runtime_file"
assert_equal "$EXIT_ROLLBACK" "$status" 'rollback verification command status'
assert_contains 'КРИТИЧНО' "$output" 'rollback verification command critical'
begin_test 'forward runtime dump variants roll back safely'
local variant_name variant_file variant_status variant_before variant_attrs
declare -a variant_names=(malformed missing extra duplicate allowed-mismatch listen-mismatch)
for variant_name in "${variant_names[@]}"; do
variant_file="${rollback_sequence}/${variant_name}.dump"
case $variant_name in
malformed) printf 'malformed\n' >"$variant_file" ;;
missing) head -n 1 "$runtime_file" >"$variant_file" ;;
extra) { cat "$runtime_file"; printf '%s\t(none)\t(none)\t192.0.2.3/32\t0\t0\t0\t0\n' CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC=; } >"$variant_file" ;;
duplicate) { cat "$runtime_file"; tail -n 1 "$runtime_file"; } >"$variant_file" ;;
allowed-mismatch) sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$variant_file" ;;
listen-mismatch) sed '1s/51820/51821/' "$runtime_file" >"$variant_file" ;;
esac
rm -f -- "${rollback_sequence}/1" "${rollback_sequence}/2" "${rollback_sequence}/counter"
cp -- "$variant_file" "${rollback_sequence}/1"
cp -- "$runtime_file" "${rollback_sequence}/2"
: >"$TEST_MOCK_LOG"
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" WG_MOCK_DUMP_FILE="$runtime_file"
variant_before=$(sha256sum "$fixture"); variant_attrs=$(stat -c '%a:%u:%g' "$fixture")
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; variant_status=$?; set -e
assert_equal "$EXIT_OPERATION" "$variant_status" "variant ${variant_name} status"
assert_equal "$variant_before" "$(sha256sum "$fixture")" "variant ${variant_name} bytes"
assert_equal "$variant_attrs" "$(stat -c '%a:%u:%g' "$fixture")" "variant ${variant_name} attrs"
assert_equal 2 "$(grep -c '^wg syncconf wg0 ' "$TEST_MOCK_LOG" || true)" "variant ${variant_name} syncconf count"
assert_equal 2 "$(grep -c '^wg show wg0 dump$' "$TEST_MOCK_LOG" || true)" "variant ${variant_name} dump count"
assert_equal 2 "$(<"${rollback_sequence}/counter")" "variant ${variant_name} sequence count"
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" "variant ${variant_name} temp cleanup"
done
unset WG_MOCK_DUMP_SEQUENCE_DIR
begin_test 'malformed rollback verification is critical'
cp -- "$rollback_mismatch" "${rollback_sequence}/1"
printf 'malformed\n' >"${rollback_sequence}/2"
rm -f -- "${rollback_sequence}/counter"
export WG_MOCK_DUMP_SEQUENCE_DIR="$rollback_sequence" WG_MOCK_DUMP_FILE="$runtime_file"
variant_before=$(sha256sum "$fixture"); variant_attrs=$(stat -c '%a:%u:%g' "$fixture")
set +e; output=$(run_transaction update client transaction_builder_copy 2>&1); status=$?; set -e
unset WG_MOCK_DUMP_SEQUENCE_DIR
assert_equal "$EXIT_ROLLBACK" "$status" 'malformed rollback status'
assert_contains 'КРИТИЧНО' "$output" 'malformed rollback critical'
assert_equal "$variant_before" "$(sha256sum "$fixture")" 'malformed rollback bytes'
assert_equal "$variant_attrs" "$(stat -c '%a:%u:%g' "$fixture")" 'malformed rollback attrs'
return
;;
secret-modes)
begin_test 'backup modes and secret-safe mock log'
: >"$TEST_MOCK_LOG"
run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta" >/dev/null
assert_file_mode 700 "$BACKUP_DIR" 'режим backup каталога'
assert_file_mode 700 "$(dirname "$TRANSACTION_BACKUP_PATH")" 'режим backup root'
assert_file_mode 700 "$TRANSACTION_BACKUP_PATH" 'режим backup subdir'
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/wg.conf" 'режим backup config'
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/manifest" 'режим manifest'
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/alice.meta" 'режим metadata'
assert_file_mode 600 "$LOCK_FILE" 'режим lock'
assert_file_mode 600 "$fixture" 'режим live config'
assert_equal '' "$(grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'секреты не в mock log'
begin_test 'temporary transaction files are mode 0600 while active'
local mode_script="${TEST_ROOT}/active-mode.sh" mode_pid mode_status mode_ready fixture_dir mode_file mode_class
local -a mode_matches=()
mkfifo "${TEST_ROOT}/active-mode-ready" "${TEST_ROOT}/active-mode-release"
printf '%s\n' '#!/usr/bin/env bash' 'set -Eeuo pipefail' 'source "$1"' 'builder() { cp -- "$WG_CONF" "$1"; }' 'run_transaction update active builder' >"$mode_script"
chmod 700 "$mode_script"
WG_MANAGER_CONFIG="$config_file" WG_MANAGER_BACKUP_DIR="$BACKUP_DIR" WG_MANAGER_METADATA_DIR="$METADATA_DIR" WG_MANAGER_LOCK_FILE="$LOCK_FILE" WG_SYNC_READY_FIFO="${TEST_ROOT}/active-mode-ready" WG_SYNC_RELEASE_FIFO="${TEST_ROOT}/active-mode-release" /bin/bash "$mode_script" "$PROJECT_DIR/wg-manager.sh" >"${TEST_ROOT}/active-mode.out" 2>"${TEST_ROOT}/active-mode.err" & mode_pid=$!
IFS= read -r -t 5 mode_ready <"${TEST_ROOT}/active-mode-ready"
fixture_dir=${WG_CONF%/*}
shopt -s nullglob
for mode_class in result strip.new strip.old original; do
mode_matches=("$fixture_dir"/.wg-manager."$mode_class".*)
assert_equal 1 "${#mode_matches[@]}" "ровно один active ${mode_class} temp"
mode_file=${mode_matches[0]}
assert_equal 600 "$(stat -c '%a' "$mode_file")" "активный temp mode ${mode_file##*/}"
assert_equal 0 "$( [[ -f "$mode_file" && ! -L "$mode_file" && "$mode_file" == "$TEST_ROOT"/* ]] && printf 0 || printf 1 )" "активный temp confined ${mode_file##*/}"
done
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.stage.*' | wc -l)" 'stage отсутствует после rename'
assert_file_mode 600 "$fixture" 'live mode while syncconf blocked'
printf 'release\n' >"${TEST_ROOT}/active-mode-release"
set +e; wait "$mode_pid"; mode_status=$?; set -e
assert_equal 0 "$mode_status" 'active mode transaction succeeds'
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.*' | wc -l)" 'временные transaction files очищены'
assert_equal '' "$(grep -E 'PrivateKey|PresharedKey|\[Interface\]|\[Peer\]|AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=|BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=' "${TEST_ROOT}/active-mode.out" "${TEST_ROOT}/active-mode.err" "$TEST_MOCK_LOG" || true)" 'active output/logs secret-free'
return
;;
esac
fi
begin_test 'successful transaction uses lock, backup, strip, syncconf, and verification'
parent_exit=$(trap -p EXIT); parent_int=$(trap -p INT); parent_term=$(trap -p TERM)
before=$(sha256sum "$fixture")
: >"$TEST_MOCK_LOG"
set +e
run_transaction update client transaction_builder_copy "${METADATA_DIR}/alice.meta"
status=$?
set -e
after=$(sha256sum "$fixture")
assert_success "$status" 'успешная транзакция'
assert_equal 1 "$([[ "$before" != "$after" ]] && printf 1 || printf 0)" 'предложение действительно изменяет конфиг'
assert_file_exists "$TRANSACTION_BACKUP_PATH/wg.conf" 'резервная копия конфига'
assert_file_exists "$TRANSACTION_BACKUP_PATH/manifest" 'манифест резервной копии'
assert_file_exists "$TRANSACTION_BACKUP_PATH/alice.meta" 'копия метаданных'
assert_file_mode 700 "$(dirname "$TRANSACTION_BACKUP_PATH")" 'режим каталога резервных копий'
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/wg.conf" 'режим резервного конфига'
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/manifest" 'режим манифеста'
assert_file_mode 600 "$fixture" 'режим live конфига'
manifest_content=$(<"$TRANSACTION_BACKUP_PATH/manifest")
assert_contains $'INTERFACE=wg0\n' "$manifest_content" 'manifest interface'
assert_contains $'TIMESTAMP=' "$manifest_content" 'manifest timestamp'
assert_contains $'OPERATION=update\n' "$manifest_content" 'manifest operation'
assert_contains $'TARGET=client\n' "$manifest_content" 'manifest target'
assert_contains $'SOURCE_MODE=600\n' "$manifest_content" 'manifest source mode'
assert_contains $'CONFIG=wg.conf\nMETADATA=alice.meta' "$manifest_content" 'manifest config metadata'
assert_equal "$(<"${METADATA_DIR}/alice.meta")" "$(<"$TRANSACTION_BACKUP_PATH/alice.meta")" 'metadata backup bytes'
assert_file_mode 600 "$TRANSACTION_BACKUP_PATH/alice.meta" 'режим metadata backup'
local transaction_log
local -a transaction_lines=()
transaction_log=$(<"$TEST_MOCK_LOG")
assert_contains $'flock -n ' "$transaction_log" 'argv flock'
assert_contains $'wg-quick strip ' "$transaction_log" 'argv strip'
assert_contains $'wg syncconf wg0 ' "$transaction_log" 'argv syncconf'
assert_contains $'wg show wg0 dump' "$transaction_log" 'argv verification dump'
mapfile -t transaction_lines <"$TEST_MOCK_LOG"
assert_equal 5 "${#transaction_lines[@]}" 'точно пять внешних transaction вызовов'
assert_equal 1 "$([[ ${transaction_lines[0]} =~ ^flock\ -n\ [0-9]+$ ]] && printf 1 || printf 0)" 'первый вызов flock'
assert_equal 1 "$([[ ${transaction_lines[1]} == 'wg-quick strip '* && ${transaction_lines[2]} == 'wg-quick strip '* ]] && printf 1 || printf 0)" 'два strip подряд'
assert_equal 1 "$([[ ${transaction_lines[3]} == 'wg syncconf wg0 '* && ${transaction_lines[4]} == 'wg show wg0 dump' ]] && printf 1 || printf 0)" 'syncconf затем dump'
assert_equal 1 "$( [[ ${transaction_lines[1]} =~ ^wg-quick\ strip\ ${TEST_ROOT//./\.}/\.wg-manager\.stage\.[A-Za-z0-9]+$ && ${transaction_lines[2]} == "wg-quick strip ${fixture}" && ${transaction_lines[3]} =~ ^wg\ syncconf\ wg0\ ${TEST_ROOT//./\.}/\.wg-manager\.strip\.new\.[A-Za-z0-9]+$ ]] && printf 1 || printf 0 )" 'точные классы stage/live/syncconf'
assert_equal '' "$(grep -E 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' "$TEST_MOCK_LOG" || true)" 'mock log без ключей'
assert_equal "$parent_exit" "$(trap -p EXIT)" 'EXIT trap восстановлен'
assert_equal "$parent_int" "$(trap -p INT)" 'INT trap восстановлен'
assert_equal "$parent_term" "$(trap -p TERM)" 'TERM trap восстановлен'
begin_test 'run_transaction rejects fewer than three arguments'
set +e; run_transaction update client >/dev/null 2>&1; status=$?; set -e
assert_equal "$EXIT_USAGE" "$status" 'неполный публичный API'
begin_test 'legacy generic transaction hooks do not affect normal mode'
before=$(sha256sum "$fixture")
old_test_mode=${WG_MANAGER_TEST_MODE:-0}
export WG_MANAGER_TEST_MODE=0 TRANSACTION_METADATA_COPY_FAIL=1 TRANSACTION_BACKUP_FAIL=1 TRANSACTION_ATOMIC_REPLACE_FAIL=1 TRANSACTION_ROLLBACK_REPLACE_FAIL=1
set +e; run_transaction update client transaction_builder_copy >/dev/null 2>&1; status=$?; set -e
unset TRANSACTION_METADATA_COPY_FAIL TRANSACTION_BACKUP_FAIL TRANSACTION_ATOMIC_REPLACE_FAIL TRANSACTION_ROLLBACK_REPLACE_FAIL
export WG_MANAGER_TEST_MODE=$old_test_mode
assert_success "$status" 'старые generic hooks не меняют production mode'
assert_equal "$before" "$(sha256sum "$fixture")" 'обычная транзакция завершилась без подмены source'
begin_test 'staging validation failure preserves source'
before=$(sha256sum "$fixture")
export WG_QUICK_STRIP_FAIL=1
set +e
run_transaction update client transaction_builder_copy >/dev/null 2>&1
status=$?
set -e
unset WG_QUICK_STRIP_FAIL
after=$(sha256sum "$fixture")
assert_equal "$EXIT_VALIDATION" "$status" 'ошибка strip'
assert_equal "$before" "$after" 'источник после ошибки strip'
begin_test 'builder failure cleans staging and preserves source'
before=$(sha256sum "$fixture")
set +e
run_transaction update client transaction_builder_fail >/dev/null 2>&1
status=$?
set -e
after=$(sha256sum "$fixture")
assert_equal 77 "$status" 'статус ошибки builder'
assert_equal "$before" "$after" 'источник после ошибки builder'
assert_equal 0 "$(find "$(dirname "$fixture")" -maxdepth 1 -name '.wg-manager.stage.*' -type f | wc -l)" 'staging очищен'
begin_test 'builder symlink replacement is rejected'
before=$(sha256sum "$fixture")
set +e
run_transaction update client transaction_builder_symlink >/dev/null 2>&1
status=$?
set -e
after=$(sha256sum "$fixture")
assert_equal "$EXIT_VALIDATION" "$status" 'staged symlink отклонён'
assert_equal "$before" "$after" 'источник после staged symlink'
begin_test 'apply failure rolls back disk and runtime'
before=$(sha256sum "$fixture")
export WG_SYNC_FAIL_ONCE_FILE="${TEST_ROOT}/sync-failed-once"
set +e
run_transaction update client transaction_builder_copy >/dev/null 2>&1
status=$?
set -e
unset WG_SYNC_FAIL_ONCE_FILE
after=$(sha256sum "$fixture")
assert_equal "$EXIT_OPERATION" "$status" 'ошибка apply с успешным rollback'
assert_equal "$before" "$after" 'источник после rollback apply'
begin_test 'verification failure rolls back disk and runtime'
local sequence_dir="${TEST_ROOT}/dump-sequence" mismatch_dump
mkdir -p "$sequence_dir"
mismatch_dump="${TEST_ROOT}/mismatch.dump"
sed 's/192\.0\.2\.2\/32/192.0.2.99\/32/' "$runtime_file" >"$mismatch_dump"
cp -- "$mismatch_dump" "${sequence_dir}/1"
cp -- "$runtime_file" "${sequence_dir}/2"
before=$(sha256sum "$fixture")
export WG_MOCK_DUMP_SEQUENCE_DIR="$sequence_dir"
unset WG_MOCK_DUMP_FILE
set +e
run_transaction update client transaction_builder_copy >/dev/null 2>&1
status=$?
set -e
unset WG_MOCK_DUMP_SEQUENCE_DIR
export WG_MOCK_DUMP_FILE="$runtime_file"
after=$(sha256sum "$fixture")
assert_equal "$EXIT_OPERATION" "$status" 'ошибка verification с успешным rollback'
assert_equal "$before" "$after" 'источник после rollback verification'
begin_test 'lock contention does not enter builder'
: >"${TEST_ROOT}/builder-marker"
transaction_builder_marker() { : >"${TEST_ROOT}/builder-marker.entered"; return 0; }
export MOCK_FLOCK_BUSY=1
set +e
run_transaction test lock transaction_builder_marker >/dev/null 2>&1
status=$?
set -e
unset MOCK_FLOCK_BUSY
assert_equal "$EXIT_OPERATION" "$status" 'lock contention status'
assert_file_absent "${TEST_ROOT}/builder-marker.entered" 'builder не вошёл при contention'
begin_test 'retention keeps newest valid backup and unrelated files'
replace_config_line "$config_file" 'BACKUP_RETENTION=' 'BACKUP_RETENTION="1"'
printf 'unrelated\n' >"${TEST_ROOT}/backups/unrelated.txt"
for collision_index in {1..12}; do run_transaction update client transaction_builder_copy >/dev/null; done
backup_count=$(find "$BACKUP_DIR" -mindepth 1 -maxdepth 1 -type d -name "*-${WG_INTERFACE}-*" | wc -l)
assert_equal 1 "$backup_count" 'ограничение резервных копий'
assert_file_exists "${TEST_ROOT}/backups/unrelated.txt" 'чужой файл сохранён'
begin_test 'rollback failure is distinct and critical'
export WG_SYNC_FAIL=1
set +e
output=$(run_transaction update client transaction_builder_copy 2>&1)
status=$?
set -e
unset WG_SYNC_FAIL
assert_equal "$EXIT_ROLLBACK" "$status" 'ошибка rollback'
assert_contains 'КРИТИЧНО' "$output" 'critical rollback diagnostic'
}
run_m2_focused() {
local group=$1 config_file="${TEST_ROOT}/focused-m2.conf" fixture="${TEST_ROOT}/wg0.conf" runtime_file="${TEST_ROOT}/runtime.dump" output reversed_output status
cp -- "${PROJECT_DIR}/tests/fixtures/m2/managed.conf" "$fixture"
@@ -978,12 +1607,15 @@ if [[ $test_group == config ]]; then
run_config_tests
elif [[ $test_group == parser || $test_group == status || $test_group == list || $test_group == list-redirected || $test_group == list-deterministic ]]; then
run_m2_focused "$test_group"
elif [[ $test_group == transaction || $test_group == rollback || $test_group == locking || $test_group == backups || $test_group == mutation-preserves-source || $test_group == secret-modes ]]; then
run_m3_tests
else
run_config_tests
run_gum_tests
run_dependency_tests
run_debug_tests
run_m2_tests
run_m3_tests
fi
finish_tests