Implement iptables forward manager core

This commit is contained in:
2026-04-17 09:36:40 +08:00
parent d1a5392476
commit 28960eee03
20 changed files with 2047 additions and 0 deletions

54
tests/lib/assert.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
printf 'ASSERT FAIL: %s\n' "$*" >&2
exit 1
}
pass() {
printf 'ASSERT PASS: %s\n' "$*"
}
assert_eq() {
local expected=$1
local actual=$2
local message=${3:-values differ}
[[ ${expected} == "${actual}" ]] || fail "${message} (expected='${expected}' actual='${actual}')"
}
assert_ne() {
local left=$1
local right=$2
local message=${3:-values should differ}
[[ ${left} != "${right}" ]] || fail "${message} (value='${left}')"
}
assert_contains() {
local haystack=$1
local needle=$2
local message=${3:-substring not found}
[[ ${haystack} == *"${needle}"* ]] || fail "${message} (needle='${needle}')"
}
assert_not_contains() {
local haystack=$1
local needle=$2
local message=${3:-unexpected substring found}
[[ ${haystack} != *"${needle}"* ]] || fail "${message} (needle='${needle}')"
}
assert_file_contains() {
local file=$1
local needle=$2
local message=${3:-file does not contain substring}
[[ -f ${file} ]] || fail "file not found: ${file}"
grep -Fq -- "${needle}" "${file}" || fail "${message} (file='${file}' needle='${needle}')"
}
assert_status() {
local expected=$1
local actual=$2
local message=${3:-unexpected exit status}
[[ ${expected} == "${actual}" ]] || fail "${message} (expected=${expected} actual=${actual})"
}

1
tests/mocks/ip6tables Symbolic link
View File

@@ -0,0 +1 @@
iptables-mock.sh

1
tests/mocks/ip6tables-save Symbolic link
View File

@@ -0,0 +1 @@
iptables-mock.sh

1
tests/mocks/iptables Symbolic link
View File

@@ -0,0 +1 @@
iptables-mock.sh

View File

@@ -0,0 +1,109 @@
#!/usr/bin/env bash
set -euo pipefail
: "${IPTABLES_MOCK_DIR:=/tmp/iptables-mock}"
: "${IPTABLES_MOCK_LOG:=${IPTABLES_MOCK_DIR}/calls.log}"
mkdir -p "${IPTABLES_MOCK_DIR}"
touch "${IPTABLES_MOCK_LOG}"
base=$(basename -- "$0")
case ${base} in
iptables|iptables-save) family=4 ;;
ip6tables|ip6tables-save) family=6 ;;
*) family=${IPTABLES_MOCK_FAMILY:-4} ;;
esac
state_file="${IPTABLES_MOCK_DIR}/state.v${family}"
counter_file="${IPTABLES_MOCK_DIR}/add-counter"
touch "${state_file}" "${counter_file}"
serialize_args() {
local out=''
local arg
for arg in "$@"; do
out+="${arg}"$'\t'
done
printf '%s' "${out}"
}
line_key() {
local table=$1
local chain=$2
shift 2
printf '%s|%s|%s' "${table}" "${chain}" "$(serialize_args "$@")"
}
log_call() {
printf '%s %s\n' "${base}" "$*" >>"${IPTABLES_MOCK_LOG}"
}
load_rule_exists() {
local key=$1
grep -Fqx -- "${key}" "${state_file}"
}
save_emit() {
local table chain serialized
while IFS='|' read -r table chain serialized || [[ -n ${table:-} ]]; do
[[ -n ${table:-} ]] || continue
IFS=$'\t' read -r -a args <<<"${serialized}"
printf -- '-A %s' "${chain}"
local arg
for arg in "${args[@]}"; do
[[ -n ${arg} ]] || continue
printf ' %s' "${arg}"
done
printf '\n'
done <"${state_file}"
}
increment_add_counter() {
local count=0
count=$(cat "${counter_file}")
count=$((count + 1))
printf '%s\n' "${count}" >"${counter_file}"
printf '%s\n' "${count}"
}
if [[ ${base} == *save ]]; then
save_emit
exit 0
fi
log_call "$@"
table='filter'
if (($# >= 2)) && [[ $1 == -t ]]; then
table=$2
shift 2
fi
operation=${1-}
chain=${2-}
shift 2 || true
key=$(line_key "${table}" "${chain}" "$@")
case ${operation} in
-A)
current=$(increment_add_counter)
if [[ -n ${IPTABLES_MOCK_FAIL_ON_N:-} && ${current} == "${IPTABLES_MOCK_FAIL_ON_N}" ]]; then
exit 1
fi
printf '%s\n' "${key}" >>"${state_file}"
;;
-D)
if ! load_rule_exists "${key}"; then
exit 1
fi
grep -Fvx -- "${key}" "${state_file}" >"${state_file}.tmp" || true
mv "${state_file}.tmp" "${state_file}"
;;
-C)
load_rule_exists "${key}"
;;
*)
printf 'unsupported operation: %s\n' "${operation}" >&2
exit 2
;;
esac

1
tests/mocks/iptables-save Symbolic link
View File

@@ -0,0 +1 @@
iptables-mock.sh

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PERSIST_MOCK_LOG:=/tmp/persist-mock.log}"
: "${PERSIST_MOCK_FAIL:=0}"
printf '%s %s\n' "$(basename -- "$0")" "$*" >>"${PERSIST_MOCK_LOG}"
if [[ ${PERSIST_MOCK_FAIL} == 1 ]]; then
exit 1
fi

34
tests/run_all.sh Normal file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
SKIP_INTEGRATION=0
for arg in "$@"; do
case ${arg} in
--skip-integration)
SKIP_INTEGRATION=1
;;
*)
printf '未知参数: %s\n' "${arg}" >&2
exit 1
;;
esac
done
run_test() {
local file=$1
printf '\n==> 运行 %s\n' "$(basename -- "${file}")"
bash "${file}"
}
run_test "${ROOT_DIR}/tests/test_common.sh"
run_test "${ROOT_DIR}/tests/test_storage.sh"
run_test "${ROOT_DIR}/tests/test_env_check.sh"
run_test "${ROOT_DIR}/tests/test_rules_unit.sh"
if [[ ${SKIP_INTEGRATION} == 0 ]]; then
run_test "${ROOT_DIR}/tests/test_integration.sh"
fi
printf '\n全部测试完成。\n'

50
tests/test_common.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=tests/lib/assert.sh
source "${ROOT_DIR}/tests/lib/assert.sh"
# shellcheck source=lib/common.sh
source "${ROOT_DIR}/lib/common.sh"
status_of() {
set +e
"$@" >/dev/null 2>&1
local rc=$?
set -e
printf '%s\n' "${rc}"
}
assert_status 0 "$(status_of validate_ipv4 '0.0.0.0')" 'validate_ipv4 should accept 0.0.0.0'
assert_status 0 "$(status_of validate_ipv4 '192.168.1.1')" 'validate_ipv4 should accept private address'
assert_status 0 "$(status_of validate_ipv4 '255.255.255.255')" 'validate_ipv4 should accept broadcast'
assert_status 1 "$(status_of validate_ipv4 '256.1.1.1')" 'validate_ipv4 should reject overflow octet'
assert_status 1 "$(status_of validate_ipv4 '1.2.3')" 'validate_ipv4 should reject short address'
assert_status 1 "$(status_of validate_ipv4 'abc')" 'validate_ipv4 should reject text'
assert_status 1 "$(status_of validate_ipv4 '')" 'validate_ipv4 should reject empty string'
assert_status 0 "$(status_of validate_ipv6 '::1')" 'validate_ipv6 should accept loopback'
assert_status 0 "$(status_of validate_ipv6 '2001:db8::1')" 'validate_ipv6 should accept compressed address'
assert_status 0 "$(status_of validate_ipv6 'fe80::1%eth0')" 'validate_ipv6 should accept scoped address'
assert_status 1 "$(status_of validate_ipv6 ':::1')" 'validate_ipv6 should reject malformed address'
assert_status 1 "$(status_of validate_ipv6 '1.2.3.4')" 'validate_ipv6 should reject ipv4 literal'
assert_status 1 "$(status_of validate_ipv6 '')" 'validate_ipv6 should reject empty string'
assert_status 0 "$(status_of validate_port '1')" 'validate_port should accept lower bound'
assert_status 0 "$(status_of validate_port '65535')" 'validate_port should accept upper bound'
assert_status 1 "$(status_of validate_port '0')" 'validate_port should reject zero'
assert_status 1 "$(status_of validate_port '65536')" 'validate_port should reject overflow'
assert_status 1 "$(status_of validate_port '-1')" 'validate_port should reject negative'
assert_status 1 "$(status_of validate_port 'abc')" 'validate_port should reject text'
assert_status 0 "$(status_of validate_proto 'tcp')" 'validate_proto should accept tcp'
assert_status 0 "$(status_of validate_proto 'udp')" 'validate_proto should accept udp'
assert_status 0 "$(status_of validate_proto 'both')" 'validate_proto should accept both'
assert_status 1 "$(status_of validate_proto 'icmp')" 'validate_proto should reject unsupported protocol'
assert_status 0 "$(status_of validate_ipver '4')" 'validate_ipver should accept 4'
assert_status 0 "$(status_of validate_ipver '6')" 'validate_ipver should accept 6'
assert_status 0 "$(status_of validate_ipver 'both')" 'validate_ipver should accept both'
assert_status 1 "$(status_of validate_ipver '5')" 'validate_ipver should reject unsupported family'
pass 'test_common.sh'

129
tests/test_env_check.sh Normal file
View File

@@ -0,0 +1,129 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=tests/lib/assert.sh
source "${ROOT_DIR}/tests/lib/assert.sh"
status_of() {
set +e
"$@" >/dev/null 2>&1
local rc=$?
set -e
printf '%s\n' "${rc}"
}
TMP_DIR=$(mktemp -d)
trap 'rm -rf "${TMP_DIR}"' EXIT
BIN_DIR="${TMP_DIR}/bin"
mkdir -p "${BIN_DIR}"
IPTABLES_PATH="${BIN_DIR}/iptables"
IP6TABLES_PATH="${BIN_DIR}/ip6tables"
PERSIST_PATH="${BIN_DIR}/netfilter-persistent"
DPKG_PATH="${BIN_DIR}/dpkg"
SYSCTL_PATH="${BIN_DIR}/sysctl"
SYSTEMCTL_PATH="${BIN_DIR}/systemctl"
DEBCONF_PATH="${BIN_DIR}/debconf-set-selections"
APT_PATH="${BIN_DIR}/apt-get"
cat >"${DPKG_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
exit 1
MOCK
cat >"${SYSCTL_PATH}" <<MOCK
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "\$*" >>"${TMP_DIR}/sysctl.log"
printf '1\n' >"${TMP_DIR}/ipv4_forward"
printf '1\n' >"${TMP_DIR}/ipv6_forward"
MOCK
cat >"${SYSTEMCTL_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
exit 1
MOCK
cat >"${DEBCONF_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
cat >/dev/null
MOCK
cat >"${APT_PATH}" <<MOCK
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "\$*" >>"${TMP_DIR}/apt.log"
MOCK
chmod +x "${DPKG_PATH}" "${SYSCTL_PATH}" "${SYSTEMCTL_PATH}" "${DEBCONF_PATH}" "${APT_PATH}"
export IPF_STORAGE_DIR="${TMP_DIR}/storage"
export IPF_SYSCTL_FILE="${TMP_DIR}/99-iptables-forward.conf"
export IPF_IPV4_FORWARD_FILE="${TMP_DIR}/ipv4_forward"
export IPF_IPV6_FORWARD_FILE="${TMP_DIR}/ipv6_forward"
export IPF_CHECK_IPTABLES_CMD="${BIN_DIR}/missing-iptables"
export IPF_CHECK_IP6TABLES_CMD="${BIN_DIR}/missing-ip6tables"
export IPF_CHECK_PERSIST_CMD="${BIN_DIR}/missing-persist"
export DPKG_BIN="${DPKG_PATH}"
export SYSCTL_BIN="${SYSCTL_PATH}"
export SYSTEMCTL_BIN="${SYSTEMCTL_PATH}"
export DEBCONF_SET_SELECTIONS_BIN="${DEBCONF_PATH}"
export APT_GET_BIN="${APT_PATH}"
echo 0 >"${IPF_IPV4_FORWARD_FILE}"
echo 0 >"${IPF_IPV6_FORWARD_FILE}"
# shellcheck source=lib/common.sh
source "${ROOT_DIR}/lib/common.sh"
# shellcheck source=lib/env_check.sh
source "${ROOT_DIR}/lib/env_check.sh"
env_check_collect_issues
assert_eq '6' "${#ENV_CHECK_ISSUES[@]}" 'env_check_collect_issues should capture missing binaries, persistence, forwarding and storage'
cat >"${IPTABLES_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
exit 0
MOCK
cat >"${IP6TABLES_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
exit 0
MOCK
cat >"${PERSIST_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
exit 0
MOCK
cat >"${DPKG_PATH}" <<'MOCK'
#!/usr/bin/env bash
set -euo pipefail
exit 0
MOCK
chmod +x "${IPTABLES_PATH}" "${IP6TABLES_PATH}" "${PERSIST_PATH}" "${DPKG_PATH}"
mkdir -p "${IPF_STORAGE_DIR}"
export IPF_CHECK_IPTABLES_CMD="${IPTABLES_PATH}"
export IPF_CHECK_IP6TABLES_CMD="${IP6TABLES_PATH}"
export IPF_CHECK_PERSIST_CMD="${PERSIST_PATH}"
export IPF_ASSUME_YES=1
env_check_all
assert_file_contains "${IPF_SYSCTL_FILE}" 'net.ipv4.ip_forward=1' 'env_check_all should write IPv4 forwarding setting'
assert_file_contains "${IPF_SYSCTL_FILE}" 'net.ipv6.conf.all.forwarding=1' 'env_check_all should write IPv6 forwarding setting'
assert_file_contains "${TMP_DIR}/sysctl.log" '--system' 'env_check_all should apply sysctl settings'
export IPF_CHECK_IPTABLES_CMD="${BIN_DIR}/missing-iptables-again"
export IPF_CHECK_IP6TABLES_CMD="${BIN_DIR}/missing-ip6tables-again"
export IPF_CHECK_PERSIST_CMD="${BIN_DIR}/missing-persist-again"
export IPF_ASSUME_YES=0
export IPF_TEST_INPUTS=$'n\n'
assert_eq '3' "$(status_of env_check_all)" 'env_check_all should return 3 when user rejects autofix'
pass 'test_env_check.sh'

88
tests/test_integration.sh Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=tests/lib/assert.sh
source "${ROOT_DIR}/tests/lib/assert.sh"
status_of() {
set +e
"$@" >/dev/null 2>&1
local rc=$?
set -e
printf '%s\n' "${rc}"
}
maybe_enter_namespace() {
if (( EUID == 0 )); then
return 0
fi
if [[ ${IPF_IN_NAMESPACE:-0} == 1 ]]; then
return 0
fi
if command -v unshare >/dev/null 2>&1 && unshare -Urn true >/dev/null 2>&1; then
exec unshare -Urn env IPF_IN_NAMESPACE=1 bash "$0"
fi
printf 'SKIP: root 或 unshare 不可用,跳过集成测试。\n'
exit 0
}
maybe_enter_namespace
if command -v ip >/dev/null 2>&1; then
ip link set lo up >/dev/null 2>&1 || true
fi
if ! command -v iptables >/dev/null 2>&1; then
printf 'SKIP: iptables 不可用,跳过集成测试。\n'
exit 0
fi
TMP_DIR=$(mktemp -d)
BACKUP_V4="${TMP_DIR}/iptables.v4.bak"
BACKUP_V6="${TMP_DIR}/iptables.v6.bak"
cleanup() {
if [[ -f ${BACKUP_V4} ]]; then
iptables-restore <"${BACKUP_V4}" >/dev/null 2>&1 || true
fi
if [[ -f ${BACKUP_V6} ]]; then
ip6tables-restore <"${BACKUP_V6}" >/dev/null 2>&1 || true
fi
rm -rf "${TMP_DIR}"
}
trap cleanup EXIT
iptables-save >"${BACKUP_V4}"
if command -v ip6tables-save >/dev/null 2>&1; then
ip6tables-save >"${BACKUP_V6}"
fi
export IPF_STORAGE_DIR="${TMP_DIR}/storage"
export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db"
export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock"
export IPF_SKIP_ENV_CHECK=1
export IPF_SKIP_PERSIST=1
export IPF_FORCE_PLAIN_UI=1
uuid_v4=$("${ROOT_DIR}/iptables-forward.sh" --batch add tcp 65432 127.0.0.1 22 4 'integration-v4')
assert_contains "$(iptables-save)" "MGMT:${uuid_v4}" 'IPv4 rule should appear in iptables-save output'
assert_contains "$("${ROOT_DIR}/iptables-forward.sh" --batch list)" "uuid=${uuid_v4}" 'batch list should include managed rule'
iptables -A FORWARD -p tcp --dport 65000 -j ACCEPT
"${ROOT_DIR}/iptables-forward.sh" --batch delete "${uuid_v4}"
assert_status 1 "$(status_of grep -F "MGMT:${uuid_v4}" <(iptables-save))" 'deleted IPv4 rule should disappear from iptables-save'
assert_status 0 "$(status_of grep -F -- '--dport 65000 -j ACCEPT' <(iptables-save))" 'unmanaged rule should remain after deleting managed rule'
if command -v ip6tables >/dev/null 2>&1; then
uuid_v6=$("${ROOT_DIR}/iptables-forward.sh" --batch add tcp 65433 ::1 22 6 'integration-v6')
assert_contains "$(ip6tables-save)" "MGMT:${uuid_v6}" 'IPv6 rule should appear in ip6tables-save output'
"${ROOT_DIR}/iptables-forward.sh" --batch delete "${uuid_v6}"
assert_status 1 "$(status_of grep -F "MGMT:${uuid_v6}" <(ip6tables-save))" 'deleted IPv6 rule should disappear from ip6tables-save'
fi
pass 'test_integration.sh'

76
tests/test_rules_unit.sh Normal file
View File

@@ -0,0 +1,76 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=tests/lib/assert.sh
source "${ROOT_DIR}/tests/lib/assert.sh"
status_of() {
set +e
"$@" >/dev/null 2>&1
local rc=$?
set -e
printf '%s\n' "${rc}"
}
TMP_DIR=$(mktemp -d)
trap 'rm -rf "${TMP_DIR}"' EXIT
export IPF_STORAGE_DIR="${TMP_DIR}/storage"
export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db"
export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock"
export IPTABLES_MOCK_DIR="${TMP_DIR}/iptables-mock"
export IPTABLES_MOCK_LOG="${TMP_DIR}/iptables.log"
export IPTABLES_BIN="${ROOT_DIR}/tests/mocks/iptables"
export IP6TABLES_BIN="${ROOT_DIR}/tests/mocks/ip6tables"
export IPTABLES_SAVE_BIN="${ROOT_DIR}/tests/mocks/iptables-save"
export IP6TABLES_SAVE_BIN="${ROOT_DIR}/tests/mocks/ip6tables-save"
export NETFILTER_PERSISTENT_BIN="${ROOT_DIR}/tests/mocks/persist-mock.sh"
export PERSIST_MOCK_LOG="${TMP_DIR}/persist.log"
# shellcheck source=lib/common.sh
source "${ROOT_DIR}/lib/common.sh"
# shellcheck source=lib/storage.sh
source "${ROOT_DIR}/lib/storage.sh"
# shellcheck source=lib/persist.sh
source "${ROOT_DIR}/lib/persist.sh"
# shellcheck source=lib/iptables_ops.sh
source "${ROOT_DIR}/lib/iptables_ops.sh"
# shellcheck source=lib/rules_mgr.sh
source "${ROOT_DIR}/lib/rules_mgr.sh"
reset_mock_state() {
rm -rf "${IPTABLES_MOCK_DIR}" "${IPF_STORAGE_DIR}"
mkdir -p "${IPTABLES_MOCK_DIR}" "${IPF_STORAGE_DIR}"
: >"${IPTABLES_MOCK_LOG}"
: >"${PERSIST_MOCK_LOG}"
unset IPTABLES_MOCK_FAIL_ON_N || true
storage_init
}
reset_mock_state
uuid_v4=$(cmd_add_batch tcp 8080 127.0.0.1 80 4 'web service')
assert_eq '1' "$(storage_count)" 'cmd_add_batch should persist one rule'
assert_eq '3' "$(grep -Ec '^iptables ' "${IPTABLES_MOCK_LOG}")" 'tcp/ipv4 add should emit three iptables commands'
assert_contains "$(storage_get "${uuid_v4}")" "uuid=${uuid_v4}" 'stored line should contain generated uuid'
assert_eq '1' "$(grep -Ec 'persist-mock\.sh save' "${PERSIST_MOCK_LOG}")" 'successful add should trigger persist_save'
assert_contains "$(ipt_find_by_uuid "${uuid_v4}")" "MGMT:${uuid_v4}" 'ipt_find_by_uuid should locate saved mock rules'
reset_mock_state
uuid_both=$(cmd_add_batch both 5353 '127.0.0.1,::1' 53 both 'dual stack dns')
add_count=$(grep -Ec '^(iptables|ip6tables) ' "${IPTABLES_MOCK_LOG}")
assert_eq '12' "${add_count}" 'both/both add should emit twelve commands'
cmd_delete_uuid "${uuid_both}"
del_count=$(grep -Ec ' -D ' "${IPTABLES_MOCK_LOG}")
assert_eq '12' "${del_count}" 'deleting both/both rule should emit twelve delete commands'
assert_eq '0' "$(storage_count)" 'cmd_delete_uuid should remove rule from storage'
reset_mock_state
export IPTABLES_MOCK_FAIL_ON_N=2
assert_eq '1' "$(status_of cmd_add_batch tcp 9000 127.0.0.1 90 4 'rollback')" 'cmd_add_batch should fail when iptables mock injects an error'
assert_eq '0' "$(storage_count)" 'failed add should not persist storage'
assert_contains "$(cat "${IPTABLES_MOCK_LOG}")" ' -D ' 'failed add should trigger rollback deletes'
assert_eq '0' "$(wc -l < "${PERSIST_MOCK_LOG}")" 'failed add before persistence should not call persist_save'
pass 'test_rules_unit.sh'

48
tests/test_storage.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
# shellcheck source=tests/lib/assert.sh
source "${ROOT_DIR}/tests/lib/assert.sh"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "${TMP_DIR}"' EXIT
export IPF_STORAGE_DIR="${TMP_DIR}/storage"
export IPF_STORAGE_DB="${IPF_STORAGE_DIR}/rules.db"
export IPF_LOCK_FILE="${IPF_STORAGE_DIR}/.lock"
# shellcheck source=lib/storage.sh
source "${ROOT_DIR}/lib/storage.sh"
storage_init
[[ -f ${IPF_STORAGE_DB} ]] || fail 'storage_init should create rules db'
line1='uuid=a1|proto=tcp|lport=80|tip=127.0.0.1|tport=8080|ipver=4|desc=one|created=2026-04-17T00:00:00+0800'
line2='uuid=b2|proto=udp|lport=53|tip=127.0.0.1|tport=5353|ipver=4|desc=two|created=2026-04-17T00:00:01+0800'
line3='uuid=c3|proto=both|lport=22|tip=127.0.0.1,::1|tport=22|ipver=both|desc=three|created=2026-04-17T00:00:02+0800'
storage_add "${line1}"
storage_add "${line2}"
storage_add "${line3}"
assert_eq '3' "$(storage_count)" 'storage_count should reflect inserted lines'
mapfile -t listed < <(storage_list)
assert_eq "${line1}" "${listed[0]}" 'storage_list should preserve insertion order'
assert_eq "${line2}" "${listed[1]}" 'storage_list should preserve second line'
assert_eq "${line3}" "${listed[2]}" 'storage_list should preserve third line'
assert_eq "${line2}" "$(storage_get 'b2')" 'storage_get should find existing uuid'
if storage_get 'missing' >/dev/null 2>&1; then
fail 'storage_get should fail for missing uuid'
fi
storage_delete 'b2'
assert_eq '2' "$(storage_count)" 'storage_delete should remove one line'
if storage_delete 'b2' >/dev/null 2>&1; then
fail 'storage_delete should fail for missing uuid'
fi
assert_eq '2' "$(storage_count)" 'storage_delete failure should keep file unchanged'
assert_eq '22' "$(storage_parse "${line3}" tport)" 'storage_parse should extract requested field'
pass 'test_storage.sh'