release: restore ZUPT and harden source-only 5.2.2

This commit is contained in:
Cristian Cezar Moisés 2026-08-31 14:14:36 -03:00
commit ff99770bd0
205 changed files with 19627 additions and 13215 deletions

View file

@ -0,0 +1,157 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* Build a minimal, structurally valid plaintext archive with an arbitrary
* index path. This is test infrastructure for extraction-path policy: unlike
* byte mutation, the resulting index checksum and archive-integrity trailer
* are valid, so a rejection necessarily reaches the path validation code.
*/
#include "zupt.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int put_u8(FILE *stream, uint8_t value) {
return fputc(value, stream) == EOF ? -1 : 0;
}
static int put_u16le(FILE *stream, uint16_t value) {
return put_u8(stream, (uint8_t)value) ||
put_u8(stream, (uint8_t)(value >> 8)) ? -1 : 0;
}
static size_t put_u32le(uint8_t *out, uint32_t value) {
for (size_t i = 0; i < 4; i++) out[i] = (uint8_t)(value >> (i * 8));
return 4;
}
static size_t put_u64le(uint8_t *out, uint64_t value) {
for (size_t i = 0; i < 8; i++) out[i] = (uint8_t)(value >> (i * 8));
return 8;
}
static size_t put_varint(uint8_t *out, uint64_t value) {
size_t count = 0;
while (value >= 0x80) {
out[count++] = (uint8_t)(value | 0x80);
value >>= 7;
}
out[count++] = (uint8_t)value;
return count;
}
static int write_block(FILE *stream, uint8_t type, const uint8_t *payload,
size_t payload_size, uint64_t unpacked_size,
uint64_t checksum) {
uint8_t varint[10];
size_t varint_size;
if (put_u8(stream, ZUPT_BLOCK_MAGIC_0) ||
put_u8(stream, ZUPT_BLOCK_MAGIC_1) || put_u8(stream, type) ||
put_u16le(stream, ZUPT_CODEC_STORE) || put_u16le(stream, 0))
return -1;
varint_size = put_varint(varint, unpacked_size);
if (fwrite(varint, 1, varint_size, stream) != varint_size) return -1;
varint_size = put_varint(varint, payload_size);
if (fwrite(varint, 1, varint_size, stream) != varint_size) return -1;
uint8_t checksum_bytes[8];
put_u64le(checksum_bytes, checksum);
if (fwrite(checksum_bytes, 1, sizeof(checksum_bytes), stream) !=
sizeof(checksum_bytes))
return -1;
return payload_size == 0 ||
fwrite(payload, 1, payload_size, stream) == payload_size ? 0 : -1;
}
int main(int argc, char **argv) {
static const uint8_t content[] = "fixture content\n";
const char *entry = argc == 3 && strncmp(argv[2], "--entry=", 8) == 0
? argv[2] + 8 : NULL;
if (!entry || argv[1][0] == '\0' || entry[0] == '\0' ||
strlen(entry) >= ZUPT_MAX_PATH) {
fprintf(stderr, "usage: %s ARCHIVE --entry=ENTRY_PATH\n", argv[0]);
return 2;
}
FILE *stream = fopen(argv[1], "wb");
if (!stream) return 1;
zupt_archive_header_t header;
memset(&header, 0, sizeof(header));
const uint8_t magic[6] = { ZUPT_MAGIC_0, ZUPT_MAGIC_1, ZUPT_MAGIC_2,
ZUPT_MAGIC_3, ZUPT_MAGIC_4, ZUPT_MAGIC_5 };
memcpy(header.magic, magic, sizeof(magic));
header.version_major = ZUPT_FORMAT_MAJOR;
header.version_minor = ZUPT_FORMAT_MINOR;
uint8_t serialized_header[ZUPT_ARCHIVE_HEADER_SIZE] = {0};
memcpy(serialized_header, header.magic, sizeof(header.magic));
serialized_header[6] = header.version_major;
serialized_header[7] = header.version_minor;
put_u32le(serialized_header + 8, header.global_flags);
put_u64le(serialized_header + 12, header.creation_time);
memcpy(serialized_header + 20, header.archive_id, sizeof(header.archive_id));
put_u64le(serialized_header + 36, header.encryption_header_off);
put_u64le(serialized_header + 44, header.comment_offset);
memcpy(serialized_header + 52, header.reserved, sizeof(header.reserved));
if (fwrite(serialized_header, 1, sizeof(serialized_header), stream) !=
sizeof(serialized_header)) goto fail;
const size_t content_size = sizeof(content) - 1;
uint64_t content_hash = zupt_xxh64(content, content_size, 0);
uint64_t data_offset = (uint64_t)ftell(stream);
if (write_block(stream, ZUPT_BLOCK_DATA, content, content_size,
content_size, content_hash) != 0)
goto fail;
uint8_t index[ZUPT_MAX_PATH + 128];
size_t index_size = 0;
size_t path_size = strlen(entry);
index_size += put_varint(index + index_size, 1);
index_size += put_varint(index + index_size, path_size);
memcpy(index + index_size, entry, path_size);
index_size += path_size;
index_size += put_u64le(index + index_size, content_size);
index_size += put_u64le(index + index_size, content_size);
index_size += put_u64le(index + index_size, 0);
index_size += put_u64le(index + index_size, content_hash);
index_size += put_u64le(index + index_size, data_offset);
index_size += put_varint(index + index_size, 1);
index_size += put_u32le(index + index_size, 0600);
uint64_t index_offset = (uint64_t)ftell(stream);
if (write_block(stream, ZUPT_BLOCK_INDEX, index, index_size, index_size,
zupt_xxh64(index, index_size, 0)) != 0)
goto fail;
zupt_footer_t footer;
memset(&footer, 0, sizeof(footer));
footer.index_offset = index_offset;
footer.total_blocks = 1;
footer.archive_checksum = (uint64_t)ftell(stream);
memcpy(footer.footer_magic, "ZEND", 4);
footer.footer_version = 1;
uint8_t serialized_footer[ZUPT_FOOTER_SIZE] = {0};
put_u64le(serialized_footer, footer.index_offset);
put_u64le(serialized_footer + 8, footer.total_blocks);
put_u64le(serialized_footer + 16, footer.archive_checksum);
memcpy(serialized_footer + 24, footer.footer_magic,
sizeof(footer.footer_magic));
put_u32le(serialized_footer + 28, footer.footer_version);
if (fwrite(serialized_footer, 1, sizeof(serialized_footer), stream) !=
sizeof(serialized_footer)) goto fail;
uint8_t mac_input[ZUPT_AIT_MAC_INPUT_LEN];
uint8_t trailer[ZUPT_AIT_SIZE];
memcpy(mac_input, serialized_header, sizeof(serialized_header));
memcpy(mac_input + sizeof(serialized_header), serialized_footer, 24);
memset(trailer, 0, sizeof(trailer));
put_u64le(trailer, zupt_xxh64(mac_input, sizeof(mac_input), 0));
if (fwrite(trailer, sizeof(trailer), 1, stream) != 1 || fclose(stream) != 0)
return 1;
return 0;
fail:
fclose(stream);
return 1;
}

275
tests/archive_surgery.py Normal file
View file

@ -0,0 +1,275 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: AGPL-3.0-or-later
"""Strict structural mutations used by archive-authentication tests."""
import argparse
import pathlib
import sys
ARCHIVE_HEADER_SIZE = 64
FOOTER_SIZE = 32
AIT_SIZE = 32
BLOCK_DATA = 0x00
BLOCK_INDEX = 0x02
BLOCK_ENC_HEADER = 0x03
BLOCK_DEDUP_REF = 0x04
BLOCK_COMMENT = 0x05
BLOCK_FLAG_ENCRYPTED = 0x01
class ArchiveError(ValueError):
pass
def _u16le(data, offset):
return int.from_bytes(data[offset:offset + 2], "little")
def _u64le(data, offset):
return int.from_bytes(data[offset:offset + 8], "little")
def _read_varint(data, offset, limit):
value = 0
for byte_number in range(10):
if offset >= limit:
raise ArchiveError("truncated varint")
byte = data[offset]
offset += 1
if byte_number == 9 and byte > 1:
raise ArchiveError("varint exceeds uint64")
value |= (byte & 0x7f) << (7 * byte_number)
if (byte & 0x80) == 0:
if byte_number and value < (1 << (7 * byte_number)):
raise ArchiveError("non-canonical varint")
return value, offset
raise ArchiveError("unterminated varint")
def _parse_frame(data, offset, limit):
start = offset
if limit - offset < 17:
raise ArchiveError("truncated block header")
if data[offset:offset + 2] != b"\xbb\x01":
raise ArchiveError("invalid block magic")
block_type = data[offset + 2]
codec = _u16le(data, offset + 3)
flags = _u16le(data, offset + 5)
offset += 7
uncompressed_size, offset = _read_varint(data, offset, limit)
compressed_size, offset = _read_varint(data, offset, limit)
if limit - offset < 8:
raise ArchiveError("truncated block checksum")
checksum = _u64le(data, offset)
payload_start = offset + 8
if compressed_size > limit - payload_start:
raise ArchiveError("block payload exceeds structural boundary")
end = payload_start + compressed_size
return {
"type": block_type,
"codec": codec,
"flags": flags,
"uncompressed_size": uncompressed_size,
"compressed_size": compressed_size,
"checksum": checksum,
"start": start,
"payload_start": payload_start,
"end": end,
}
def _parse_current_archive(data):
minimum = ARCHIVE_HEADER_SIZE + FOOTER_SIZE + AIT_SIZE
if len(data) < minimum:
raise ArchiveError("archive is too short")
if data[:6] != b"ZUPT\x1a\x00":
raise ArchiveError("invalid archive magic")
footer_start = len(data) - FOOTER_SIZE - AIT_SIZE
if data[footer_start + 24:footer_start + 28] != b"ZEND":
raise ArchiveError("current footer before AIT not found")
if int.from_bytes(data[footer_start + 28:footer_start + 32],
"little") != 1:
raise ArchiveError("unsupported footer version")
index_offset = _u64le(data, footer_start)
if index_offset < ARCHIVE_HEADER_SIZE or index_offset >= footer_start:
raise ArchiveError("index offset is outside the archive body")
frames = []
offset = ARCHIVE_HEADER_SIZE
while offset < index_offset:
frame = _parse_frame(data, offset, index_offset)
if frame["type"] == BLOCK_INDEX:
raise ArchiveError("index frame occurs before footer index offset")
frames.append(frame)
offset = frame["end"]
if offset != index_offset:
raise ArchiveError("archive body does not end at index offset")
index = _parse_frame(data, index_offset, footer_start)
if index["type"] != BLOCK_INDEX:
raise ArchiveError("footer does not point to an index frame")
if index["end"] != footer_start:
raise ArchiveError("bytes remain between index and footer")
return {
"frames": frames,
"index": index,
"footer_start": footer_start,
}
def _kind_value(name):
return {"data": BLOCK_DATA, "enc": BLOCK_ENC_HEADER,
"ref": BLOCK_DEDUP_REF}[name]
def _matching_frames(layout, kind, require_encrypted):
matches = [frame for frame in layout["frames"]
if frame["type"] == _kind_value(kind)]
if require_encrypted:
matches = [frame for frame in matches
if frame["flags"] & BLOCK_FLAG_ENCRYPTED]
return matches
def _same_metadata(left, right):
fields = ("type", "codec", "flags", "uncompressed_size",
"compressed_size", "checksum")
return all(left[field] == right[field] for field in fields)
def _select_equal_length_pair(frames, same_metadata):
for index, left in enumerate(frames):
for right in frames[index + 1:]:
if left["end"] - left["start"] != right["end"] - right["start"]:
continue
if same_metadata and not _same_metadata(left, right):
continue
return left, right
qualifier = " with identical metadata" if same_metadata else ""
raise ArchiveError("no two equal-length frames" + qualifier)
def _write(destination, data):
pathlib.Path(destination).write_bytes(data)
def command_strip_ait(args):
data = pathlib.Path(args.source).read_bytes()
layout = _parse_current_archive(data)
_write(args.destination, data[:layout["footer_start"] + FOOTER_SIZE])
def command_flip_payload(args):
data = bytearray(pathlib.Path(args.source).read_bytes())
layout = _parse_current_archive(data)
frames = _matching_frames(layout, args.kind, args.require_encrypted)
if not frames:
raise ArchiveError("requested frame was not found")
frame = frames[0]
if frame["compressed_size"] == 0:
raise ArchiveError("requested frame has no payload")
position = frame["payload_start"] + frame["compressed_size"] // 2
data[position] ^= 0x01
_write(args.destination, data)
def command_swap_frames(args):
data = bytearray(pathlib.Path(args.source).read_bytes())
layout = _parse_current_archive(data)
frames = _matching_frames(layout, args.kind, args.require_encrypted)
left, right = _select_equal_length_pair(frames, args.same_metadata)
left_bytes = bytes(data[left["start"]:left["end"]])
right_bytes = bytes(data[right["start"]:right["end"]])
if left_bytes == right_bytes:
raise ArchiveError("selected frames are byte-identical; swap is a no-op")
data[left["start"]:left["end"]] = right_bytes
data[right["start"]:right["end"]] = left_bytes
_write(args.destination, data)
def command_replay_frame(args):
data = bytearray(pathlib.Path(args.source).read_bytes())
layout = _parse_current_archive(data)
frames = _matching_frames(layout, args.kind, args.require_encrypted)
source, destination = _select_equal_length_pair(frames,
args.same_metadata)
replay = bytes(data[source["start"]:source["end"]])
if replay == bytes(data[destination["start"]:destination["end"]]):
raise ArchiveError("selected frames are already byte-identical")
data[destination["start"]:destination["end"]] = replay
_write(args.destination, data)
def command_preface_positions(args):
data = pathlib.Path(args.source).read_bytes()
layout = _parse_current_archive(data)
for frame in layout["frames"] + [layout["index"]]:
for position in range(frame["start"], frame["payload_start"]):
print(position)
def command_set_frame_type(args):
data = bytearray(pathlib.Path(args.source).read_bytes())
layout = _parse_current_archive(data)
frames = _matching_frames(layout, args.kind, args.require_encrypted)
if not frames:
raise ArchiveError("requested frame was not found")
replacement = {"data": BLOCK_DATA, "comment": BLOCK_COMMENT}[args.type]
data[frames[0]["start"] + 2] = replacement
_write(args.destination, data)
def _add_frame_options(parser):
parser.add_argument("source")
parser.add_argument("destination")
parser.add_argument("--kind", choices=("data", "enc", "ref"), required=True)
parser.add_argument("--require-encrypted", action="store_true")
def main():
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest="command")
strip_ait = commands.add_parser("strip-ait")
strip_ait.add_argument("source")
strip_ait.add_argument("destination")
strip_ait.set_defaults(function=command_strip_ait)
flip = commands.add_parser("flip-payload")
_add_frame_options(flip)
flip.set_defaults(function=command_flip_payload)
swap = commands.add_parser("swap-frames")
_add_frame_options(swap)
swap.add_argument("--same-metadata", action="store_true")
swap.set_defaults(function=command_swap_frames)
replay = commands.add_parser("replay-frame")
_add_frame_options(replay)
replay.add_argument("--same-metadata", action="store_true")
replay.set_defaults(function=command_replay_frame)
prefaces = commands.add_parser("preface-positions")
prefaces.add_argument("source")
prefaces.set_defaults(function=command_preface_positions)
set_type = commands.add_parser("set-frame-type")
_add_frame_options(set_type)
set_type.add_argument("--type", choices=("data", "comment"), required=True)
set_type.set_defaults(function=command_set_frame_type)
args = parser.parse_args()
if not hasattr(args, "function"):
parser.error("a mutation command is required")
try:
args.function(args)
except (ArchiveError, OSError) as error:
parser.error(str(error))
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,46 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later */
#include <ctype.h>
#include <stdio.h>
static int hex_value(int character) {
if (character >= '0' && character <= '9') return character - '0';
if (character >= 'a' && character <= 'f') return character - 'a' + 10;
if (character >= 'A' && character <= 'F') return character - 'A' + 10;
return -1;
}
int main(int argc, char **argv) {
if (argc != 3) return 2;
FILE *input = fopen(argv[1], "rb");
FILE *output = input ? fopen(argv[2], "wb") : NULL;
if (!input || !output) {
if (input) fclose(input);
if (output) fclose(output);
return 1;
}
int high_nibble = -1;
int character;
int failed = 0;
while ((character = fgetc(input)) != EOF) {
if (isspace((unsigned char)character)) continue;
int value = hex_value(character);
if (value < 0) {
failed = 1;
break;
}
if (high_nibble < 0) {
high_nibble = value;
} else {
if (fputc((high_nibble << 4) | value, output) == EOF) {
failed = 1;
break;
}
high_nibble = -1;
}
}
if (ferror(input) || high_nibble >= 0 || fflush(output) != 0)
failed = 1;
if (fclose(input) != 0) failed = 1;
if (fclose(output) != 0) failed = 1;
return failed ? 1 : 0;
}

29
tests/fixtures/README.md vendored Normal file
View file

@ -0,0 +1,29 @@
# Compatibility fixtures
SPDX-License-Identifier: AGPL-3.0-or-later
`v5.2.1-encrypted-dedup-disk.zupt.hex` is a textual hexadecimal encoding of
a 718-byte archive produced by the unmodified VaptVupt `v5.2.1` tag at commit
`3f897190564d23dd8682f1a07aec62db376b0137`.
The input is four 65,536-byte blocks: `A`, `B`, `B`, then `C`. The archive was
created with:
```text
vaptvupt disk backup --dedup -b 65536 \
-p vaptvupt-5.2.1-fixture legacy-abbc.zupt legacy-abbc.img
```
The repeated third block creates a legacy unauthenticated dedup reference to
the non-zero AAD sequence used by the second DATA frame. The fourth DATA frame
proves that the 5.2.1 linear AAD sequence advances across that reference. The
regression decodes the text only in a temporary directory. No binary archive
is tracked or included as a precompiled program/library.
```text
input SHA-256: f144a6486d4971d5af80597dc283254abf3e40a3ea48cb1326eb78a32df009a6
archive SHA-256: 7aedc693450ff048348730c2d17502499055420d87918d6801dffe87580905bc
```
The fixture is test data generated by the VaptVupt project and is distributed
under the project license, AGPL-3.0-or-later.

View file

@ -0,0 +1 @@
5a5550541a000106c1000000004c4bcb31d9ce1871636dd7ea914893a9ec6b3ac330947f40000000000000000000000000000000000000000000000000000000bb0103000000003535dfcd5687362b923e017cc0d3d4dbe979c8ffc1294ca8f9735f6a1dca2eada0761483cf98ad06c75a87bb737c1adfa566513e1114d1e6f8326dc0270900bb01001000010080800475cf99e954d44bb621fc91737f492f2cd390cdbeaadb23a6bc42250a8c15f8e41284be8b5ed8c00ca3c548b80e08dbd4edda4926c7e4150a53ee84f2ad94d74d6610bce4d72f9fa1a255a71a9b8bb3dc56d6feaa292e8ba02c82d6820872a5b37434c86dc59d0ddccd58c12b2968c8cd1bc3aaa53c37940b75820b5b7ff4bb01001000010080800475647dfd91a6b9e6c021d49988ac9bf13ed8106c6c7505353e7857f699687e25392fabd4e8bc6287a156474f0dbf608801c28e062505b42073e7c8d982f4beddc9e3bd70643e56f21b008e40be5a0924e1ce7d6499047d6e5eae782aa5f64a85231a382a5436f958880a2cf937272b34b9b9048df33a738f7b3a3c452d0bbb01040000000080800408647dfd91a6b9e6c00e01000000000000bb0100100001008080047561edd57b45ef972a0a654bc02830ee9b4db7bd1431f80e984e2e78cd3843dd129dbdfdfa36f37997f55edf6205afe1593b4c993a0a5db70cb3ea1af3f231e09c074233e2f9c1b1047d30d880e891d4f7f9a5d5b42b202d92839deca17ffa5cb6c53c5c1326298dd76006d2437bfe209092e93ba811198f3b4afcccbbd7bb010200000000444427de2204fe74e31a010000000f6c65676163792d616262632e696d67000004000000000067010000000000000016e60632d9ce180d922d4614e9c20186000000000000000400000000000000390200000000000004000000000000004435c96b5a53aaaf5a454e440100000072d5f64874d717f7784f84b625d43ac127352bd3a37b9f2804056d0d475b12c9

View file

@ -1,7 +1,7 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* Zupt v2.0.0 AFL++ Fuzzing Harness: Archive Decompression
* ZUPT v2.0.0 AFL++ Fuzzing Harness: Archive Decompression
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Reads a fuzzed .zupt archive from stdin, attempts to extract it.

View file

@ -1,11 +1,11 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* Zupt v2.0.0 AFL++ Fuzzing Harness: VaptVupt Codec
* ZUPT v2.0.0 AFL++ Fuzzing Harness: VaptVupt Codec
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Reads fuzzed VaptVupt frame data from stdin, attempts decompression.
* Tests the VaptVupt codec directly (bypassing Zupt archive format).
* Tests the VaptVupt codec directly (bypassing ZUPT archive format).
*
* Build:
* afl-clang-fast -fsanitize=address,undefined -g -O1 -mavx2 \

View file

@ -1,4 +1,4 @@
#!/bin/sh
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# ZUPT v2.0.0 — Comprehensive Regression Test Suite
@ -6,7 +6,7 @@
# Run: sh tests/regression.sh
set +e # Don't exit on failure — we track pass/fail ourselves
ZUPT="./zupt"
ZUPT="${1:-./zupt}"
T="/tmp/zupt_regression_$$"
PASS=0; FAIL=0; TOTAL=0
@ -233,15 +233,17 @@ N_SZ=$(stat -c%s "$T/normal.zupt" 2>/dev/null || stat -f%z "$T/normal.zupt" 2>/d
tar cf - -C "$T" data/ 2>/dev/null | gzip -9 > "$T/gz.tar.gz"
G_SZ=$(stat -c%s "$T/gz.tar.gz" 2>/dev/null || stat -f%z "$T/gz.tar.gz" 2>/dev/null)
SR=$(echo "scale=2; $TOTAL_SZ / $S_SZ" | bc)
NR=$(echo "scale=2; $TOTAL_SZ / $N_SZ" | bc)
GR=$(echo "scale=2; $TOTAL_SZ / $G_SZ" | bc)
SR=$(awk -v total="$TOTAL_SZ" -v size="$S_SZ" 'BEGIN { printf "%.2f", total / size }')
NR=$(awk -v total="$TOTAL_SZ" -v size="$N_SZ" 'BEGIN { printf "%.2f", total / size }')
GR=$(awk -v total="$TOTAL_SZ" -v size="$G_SZ" 'BEGIN { printf "%.2f", total / size }')
echo " gzip -9: $G_SZ bytes ${GR}:1"
echo " ZUPT normal: $N_SZ bytes ${NR}:1"
echo " ZUPT solid: $S_SZ bytes ${SR}:1"
if [ "$S_SZ" -le "$G_SZ" ]; then
pass "Solid beats gzip ($(echo "scale=1; ($G_SZ-$S_SZ)*100/$G_SZ" | bc)% smaller)"
SAVING=$(awk -v gzip="$G_SZ" -v solid="$S_SZ" \
'BEGIN { printf "%.1f", (gzip - solid) * 100 / gzip }')
pass "Solid beats gzip (${SAVING}% smaller)"
else
echo " NOTE: gzip wins (normal for small non-backup corpus)"
pass "Compression comparison complete"

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
set +e
Z="./zupt"; T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
Z=${1:-./zupt}; T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
mkdir -p "$T/d"; echo "hello" > "$T/d/a.txt"
dd if=/dev/urandom bs=1024 count=10 of="$T/d/b.bin" 2>/dev/null; touch "$T/d/e.txt"
P=0; F=0; ok() { echo " OK: $1"; P=$((P+1)); }; fl() { echo " FAIL: $1"; F=$((F+1)); }
@ -31,6 +31,6 @@ R=$($Z test "$T/1.zupt" 2>&1); echo "$R"|grep -q "0 failed" && ok "Integrity" ||
# F-01 (2.2.4): every help command verb starts its own line.
# Pre-fix output ran "keygen … Key generation zupt version" on one
# wrapped line due to a missing \n in src/zupt_main.c:41.
HC=$($Z help 2>&1 | grep -cE '^ (vaptvupt|zupt) ')
HC=$($Z help 2>&1 | grep -cE '^ zupt ')
[ "$HC" -ge 10 ] && ok "Help command lines ($HC)" || fl "Help command lines ($HC, need ≥10)"
echo ""; echo " Results: $P passed, $F failed (11 tests)"; [ "$F" -eq 0 ] && exit 0 || exit 1

View file

@ -5,7 +5,11 @@
# Bug #15 (v2.2.2): options after the positional archive argument were
# silently dropped. e.g. `zupt x arch.zupt -o out` ignored `-o out`.
ZUPT_BIN="$(realpath ./zupt)"
ZUPT_BIN=${1:-./zupt}
case $ZUPT_BIN in
/*) ;;
*) ZUPT_BIN=$PWD/${ZUPT_BIN#./} ;;
esac
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"

View file

@ -0,0 +1,413 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
bin=${1:-./zupt}
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
case "$bin" in
/*) ;;
*) bin="$(pwd -P)/${bin#./}" ;;
esac
surgery="$repo_root/tests/archive_surgery.py"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-atomic-output.XXXXXX")
trap 'rm -rf "$tmp"' EXIT
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
assert_no_temps() {
if find "$tmp" -name '.zupt-archive-*' -print -quit | grep -q .; then
fail 'private archive temporary was not removed'
fi
}
printf 'archive payload\n' > "$tmp/input.txt"
printf 'victim must remain unchanged\n' > "$tmp/victim.txt"
cp "$tmp/victim.txt" "$tmp/victim.expected"
# Writers must never create an archive that their own extraction policy would
# reject. A parent component in the user-supplied input name fails before any
# output is published.
mkdir "$tmp/parent-input-work"
printf 'parent input\n' > "$tmp/parent-input.txt"
if (cd "$tmp/parent-input-work" &&
MSYS2_ARG_CONV_EXCL='../parent-input.txt' \
"$bin" compress -s parent-path.zupt ../parent-input.txt \
>/dev/null 2>&1); then
fail 'compression accepted an unsafe parent-component archive name'
fi
test ! -e "$tmp/parent-input-work/parent-path.zupt" ||
fail 'unsafe parent-component input published an archive'
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) ;;
*)
mkdir -p "$tmp/collision/in/foo"
printf 'literal backslash\n' > "$tmp/collision/in/foo\\bar"
printf 'nested separator\n' > "$tmp/collision/in/foo/bar"
if "$bin" compress -s "$tmp/collision.zupt" \
"$tmp/collision/in" >/dev/null 2>&1; then
fail 'compression accepted colliding slash/backslash destinations'
fi
test ! -e "$tmp/collision.zupt" ||
fail 'colliding archive paths published an archive'
mkdir "$tmp/case-collision"
printf 'upper\n' > "$tmp/case-collision/Name.txt"
printf 'lower\n' > "$tmp/case-collision/name.txt"
if [[ $(find "$tmp/case-collision" -type f | wc -l) -eq 2 ]]; then
if "$bin" compress -s "$tmp/case-collision.zupt" \
"$tmp/case-collision" >/dev/null 2>&1; then
fail 'compression accepted ASCII case-colliding destinations'
fi
test ! -e "$tmp/case-collision.zupt" ||
fail 'case-colliding archive paths published an archive'
fi
;;
esac
# Normal compression must not publish an archive over any spelling or link
# alias of an input file. --force does not bypass this data-loss boundary.
mkdir "$tmp/self-input"
printf 'self input must survive\n' > "$tmp/self-input/self.zupt"
cp "$tmp/self-input/self.zupt" "$tmp/self-input.expected"
if "$bin" compress -s "$tmp/self-input/./self.zupt" \
"$tmp/self-input/self.zupt" >/dev/null 2>&1; then
fail 'compression accepted an alternate spelling of its input as output'
fi
if "$bin" compress --solid -s "$tmp/self-input/./self.zupt" \
"$tmp/self-input/self.zupt" >/dev/null 2>&1; then
fail 'solid compression accepted an alternate spelling of its input as output'
fi
cmp "$tmp/self-input.expected" "$tmp/self-input/self.zupt" ||
fail 'alternate-spelling self compression changed its input'
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
# Native Windows publication uses handle-relative APIs and rejects
# reparse-point ancestors. Exercise the portable guarantees here;
# POSIX symlink, hardlink, ulimit and raw-device cases are reported as
# skipped instead of imposing contradictory MSYS semantics on the PE.
printf 'existing Windows output\n' > "$tmp/windows-output.zupt"
"$bin" compress -s "$tmp/windows-output.zupt" "$tmp/input.txt" \
>/dev/null 2>&1 || fail 'Windows archive replacement failed'
"$bin" test "$tmp/windows-output.zupt" >/dev/null 2>&1 ||
fail 'Windows atomically published archive is invalid'
mkdir "$tmp/windows-directory.zupt"
if "$bin" compress -s "$tmp/windows-directory.zupt" \
"$tmp/input.txt" >/dev/null 2>&1; then
fail 'Windows directory destination was replaced'
fi
dd if=/dev/urandom of="$tmp/windows-disk.img" bs=65536 count=2 \
2>/dev/null
"$bin" disk backup -s -b 65536 "$tmp/windows-disk.zupt" \
"$tmp/windows-disk.img" >/dev/null 2>&1 ||
fail 'Windows disk backup failed'
"$bin" test "$tmp/windows-disk.zupt" >/dev/null 2>&1 ||
fail 'Windows disk archive is invalid'
mkdir "$tmp/windows-disk-extracted"
"$bin" extract -o "$tmp/windows-disk-extracted" \
"$tmp/windows-disk.zupt" >/dev/null 2>&1 ||
fail 'Windows disk archive generic extraction failed'
cmp "$tmp/windows-disk.img" \
"$tmp/windows-disk-extracted/windows-disk.img" ||
fail 'Windows disk archive generic extraction mismatch'
"$bin" disk restore "$tmp/windows-disk.zupt" \
"$tmp/windows-restored.img" >/dev/null 2>&1 ||
fail 'Windows disk restore failed'
cmp "$tmp/windows-disk.img" "$tmp/windows-restored.img" ||
fail 'Windows disk restore mismatch'
python3 "$surgery" flip-payload "$tmp/windows-disk.zupt" \
"$tmp/windows-disk-corrupt.zupt" --kind data ||
fail 'could not corrupt Windows disk archive fixture'
printf 'Windows restore sentinel\n' > "$tmp/windows-restore-target"
cp "$tmp/windows-restore-target" "$tmp/windows-restore.expected"
if "$bin" disk restore "$tmp/windows-disk-corrupt.zupt" \
"$tmp/windows-restore-target" >/dev/null 2>&1; then
fail 'Windows disk restore accepted corrupt DATA'
fi
cmp "$tmp/windows-restore.expected" "$tmp/windows-restore-target" ||
fail 'Windows corrupt disk restore changed its target'
assert_no_temps
printf 'SKIP: POSIX symlink, hardlink, ulimit and raw-device atomic cases\n'
printf 'atomic archive output Windows subset: PASS\n'
exit 0
;;
esac
printf 'hardlinked input must survive\n' > "$tmp/self-hard-input"
cp "$tmp/self-hard-input" "$tmp/self-hard.expected"
ln "$tmp/self-hard-input" "$tmp/self-hard-output.zupt"
if "$bin" compress -y -s "$tmp/self-hard-output.zupt" \
"$tmp/self-hard-input" >/dev/null 2>&1; then
fail 'compression accepted a hardlink alias of its input as output'
fi
if "$bin" compress --solid -y -s "$tmp/self-hard-output.zupt" \
"$tmp/self-hard-input" >/dev/null 2>&1; then
fail 'solid compression accepted a hardlink alias of its input as output'
fi
test "$tmp/self-hard-input" -ef "$tmp/self-hard-output.zupt" ||
fail 'rejected compression hardlink alias was replaced'
cmp "$tmp/self-hard.expected" "$tmp/self-hard-input" ||
fail 'hardlink-alias compression changed its input'
printf 'symlinked input must survive\n' > "$tmp/self-symlink-input"
cp "$tmp/self-symlink-input" "$tmp/self-symlink.expected"
ln -s self-symlink-input "$tmp/self-symlink-output.zupt"
if "$bin" compress -s "$tmp/self-symlink-output.zupt" \
"$tmp/self-symlink-input" >/dev/null 2>&1; then
fail 'compression accepted a symlink alias of its input as output'
fi
if "$bin" compress --solid -s "$tmp/self-symlink-output.zupt" \
"$tmp/self-symlink-input" >/dev/null 2>&1; then
fail 'solid compression accepted a symlink alias of its input as output'
fi
test -L "$tmp/self-symlink-output.zupt" ||
fail 'rejected compression symlink alias was replaced'
cmp "$tmp/self-symlink.expected" "$tmp/self-symlink-input" ||
fail 'symlink-alias compression changed its input'
# Replacing the output entry must not open or truncate its symlink target.
ln -s victim.txt "$tmp/symlink.zupt"
"$bin" compress -s "$tmp/symlink.zupt" "$tmp/input.txt" >/dev/null 2>&1
cmp "$tmp/victim.expected" "$tmp/victim.txt" || fail 'symlink target changed'
test ! -L "$tmp/symlink.zupt" || fail 'archive remained a symlink'
"$bin" test "$tmp/symlink.zupt" >/dev/null 2>&1 || fail 'published archive is invalid'
assert_no_temps
# The same directory-entry replacement rule protects another name linked to
# the old inode. The victim keeps its bytes while the output gets a new inode.
printf 'hardlink victim\n' > "$tmp/hard-victim"
cp "$tmp/hard-victim" "$tmp/hard.expected"
ln "$tmp/hard-victim" "$tmp/hardlink.zupt"
"$bin" compress --solid -s "$tmp/hardlink.zupt" "$tmp/input.txt" >/dev/null 2>&1
cmp "$tmp/hard.expected" "$tmp/hard-victim" || fail 'hardlink peer changed'
if test "$tmp/hard-victim" -ef "$tmp/hardlink.zupt"; then
fail 'archive reused victim inode'
fi
"$bin" test "$tmp/hardlink.zupt" >/dev/null 2>&1 || fail 'solid archive is invalid'
assert_no_temps
# A symlink explicitly present in the user-selected POSIX parent is resolved
# once, then the physical directory is pinned for the entire publication.
mkdir "$tmp/real-parent"
ln -s real-parent "$tmp/parent-link"
"$bin" compress -s "$tmp/parent-link/through-link.zupt" \
"$tmp/input.txt" >/dev/null 2>&1 || fail 'symlinked parent was unusable'
"$bin" test "$tmp/real-parent/through-link.zupt" >/dev/null 2>&1 ||
fail 'archive through resolved parent is invalid'
assert_no_temps
# A directory at the final name cannot be replaced. The publication failure
# must remove the private temporary and leave the old directory untouched.
mkdir "$tmp/final-is-directory.zupt"
printf 'directory sentinel\n' > "$tmp/final-is-directory.zupt/sentinel"
if "$bin" compress -s "$tmp/final-is-directory.zupt" \
"$tmp/input.txt" >/dev/null 2>&1; then
fail 'directory destination was replaced'
fi
grep -qx 'directory sentinel' "$tmp/final-is-directory.zupt/sentinel" ||
fail 'directory destination changed after failed publication'
assert_no_temps
# Force a write/fsync failure after the temporary has been opened. A prior
# destination must survive byte-for-byte and no partial archive may appear.
head -c 16384 /dev/urandom > "$tmp/large-input.bin"
printf 'previous archive sentinel\n' > "$tmp/write-failure.zupt"
cp "$tmp/write-failure.zupt" "$tmp/write-failure.expected"
if (trap '' XFSZ; ulimit -f 1; "$bin" compress -s \
"$tmp/write-failure.zupt" "$tmp/large-input.bin" \
>/dev/null 2>&1); then
fail 'forced write failure unexpectedly succeeded'
fi
cmp "$tmp/write-failure.expected" "$tmp/write-failure.zupt" ||
fail 'prior archive changed after write failure'
assert_no_temps
# Two publishers may race for the same directory entry. Each builds a private
# complete archive; whichever rename wins must leave a valid final archive.
"$bin" compress -s "$tmp/concurrent.zupt" "$tmp/input.txt" \
>/dev/null 2>&1 &
first_pid=$!
"$bin" compress --solid -s "$tmp/concurrent.zupt" "$tmp/input.txt" \
>/dev/null 2>&1 &
second_pid=$!
wait "$first_pid" || fail 'first concurrent publisher failed'
wait "$second_pid" || fail 'second concurrent publisher failed'
"$bin" test "$tmp/concurrent.zupt" >/dev/null 2>&1 ||
fail 'concurrent final archive is invalid'
assert_no_temps
# Disk-image backup uses the same atomic publisher.
printf 'disk image bytes\n' > "$tmp/disk.img"
# A disk backup must never replace its only source name with the archive. The
# identity check covers direct spelling, hardlink aliases, and symlink aliases.
cp "$tmp/disk.img" "$tmp/disk-same.img"
cp "$tmp/disk-same.img" "$tmp/disk-same.expected"
if "$bin" disk backup -s "$tmp/disk-same.img" "$tmp/disk-same.img" \
>/dev/null 2>&1; then
fail 'disk backup accepted the same source and output path'
fi
cmp "$tmp/disk-same.expected" "$tmp/disk-same.img" ||
fail 'same-path disk backup changed its source'
cp "$tmp/disk.img" "$tmp/disk-hardlink-source"
cp "$tmp/disk-hardlink-source" "$tmp/disk-hardlink.expected"
ln "$tmp/disk-hardlink-source" "$tmp/disk-hardlink-output.zupt"
if "$bin" disk backup -s "$tmp/disk-hardlink-output.zupt" \
"$tmp/disk-hardlink-source" >/dev/null 2>&1; then
fail 'disk backup accepted a hardlink alias of its source'
fi
test "$tmp/disk-hardlink-source" -ef "$tmp/disk-hardlink-output.zupt" ||
fail 'rejected disk hardlink alias was replaced'
cmp "$tmp/disk-hardlink.expected" "$tmp/disk-hardlink-source" ||
fail 'hardlink-alias disk backup changed its source'
cp "$tmp/disk.img" "$tmp/disk-symlink-source"
cp "$tmp/disk-symlink-source" "$tmp/disk-symlink.expected"
ln -s disk-symlink-source "$tmp/disk-symlink-output.zupt"
if "$bin" disk backup -s "$tmp/disk-symlink-output.zupt" \
"$tmp/disk-symlink-source" >/dev/null 2>&1; then
fail 'disk backup accepted a symlink alias of its source'
fi
test -L "$tmp/disk-symlink-output.zupt" ||
fail 'rejected disk symlink alias was replaced'
cmp "$tmp/disk-symlink.expected" "$tmp/disk-symlink-source" ||
fail 'symlink-alias disk backup changed its source'
assert_no_temps
printf 'disk victim\n' > "$tmp/disk-victim"
cp "$tmp/disk-victim" "$tmp/disk.expected"
ln -s disk-victim "$tmp/disk.zupt"
"$bin" disk backup -s "$tmp/disk.zupt" "$tmp/disk.img" >/dev/null 2>&1
cmp "$tmp/disk.expected" "$tmp/disk-victim" || fail 'disk backup followed symlink'
test ! -L "$tmp/disk.zupt" || fail 'disk archive remained a symlink'
"$bin" disk restore "$tmp/disk.zupt" "$tmp/disk-restored.img" \
>/dev/null 2>&1 || fail 'disk archive could not be restored'
cmp "$tmp/disk.img" "$tmp/disk-restored.img" || fail 'disk restore mismatch'
"$bin" test "$tmp/disk.zupt" >/dev/null 2>&1 || fail 'disk archive test failed'
"$bin" list "$tmp/disk.zupt" >/dev/null 2>&1 || fail 'disk archive list failed'
mkdir "$tmp/disk-extracted"
"$bin" extract -o "$tmp/disk-extracted" "$tmp/disk.zupt" \
>/dev/null 2>&1 || fail 'absolute-source disk archive generic extraction failed'
cmp "$tmp/disk.img" "$tmp/disk-extracted/disk.img" ||
fail 'absolute-source disk archive generic extraction mismatch'
assert_no_temps
# Restore must fail closed if it cannot create its private source snapshot;
# it may not fall back to validating and consuming a mutable pathname.
printf 'not a directory\n' > "$tmp/not-a-snapshot-directory"
printf 'snapshot failure target\n' > "$tmp/snapshot-failure-target"
cp "$tmp/snapshot-failure-target" "$tmp/snapshot-failure.expected"
if ZUPT_TMPDIR="$tmp/not-a-snapshot-directory" \
"$bin" disk restore "$tmp/disk.zupt" \
"$tmp/snapshot-failure-target" >/dev/null 2>&1; then
fail 'disk restore continued without a private archive snapshot'
fi
cmp "$tmp/snapshot-failure.expected" "$tmp/snapshot-failure-target" ||
fail 'snapshot creation failure changed the restore target'
assert_no_temps
# Restore targets are destructive by nature. A final-component symlink must
# be rejected without following it or replacing it, and its external target
# must remain byte-for-byte unchanged.
printf 'external restore target\n' > "$tmp/restore-symlink-victim"
cp "$tmp/restore-symlink-victim" "$tmp/restore-symlink.expected"
ln -s restore-symlink-victim "$tmp/restore-symlink-target"
if "$bin" disk restore "$tmp/disk.zupt" "$tmp/restore-symlink-target" \
>/dev/null 2>&1; then
fail 'disk restore accepted a symlink target'
fi
test -L "$tmp/restore-symlink-target" ||
fail 'disk restore replaced the rejected symlink'
cmp "$tmp/restore-symlink.expected" "$tmp/restore-symlink-victim" ||
fail 'disk restore changed the symlink target'
# A regular target with st_nlink > 1 must also be rejected. Both directory
# entries must still name the original inode and retain its original bytes.
printf 'multiply linked restore target\n' > "$tmp/restore-hardlink-peer"
cp "$tmp/restore-hardlink-peer" "$tmp/restore-hardlink.expected"
ln "$tmp/restore-hardlink-peer" "$tmp/restore-hardlink-target"
if "$bin" disk restore "$tmp/disk.zupt" "$tmp/restore-hardlink-target" \
>/dev/null 2>&1; then
fail 'disk restore accepted a multiply-linked target'
fi
test "$tmp/restore-hardlink-peer" -ef "$tmp/restore-hardlink-target" ||
fail 'disk restore replaced the rejected hardlink entry'
cmp "$tmp/restore-hardlink.expected" "$tmp/restore-hardlink-peer" ||
fail 'disk restore changed the hardlink peer'
cmp "$tmp/restore-hardlink.expected" "$tmp/restore-hardlink-target" ||
fail 'disk restore changed the multiply-linked target'
# A target that is another hardlink to the archive itself is rejected before
# opening either inode for writing. The archive must remain readable.
cp "$tmp/disk.zupt" "$tmp/same-inode.zupt"
ln "$tmp/same-inode.zupt" "$tmp/same-inode-target"
cp "$tmp/same-inode.zupt" "$tmp/same-inode.expected"
if "$bin" disk restore "$tmp/same-inode.zupt" "$tmp/same-inode-target" \
>/dev/null 2>&1; then
fail 'disk restore accepted its own archive inode as the target'
fi
cmp "$tmp/same-inode.expected" "$tmp/same-inode.zupt" ||
fail 'same-inode restore attempt changed the archive'
test "$tmp/same-inode.zupt" -ef "$tmp/same-inode-target" ||
fail 'same-inode restore attempt replaced one hardlink'
"$bin" test "$tmp/same-inode.zupt" >/dev/null 2>&1 ||
fail 'same-inode restore attempt corrupted the archive'
# Removing the trailing archive-integrity field creates the structurally valid
# legacy framing used by the downgrade attack. Disk restore must reject it by
# default and leave a preexisting regular target untouched.
python3 "$surgery" strip-ait "$tmp/disk.zupt" \
"$tmp/disk-without-ait.zupt" || fail 'could not remove disk archive AIT'
printf 'existing no-AIT restore target\n' > "$tmp/no-ait-restore-target"
cp "$tmp/no-ait-restore-target" "$tmp/no-ait-restore.expected"
if "$bin" disk restore "$tmp/disk-without-ait.zupt" \
"$tmp/no-ait-restore-target" >/dev/null 2>&1; then
fail 'disk restore accepted a no-AIT archive by default'
fi
cmp "$tmp/no-ait-restore.expected" "$tmp/no-ait-restore-target" ||
fail 'no-AIT disk archive changed the existing restore target'
# Late DATA corruption must be discovered before publishing over an existing
# regular target. This specifically guards against open(O_TRUNC)-then-verify
# behavior and partial output left behind after a checksum/authentication
# failure.
python3 "$surgery" flip-payload "$tmp/disk.zupt" \
"$tmp/corrupt-disk.zupt" --kind data ||
fail 'could not construct corrupt disk archive'
printf 'existing regular restore target\n' > "$tmp/restore-existing"
cp "$tmp/restore-existing" "$tmp/restore-existing.expected"
if "$bin" disk restore "$tmp/corrupt-disk.zupt" "$tmp/restore-existing" \
>/dev/null 2>&1; then
fail 'disk restore accepted a corrupt DATA block'
fi
cmp "$tmp/restore-existing.expected" "$tmp/restore-existing" ||
fail 'corrupt archive changed the existing restore target'
assert_no_temps
# Encrypted dedup references carry the original DATA frame AAD sequence and
# authenticate their own logical position; restore must reproduce the bytes.
dd if=/dev/urandom of="$tmp/repeated-block" bs=65536 count=1 2>/dev/null
cp "$tmp/repeated-block" "$tmp/dedup-disk.img"
dd if="$tmp/repeated-block" of="$tmp/dedup-disk.img" bs=65536 seek=1 \
conv=notrunc 2>/dev/null
printf 'atomic-disk-test-password\n' > "$tmp/disk-password"
chmod 600 "$tmp/disk-password"
"$bin" disk backup --dedup -b 65536 --pass-file "$tmp/disk-password" -s \
"$tmp/dedup-encrypted.zupt" "$tmp/dedup-disk.img" >/dev/null 2>&1 ||
fail 'encrypted dedup disk backup failed'
"$bin" test --pass-file "$tmp/disk-password" "$tmp/dedup-encrypted.zupt" \
>/dev/null 2>&1 || fail 'encrypted dedup disk archive test failed'
"$bin" disk restore --pass-file "$tmp/disk-password" \
"$tmp/dedup-encrypted.zupt" "$tmp/dedup-restored.img" >/dev/null 2>&1 ||
fail 'encrypted dedup disk restore failed'
cmp "$tmp/dedup-disk.img" "$tmp/dedup-restored.img" ||
fail 'encrypted dedup disk restore mismatch'
assert_no_temps
printf 'atomic archive output: PASS\n'

View file

@ -1,20 +1,25 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# zupt audit test suite — double-validated security checks for zupt 2.2+
# ZUPT audit test suite — double-validated security checks.
# Each property is checked via TWO independent paths.
ZUPT_BIN="$(realpath ./zupt)"
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! "$ZUPT_BIN" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
set -Eeuo pipefail
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
ZUPT_BIN=${ZUPT_BIN:-$repo_root/zupt}
if [[ ! -x $ZUPT_BIN ]]; then
printf ' FAIL: %s not found; build ZUPT first\n' "$ZUPT_BIN" >&2
exit 1
fi
version=$("$ZUPT_BIN" --version 2>&1)
if ! grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
echo ' SKIP: system libvuptsdk integration is disabled (build with WITH_SDK=1)'
exit 0
fi
rm -rf "$_sdkck"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
trap 'rm -rf -- "$TMPDIR"' EXIT
cd "$TMPDIR"
PASS=0; FAIL=0
@ -37,15 +42,23 @@ echo " [A. Authenticated archives]"
# A1. Wrong key rejected: SDK key vs SDK archive (path A) + Legacy key vs SDK archive (path B)
echo "data" > input.txt
"$ZUPT_BIN" c --pq-sdk k.priv.pub a.zupt input.txt > /dev/null 2>&1
mkdir -p ea && (cd ea && "$ZUPT_BIN" x --pq-sdk ../other.priv ../a.zupt > /dev/null 2>&1)
A=$([ ! -f ea/input.txt ] && echo 1 || echo 0)
mkdir -p eb && (cd eb && "$ZUPT_BIN" x --pq legacy.key ../a.zupt > /dev/null 2>&1)
B=$([ ! -f eb/input.txt ] && echo 1 || echo 0)
mkdir -p ea
set +e
(cd ea && "$ZUPT_BIN" x --pq-sdk ../other.priv ../a.zupt > /dev/null 2>&1)
A_RC=$?
set -e
A=$([ "$A_RC" -ne 0 ] && [ ! -f ea/input.txt ] && echo 1 || echo 0)
mkdir -p eb
set +e
(cd eb && "$ZUPT_BIN" x --pq legacy.key ../a.zupt > /dev/null 2>&1)
B_RC=$?
set -e
B=$([ "$B_RC" -ne 0 ] && [ ! -f eb/input.txt ] && echo 1 || echo 0)
DCHK "Wrong key rejected (SDK key + legacy key paths)" "$A" "$B"
# A2. Tamper at byte position N detected.
#
# F-02 (Zupt 2.2.4): the previous version flipped a byte at len-50 for
# F-02 (ZUPT 2.2.4): the previous version flipped a byte at len-50 for
# path B. SDK-PQ archive sizes vary by 1-2 bytes per run (ciphertext
# encoding), so len-50 occasionally landed inside the *index* region
# (between footer.index_offset and the trailing 32-byte footer), which
@ -69,16 +82,21 @@ python3 -c "
b = bytearray(open('t2.zupt','rb').read())
b[500] ^= 1
open('t2.zupt','wb').write(bytes(b))" 2>/dev/null
mkdir -p t1e && (cd t1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t1.zupt > /dev/null 2>&1)
mkdir -p t2e && (cd t2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t2.zupt > /dev/null 2>&1)
A=$([ ! -f t1e/input.txt ] && echo 1 || echo 0)
B=$([ ! -f t2e/input.txt ] && echo 1 || echo 0)
mkdir -p t1e t2e
set +e
(cd t1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t1.zupt > /dev/null 2>&1)
A_RC=$?
(cd t2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../t2.zupt > /dev/null 2>&1)
B_RC=$?
set -e
A=$([ "$A_RC" -ne 0 ] && [ ! -f t1e/input.txt ] && echo 1 || echo 0)
B=$([ "$B_RC" -ne 0 ] && [ ! -f t2e/input.txt ] && echo 1 || echo 0)
DCHK "Tamper detected at body offset 200 and 500" "$A" "$B"
echo " [B. Format security]"
# B1. Zero-byte file (path A) + 1-byte file (path B): both must roundtrip
> empty.txt
: >empty.txt
echo -n "x" > one.txt
"$ZUPT_BIN" c --pq-sdk k.priv.pub e.zupt empty.txt > /dev/null 2>&1
"$ZUPT_BIN" c --pq-sdk k.priv.pub o.zupt one.txt > /dev/null 2>&1
@ -101,24 +119,42 @@ DCHK "1MB roundtrip (random + structured)" "$A" "$B"
# B3. Truncated archive rejected (path A: cut last 50 bytes) (path B: cut at midpoint)
cp a.zupt tr1.zupt; cp a.zupt tr2.zupt
truncate -s -50 tr1.zupt
truncate -s 100 tr2.zupt
python3 - <<'PY'
from pathlib import Path
first = Path("tr1.zupt")
first.write_bytes(first.read_bytes()[:-50])
second = Path("tr2.zupt")
second.write_bytes(second.read_bytes()[:100])
PY
mkdir -p tr1e tr2e
set +e
(cd tr1e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../tr1.zupt > /dev/null 2>&1)
A_RC=$?
(cd tr2e && "$ZUPT_BIN" x --pq-sdk ../k.priv ../tr2.zupt > /dev/null 2>&1)
A=$([ ! -f tr1e/input.txt ] && echo 1 || echo 0)
B=$([ ! -f tr2e/input.txt ] && echo 1 || echo 0)
B_RC=$?
set -e
A=$([ "$A_RC" -ne 0 ] && [ ! -f tr1e/input.txt ] && echo 1 || echo 0)
B=$([ "$B_RC" -ne 0 ] && [ ! -f tr2e/input.txt ] && echo 1 || echo 0)
DCHK "Truncated archive rejected" "$A" "$B"
echo " [C. Format compatibility]"
# C1. Mode confusion: SDK archive cannot be read with --pq (legacy)
mkdir -p mc1 && (cd mc1 && "$ZUPT_BIN" x --pq ../legacy.key ../a.zupt > /dev/null 2>&1)
A=$([ ! -f mc1/input.txt ] && echo 1 || echo 0)
mkdir -p mc1
set +e
(cd mc1 && "$ZUPT_BIN" x --pq ../legacy.key ../a.zupt > /dev/null 2>&1)
A_RC=$?
set -e
A=$([ "$A_RC" -ne 0 ] && [ ! -f mc1/input.txt ] && echo 1 || echo 0)
# Also: legacy archive cannot be read with --pq-sdk
"$ZUPT_BIN" c --pq legacy.key leg.zupt input.txt > /dev/null 2>&1
mkdir -p mc2 && (cd mc2 && "$ZUPT_BIN" x --pq-sdk ../k.priv ../leg.zupt > /dev/null 2>&1)
B=$([ ! -f mc2/input.txt ] && echo 1 || echo 0)
mkdir -p mc2
set +e
(cd mc2 && "$ZUPT_BIN" x --pq-sdk ../k.priv ../leg.zupt > /dev/null 2>&1)
B_RC=$?
set -e
B=$([ "$B_RC" -ne 0 ] && [ ! -f mc2/input.txt ] && echo 1 || echo 0)
DCHK "Mode confusion prevented (SDK↔legacy)" "$A" "$B"
# C2. Legacy archive readable with legacy key (compat baseline)
@ -132,17 +168,26 @@ DCHK "Both SDK and legacy paths roundtrip independently" "$A" "$B"
echo " [D. Robustness]"
# D1. Non-existent input handled
set +e
"$ZUPT_BIN" c --pq-sdk k.priv.pub nx.zupt /nonexistent_file_12345 > /dev/null 2>&1
A=$([ ! -f nx.zupt ] && echo 1 || echo 0)
A_RC=$?
"$ZUPT_BIN" c --pq-sdk k.priv.pub nx2.zupt /dev/nonexistent > /dev/null 2>&1
B=$([ ! -f nx2.zupt ] && echo 1 || echo 0)
B_RC=$?
set -e
A=$([ "$A_RC" -ne 0 ] && [ ! -f nx.zupt ] && echo 1 || echo 0)
B=$([ "$B_RC" -ne 0 ] && [ ! -f nx2.zupt ] && echo 1 || echo 0)
DCHK "Missing input file rejected cleanly" "$A" "$B"
# D2. Non-existent key handled
mkdir -p nk1 && (cd nk1 && "$ZUPT_BIN" x --pq-sdk /nonexistent.key ../a.zupt > /dev/null 2>&1)
A=$([ ! -f nk1/input.txt ] && echo 1 || echo 0)
mkdir -p nk1
set +e
(cd nk1 && "$ZUPT_BIN" x --pq-sdk /nonexistent.key ../a.zupt > /dev/null 2>&1)
A_RC=$?
"$ZUPT_BIN" c --pq-sdk /nonexistent.pub bbnk.zupt input.txt > /dev/null 2>&1
B=$([ ! -s bbnk.zupt ] && echo 1 || echo 0)
B_RC=$?
set -e
A=$([ "$A_RC" -ne 0 ] && [ ! -f nk1/input.txt ] && echo 1 || echo 0)
B=$([ "$B_RC" -ne 0 ] && [ ! -s bbnk.zupt ] && echo 1 || echo 0)
DCHK "Missing key file rejected cleanly" "$A" "$B"
# D3. Multiple files in one archive
@ -158,4 +203,4 @@ echo
echo " ───────────────────────────────────────"
echo " Audit results: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]
((FAIL == 0))

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
bin=${1:-$repo_root/zupt}
case "$bin" in
/*) ;;
*) bin="$(pwd -P)/${bin#./}" ;;
esac
surgery="$repo_root/tests/archive_surgery.py"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-dedup-auth.XXXXXX")
trap 'rm -rf "$tmp"' EXIT
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
expect_rejected() {
archive=$1
description=$2
stderr="$tmp/rejected.stderr"
if "$bin" test --pass-file "$tmp/password" "$archive" \
>/dev/null 2>"$stderr"; then
fail "$description was accepted"
fi
grep -F 'Authentication failed' "$stderr" >/dev/null ||
fail "$description was rejected for a reason other than authentication"
}
test -x "$bin" || fail "$bin is not executable"
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
printf 'authenticated-dedup-test-password\n' > "$tmp/password"
chmod 600 "$tmp/password"
# Equal-size, distinct blocks exercise DATA frame position binding while the
# archive is in dedup mode. Whole-frame swaps and replays preserve each
# frame's internal HMAC, so only its logical-position AAD can reject them
# before extraction trusts the data.
dd if=/dev/urandom of="$tmp/data-a" bs=65536 count=1 2>/dev/null
dd if=/dev/urandom of="$tmp/data-b" bs=65536 count=1 2>/dev/null
cp "$tmp/data-a" "$tmp/two-data-blocks.bin"
dd if="$tmp/data-b" of="$tmp/two-data-blocks.bin" bs=65536 seek=1 \
conv=notrunc 2>/dev/null
"$bin" compress --dedup --store --block 65536 --threads 1 --kdf pbkdf2 \
--pass-file "$tmp/password" "$tmp/data.zupt" \
"$tmp/two-data-blocks.bin" >/dev/null 2>&1 ||
fail 'could not create encrypted dedup DATA fixture'
"$bin" test --pass-file "$tmp/password" "$tmp/data.zupt" \
>/dev/null 2>&1 || fail 'clean encrypted dedup DATA fixture is invalid'
python3 "$surgery" swap-frames "$tmp/data.zupt" \
"$tmp/data-swapped.zupt" --kind data --require-encrypted ||
fail 'could not construct DATA swap mutation'
expect_rejected "$tmp/data-swapped.zupt" 'encrypted dedup DATA swap'
python3 "$surgery" replay-frame "$tmp/data.zupt" \
"$tmp/data-replayed.zupt" --kind data --require-encrypted ||
fail 'could not construct DATA replay mutation'
expect_rejected "$tmp/data-replayed.zupt" 'encrypted dedup DATA replay'
# Three duplicate blocks produce one DATA frame followed by at least two REF
# frames with the same logical content and metadata. Swapping or replaying
# those REF frames does not alter reconstructed bytes, so content hashes
# cannot mask a missing REF-position binding.
dd if=/dev/urandom of="$tmp/repeated-block" bs=65536 count=1 2>/dev/null
cp "$tmp/repeated-block" "$tmp/repeated.bin"
dd if="$tmp/repeated-block" of="$tmp/repeated.bin" bs=65536 seek=1 \
conv=notrunc 2>/dev/null
dd if="$tmp/repeated-block" of="$tmp/repeated.bin" bs=65536 seek=2 \
conv=notrunc 2>/dev/null
"$bin" compress --dedup --store --block 65536 --threads 1 --kdf pbkdf2 \
--pass-file "$tmp/password" "$tmp/ref.zupt" "$tmp/repeated.bin" \
>/dev/null 2>&1 || fail 'could not create encrypted dedup REF fixture'
"$bin" test --pass-file "$tmp/password" "$tmp/ref.zupt" \
>/dev/null 2>&1 || fail 'clean encrypted dedup REF fixture is invalid'
python3 "$surgery" swap-frames "$tmp/ref.zupt" \
"$tmp/ref-swapped.zupt" --kind ref --require-encrypted \
--same-metadata || fail 'could not construct same-content REF swap'
expect_rejected "$tmp/ref-swapped.zupt" 'encrypted dedup REF swap'
python3 "$surgery" replay-frame "$tmp/ref.zupt" \
"$tmp/ref-replayed.zupt" --kind ref --require-encrypted \
--same-metadata || fail 'could not construct same-content REF replay'
expect_rejected "$tmp/ref-replayed.zupt" 'encrypted dedup REF replay'
printf 'authenticated dedup reorder/replay: PASS\n'

View file

@ -0,0 +1,49 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
bin=${1:-./zupt}
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-bench-safety.XXXXXXXX")
trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
case $(uname -s 2>/dev/null || printf unknown) in
MINGW*|MSYS*|CYGWIN*)
printf 'SKIP: historical POSIX /tmp symlink benchmark test is not native on Windows\n'
exit 0
;;
esac
printf 'benchmark sentinel must remain unchanged\n' > "$tmp/sentinel"
cp "$tmp/sentinel" "$tmp/sentinel.expected"
# The historical implementation derived this public directory from its PID
# and followed a precreated text.txt symlink. A fresh Bash process has `$$`
# equal to the PID retained by exec, including on macOS Bash 3.2, so the test
# recreates that exact attack without guessing another process.
bash -c '
set -e
old_directory="/tmp/zupt_bench_corpus_$$"
printf "%s\n" "$old_directory" > "$2/old-directory"
mkdir "$old_directory"
ln -s "$2/sentinel" "$old_directory/text.txt"
test -L "$old_directory/text.txt"
exec "$1" bench --compare >/dev/null 2>&1
' zupt-benchmark-test "$bin" "$tmp" || fail 'benchmark comparison failed'
cmp "$tmp/sentinel.expected" "$tmp/sentinel" ||
fail 'benchmark followed the historical predictable temporary symlink'
old_directory=$(sed -n '1p' "$tmp/old-directory")
case $old_directory in
/tmp/zupt_bench_corpus_[0-9]*) ;;
*) fail 'unexpected historical temporary path' ;;
esac
if [[ -d $old_directory ]]; then
mv "$old_directory" "$tmp/historical-remnant"
fi
printf 'private benchmark workspace: PASS\n'

View file

@ -23,7 +23,11 @@
# 3. Verifies extract REJECTS the swapped archive (auth failure)
# 4. Also verifies normal extract still works (regression guard)
ZUPT_BIN="$(realpath ./zupt)"
ZUPT_BIN=${1:-./zupt}
case $ZUPT_BIN in
/*) ;;
*) ZUPT_BIN=$PWD/${ZUPT_BIN#./} ;;
esac
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
@ -135,7 +139,8 @@ if [ $swap_status -eq 0 ]; then
fi
chk "Block-swap attack rejected (cross-file reorder)"
else
echo " ⊘ Block-swap attack test skipped (couldn't locate block boundaries)"
false
chk "Block-swap attack rejected (test archive could not be constructed)"
fi
# P3: Single-block file (boundary case — empty seq_AAD doesn't degenerate)

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
bin=${1:-./zupt}
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
surgery="$repo_root/tests/archive_surgery.py"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-block-type.XXXXXXXX")
trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
dd if=/dev/zero bs=65536 count=3 2>/dev/null | tr '\000' 'T' > "$tmp/input.bin"
"$bin" compress -s -b 65536 -t 2 "$tmp/original.zupt" "$tmp/input.bin" \
>/dev/null 2>&1 || fail 'could not create block-type fixture'
python3 "$surgery" set-frame-type "$tmp/original.zupt" \
"$tmp/comment-frame.zupt" --kind data --type comment ||
fail 'could not change DATA frame type'
if "$bin" test "$tmp/comment-frame.zupt" >/dev/null 2>&1; then
fail 'archive test accepted COMMENT in a DATA range'
fi
for threads in 1 2; do
mkdir "$tmp/out-$threads"
if "$bin" extract -t "$threads" -o "$tmp/out-$threads" \
"$tmp/comment-frame.zupt" >/dev/null 2>&1; then
fail "${threads}-thread extraction accepted COMMENT in a DATA range"
fi
if find "$tmp/out-$threads" -type f -print -quit | grep -q .; then
fail "${threads}-thread extraction published output after type rejection"
fi
done
printf 'archive DATA-frame type enforcement: PASS\n'

View file

@ -22,6 +22,7 @@
*/
#include "vaptvupt.h"
#include "vaptvupt_api.h"
#include "vv_bcj.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
@ -72,11 +73,72 @@ static void fill_elfish(uint8_t *p, size_t n) {
}
}
static uint32_t bcj_prng_state = 0x7a5b3c1du;
static uint32_t bcj_prng(void) {
uint32_t x = bcj_prng_state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
bcj_prng_state = x;
return x;
}
static int test_bcj_bijections(void) {
uint8_t original[4097];
uint8_t transformed[4097];
for (unsigned iteration = 0; iteration < 1024; iteration++) {
size_t n = (size_t)(bcj_prng() % sizeof(original));
uint32_t ip = bcj_prng();
for (size_t i = 0; i < n; i++)
original[i] = (uint8_t)bcj_prng();
/* Force dense branch-like operands in half the corpus so both filters
* exercise their rewrite paths rather than only scanning random data. */
if ((iteration & 1u) != 0) {
for (size_t i = 0; i < n; i++) {
static const uint8_t pattern[] = {
0xe8, 0x00, 0x00, 0x00, 0x00,
0xe9, 0xff, 0xff, 0xff, 0xff,
0x00, 0x00, 0x00, 0x94
};
original[i] = pattern[i % sizeof(pattern)];
}
}
memcpy(transformed, original, n);
(void)vv_bcj_x86(transformed, n, ip, 1);
(void)vv_bcj_x86(transformed, n, ip, 0);
if (memcmp(transformed, original, n) != 0) {
fprintf(stderr, " x86 BCJ bijection failed: iteration=%u size=%zu\n",
iteration, n);
return 1;
}
memcpy(transformed, original, n);
(void)vv_bcj_arm64(transformed, n, ip, 1);
(void)vv_bcj_arm64(transformed, n, ip, 0);
if (memcmp(transformed, original, n) != 0) {
fprintf(stderr, " AArch64 BCJ bijection failed: iteration=%u size=%zu\n",
iteration, n);
return 1;
}
}
printf(" BCJ bijections: 1024 deterministic randomized/adversarial cases passed\n");
return 0;
}
int main(void) {
printf("Codec exact-content_size decode (OOB regression, codec 2.60.4)\n");
srand(424242);
int fail = 0, pass = 0;
if (test_bcj_bijections() != 0)
fail++;
else
pass++;
/* Tail coverage: n mod 32 in {1, 7, 31, 32 (0), >32 leftovers} at
* block-ish sizes, plus tiny buffers. */
static const size_t sizes[] = {

View file

@ -11,7 +11,11 @@
set -u
ARCH=$(uname -m)
SIMD=""
[ "$ARCH" = "x86_64" ] && SIMD="-mavx2"
if [[ $ARCH == x86_64 ]] && grep -qiw avx2 /proc/cpuinfo 2>/dev/null; then
SIMD="-mavx2"
else
echo " SKIP: AVX2-specific subpath unavailable; scalar exact-size test remains enabled"
fi
TMP=$(mktemp -d)
rc=0
@ -31,12 +35,12 @@ fi
# Tool-level BCJ roundtrip: real binary fixture at L5 (BALANCED+auto-filter)
# and L9 (EXTREME+auto-filter); byte-exact extraction required. Guards the
# F-16 defect class (old in-tree BCJ wrote undecodable streams).
FX=/tmp/bench/fixtures/binary.dat
if [ -f "$FX" ] && [ -x ./vaptvupt ]; then
FX=${ZUPT_BIN:-./zupt}
if [ -f "$FX" ] && [ -x ./zupt ]; then
for L in 5 9; do
rm -rf "$TMP/o$L"; mkdir -p "$TMP/o$L"
./vaptvupt c -l $L "$TMP/a$L.zupt" "$FX" >/dev/null 2>&1
./vaptvupt x -o "$TMP/o$L" "$TMP/a$L.zupt" >/dev/null 2>&1
./zupt c -l $L "$TMP/a$L.zupt" "$FX" >/dev/null 2>&1
./zupt x -o "$TMP/o$L" "$TMP/a$L.zupt" >/dev/null 2>&1
F=$(find "$TMP/o$L" -type f | head -1)
if [ -n "$F" ] && diff -q "$F" "$FX" >/dev/null 2>&1; then
echo " ✓ BCJ roundtrip L$L (binary fixture) byte-exact"
@ -45,7 +49,7 @@ if [ -f "$FX" ] && [ -x ./vaptvupt ]; then
fi
done
else
echo " - BCJ tool roundtrip skipped (fixture or binary missing)"
echo " - BCJ tool roundtrip skipped (source-built executable missing)"
fi
rm -rf "$TMP"

View file

@ -1,224 +1,207 @@
#!/bin/bash
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Sprint 2.4.7 regression: shell completions + manpage.
#
# Asserts:
# - completions/vaptvupt.bash has bash-clean syntax
# - completions/_vaptvupt has zsh-clean syntax (if zsh available)
# - completions/vaptvupt.fish has fish-clean syntax (if fish available)
# - Each completion file mentions all the major CLI flags the binary
# actually parses (--kdf, --comment, --pq-sdk, --dedup, ...)
# - doc/zupt.1 mentions current v2.4.x features (--kdf, --comment,
# Argon2id, F-11, comment-file)
# - doc/zupt.1 has the standard sections (NAME, SYNOPSIS, DESCRIPTION,
# COMMANDS, EXAMPLES)
set -u
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
SKIP() { echo " - skipped: $1"; }
set -Eeuo pipefail
cd "$(dirname "$0")/.."
VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
echo "Completions + manpage (vaptvupt $VERSION)"
pass_count=0
fail_count=0
skip_count=0
pass() { pass_count=$((pass_count + 1)); printf ' PASS: %s\n' "$1"; }
fail() { fail_count=$((fail_count + 1)); printf ' FAIL: %s\n' "$1"; }
skip() { skip_count=$((skip_count + 1)); printf ' SKIP: %s\n' "$1"; }
# ─── Bash completion ───
if [ -f completions/vaptvupt.bash ]; then
if bash -n completions/vaptvupt.bash 2>/dev/null; then
P "bash completion: syntax clean"
else
F "bash completion: syntax error"
fi
# Should define a _zupt function and register it via complete -F
if grep -q "^_vaptvupt()" completions/vaptvupt.bash; then
P "bash completion: defines _vaptvupt function"
else
F "bash completion: missing _vaptvupt function"
fi
if grep -qE "^complete -F _vaptvupt (vaptvupt|zupt)" completions/vaptvupt.bash; then
P "bash completion: registers via complete -F"
else
F "bash completion: missing complete -F registration"
fi
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
[[ -n $version ]] || { printf 'FAIL: cannot determine version\n' >&2; exit 1; }
printf 'ZUPT %s completion and manual-page checks\n' "$version"
completion_files=(
completions/zupt.bash
completions/_zupt
completions/zupt.fish
)
for file in "${completion_files[@]}"; do
[[ -f $file ]] && pass "$file exists" || fail "$file is missing"
done
if bash -n completions/zupt.bash; then
pass 'bash completion parses'
else
F "completions/vaptvupt.bash missing"
fail 'bash completion has a syntax error'
fi
# ─── Zsh completion ───
if [ -f completions/_vaptvupt ]; then
if command -v zsh >/dev/null 2>&1; then
if zsh -n completions/_vaptvupt 2>/dev/null; then
P "zsh completion: syntax clean"
else
F "zsh completion: syntax error"
fi
if command -v zsh >/dev/null 2>&1; then
if zsh -n completions/_zupt; then
pass 'zsh completion parses'
else
SKIP "zsh not installed — skipping syntax check"
fi
# Should have #compdef directive
if grep -qE "^#compdef vaptvupt( zupt)?$" completions/_vaptvupt; then
P "zsh completion: has #compdef vaptvupt directive"
else
F "zsh completion: missing #compdef directive"
fail 'zsh completion has a syntax error'
fi
else
F "completions/_vaptvupt missing"
skip 'zsh is unavailable'
fi
# ─── Fish completion ───
if [ -f completions/vaptvupt.fish ]; then
if command -v fish >/dev/null 2>&1; then
if fish -n completions/vaptvupt.fish 2>/dev/null; then
P "fish completion: syntax clean"
else
F "fish completion: syntax error"
fi
if command -v fish >/dev/null 2>&1; then
if fish -n completions/zupt.fish; then
pass 'fish completion parses'
else
SKIP "fish not installed — skipping syntax check"
fi
# Should have complete -c zupt entries
if grep -qE "^complete -c (vaptvupt|zupt)" completions/vaptvupt.fish; then
P "fish completion: has complete -c vaptvupt entries"
else
F "fish completion: no complete -c vaptvupt entries"
fail 'fish completion has a syntax error'
fi
else
F "completions/vaptvupt.fish missing"
skip 'fish is unavailable'
fi
# ─── Flag-coverage check (across all three completion files) ───
# Every flag the binary actually parses should appear in every completion file.
# Each completion format has its own way of writing long options:
# bash: --flag
# zsh: --flag
# fish: -l flag (or --flag in comments)
critical_flags=(kdf comment comment-file pq pq-sdk dedup solid verbose quiet threads level block store fast lzhp vaptvupt)
if grep -qxF 'complete -F _zupt zupt' completions/zupt.bash &&
! grep -Eq '^complete[[:space:]].*[[:space:]]vaptvupt([[:space:]]|$)' completions/zupt.bash; then
pass 'bash registers only zupt'
else
fail 'bash completion is not limited to the primary zupt command'
fi
for f in completions/vaptvupt.bash completions/_vaptvupt; do
[ -f "$f" ] || continue
name=$(basename "$f")
missing=""
for flag in "${critical_flags[@]}"; do
if ! grep -qF -- "--$flag" "$f"; then
missing="$missing --$flag"
if [[ $(sed -n '1p' completions/_zupt) == '#compdef zupt' ]]; then
pass 'zsh registers only zupt'
else
fail 'zsh #compdef is not limited to zupt'
fi
if grep -q '^complete -c zupt' completions/zupt.fish &&
! grep -q '^complete -c vaptvupt\([[:space:]]\|$\)' completions/zupt.fish; then
pass 'fish registers only zupt'
else
fail 'fish completion is not limited to the primary zupt command'
fi
required_flags=(
password-prompt pass-file pass-fd allow-legacy-no-ait kdf comment comment-file
pq pq-only pq-sdk pq-box dedup solid force verbose threads
level block store fast lzhp vaptvupt compare output key pub
sdk box pqonly help version
)
for file in "${completion_files[@]}"; do
missing=()
for flag in "${required_flags[@]}"; do
if ! grep -qF -- "--$flag" "$file" &&
! grep -qE -- "-l[[:space:]]+$flag([[:space:]]|$)" "$file"; then
missing+=("--$flag")
fi
done
if [ -z "$missing" ]; then
P "$name: covers all ${#critical_flags[@]} critical flags"
if ((${#missing[@]} == 0)); then
pass "$file covers current critical flags"
else
F "$name: missing flags:$missing"
fail "$file is missing: ${missing[*]}"
fi
done
if [ -f completions/vaptvupt.fish ]; then
name="vaptvupt.fish"
missing=""
for flag in "${critical_flags[@]}"; do
# fish uses `-l flag-name` for long opts
if ! grep -qE -- "(-l $flag|--$flag)" completions/vaptvupt.fish; then
missing="$missing $flag"
unsupported_flags=(quiet jobs codec keyfile sync no-mtime strip-components block-size)
for file in "${completion_files[@]}"; do
advertised=()
for flag in "${unsupported_flags[@]}"; do
if grep -qF -- "--$flag" "$file" ||
grep -qE -- "-l[[:space:]]+$flag([[:space:]]|$)" "$file"; then
advertised+=("--$flag")
fi
done
if [ -z "$missing" ]; then
P "$name: covers all ${#critical_flags[@]} critical flags (via -l form)"
if ((${#advertised[@]} == 0)); then
pass "$file does not advertise unsupported flags"
else
F "$name: missing flags:$missing"
fail "$file advertises unsupported flags: ${advertised[*]}"
fi
fi
done
# ─── Manpage refresh ───
if [ -f doc/zupt.1 ]; then
# v2.4.x features must be mentioned. Use shell-friendly regexes that
# match groff's `\-\-` escape (literal backslash, dash, backslash, dash).
declare -a manpage_checks=(
"kdf:--kdf option"
"comment:--comment option"
"argon2id:Argon2id KDF"
"Argon2id:Argon2id KDF (capital)"
"verbal probe-oracle:F-11 message change"
"ML-KEM-768:post-quantum KEM"
)
manpage_misses=0
for entry in "${manpage_checks[@]}"; do
key="${entry%%:*}"
desc="${entry#*:}"
if grep -qF "$key" doc/zupt.1; then
:
else
F "manpage: doesn't mention '$desc' (looking for '$key')"
manpage_misses=$((manpage_misses+1))
fi
done
# Two additional checks for groff-escaped hyphens (--comment-file, --pq-sdk
# render as `\-\-comment\-file` and `\-\-pq\-sdk` in the source)
if grep -qE "comment\\\\-file|comment-file" doc/zupt.1; then
:
else
F "manpage: doesn't mention --comment-file (looking for comment\\-file or comment-file)"
manpage_misses=$((manpage_misses+1))
fi
if grep -qE "pq\\\\-sdk|pq-sdk" doc/zupt.1; then
:
else
F "manpage: doesn't mention --pq-sdk (looking for pq\\-sdk or pq-sdk)"
manpage_misses=$((manpage_misses+1))
fi
if [ "$manpage_misses" = 0 ]; then
P "manpage: mentions all v2.4.x features"
fi
# Required sections
for section in NAME SYNOPSIS DESCRIPTION COMMANDS EXAMPLES; do
if grep -qE "^\.SH $section" doc/zupt.1; then
:
else
F "manpage: missing section '.SH $section'"
fi
done
P "manpage: required sections present"
# Version header
if grep -qE "\"(vaptvupt|zupt) $VERSION\"" doc/zupt.1; then
P "manpage: TH version matches include/zupt.h ($VERSION)"
else
F "manpage: TH version doesn't match include/zupt.h"
fi
# Try to render with groff if available
if command -v groff >/dev/null 2>&1; then
if groff -mandoc -Tutf8 doc/zupt.1 > /tmp/render.txt 2>/tmp/groff_warn.txt; then
LINES=$(wc -l < /tmp/render.txt)
if [ "$LINES" -gt 50 ]; then
P "manpage: renders cleanly with groff ($LINES lines)"
else
F "manpage: groff produced suspiciously short output ($LINES lines)"
fi
else
F "manpage: groff rendering failed"
fi
rm -f /tmp/render.txt /tmp/groff_warn.txt
elif command -v mandoc >/dev/null 2>&1; then
if mandoc -Tlint doc/zupt.1 >/tmp/mandoc.out 2>&1; then
P "manpage: mandoc lint clean"
else
P "manpage: mandoc lint had warnings (acceptable)"
fi
rm -f /tmp/mandoc.out
else
SKIP "no groff or mandoc — skipping render lint"
fi
if [[ ! -e doc/vaptvupt.1 && ! -L doc/vaptvupt.1 ]]; then
pass 'former primary man page is absent from the source tree'
else
F "doc/zupt.1 missing"
fail 'doc/vaptvupt.1 remains despite the zupt-only default installation'
fi
echo ""
echo " ───────────────────────────────────────"
echo " completions + manpage: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1
manpage=doc/zupt.1
if [[ ! -f $manpage ]]; then
fail "$manpage is missing"
else
required_sections=(NAME SYNOPSIS DESCRIPTION COMMANDS PASSWORD\ INPUT EXAMPLES EXIT\ STATUS LICENSE)
missing_sections=()
for section in "${required_sections[@]}"; do
grep -qxF ".SH $section" "$manpage" || missing_sections+=("$section")
done
if ((${#missing_sections[@]} == 0)); then
pass 'manpage contains required sections'
else
fail "manpage is missing sections: ${missing_sections[*]}"
fi
if grep -qF "ZUPT $version" "$manpage"; then
pass 'manpage version matches include/zupt.h'
else
fail 'manpage version does not match include/zupt.h'
fi
required_man_flags=(
password-prompt pass-file pass-fd allow-legacy-no-ait kdf comment comment-file
pq pq-only pq-sdk pq-box dedup solid force verbose threads
level block store fast lzhp vaptvupt compare output key pub
sdk box pqonly help version
)
missing=()
for flag in "${required_man_flags[@]}"; do
grep -qF -- "--$flag" "$manpage" || missing+=("--$flag")
done
if ((${#missing[@]} == 0)); then
pass 'manpage documents current critical flags'
else
fail "manpage is missing: ${missing[*]}"
fi
advertised=()
for flag in "${unsupported_flags[@]}"; do
grep -qF -- "--$flag" "$manpage" && advertised+=("--$flag")
done
if ((${#advertised[@]} == 0)); then
pass 'manpage does not document unsupported flags'
else
fail "manpage documents unsupported flags: ${advertised[*]}"
fi
if grep -qF 'Plain archives provide compression checksums' "$manpage" &&
grep -qF 'does not restore ownership' "$manpage" &&
grep -qF 'Automatic codec selection' "$manpage"; then
pass 'manpage states current integrity, metadata, and codec behavior'
else
fail 'manpage is missing current behavioral limits'
fi
if grep -q '^\.B 2$\|^\.B 3$\|^\.B 4$\|^\.B 5$' "$manpage"; then
fail 'manpage advertises exit statuses not emitted by the CLI'
else
pass 'manpage documents only emitted exit statuses'
fi
lint_tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-man-lint.XXXXXXXX")
trap 'rm -rf -- "$lint_tmp"' EXIT HUP INT TERM
if command -v mandoc >/dev/null 2>&1; then
if mandoc -Tlint "$manpage" >"$lint_tmp/mandoc.log" 2>&1; then
pass 'mandoc lint passes'
else
fail 'mandoc lint reports diagnostics'
sed -n '1,10p' "$lint_tmp/mandoc.log"
fi
elif command -v groff >/dev/null 2>&1; then
if groff -mandoc -Tutf8 "$manpage" >"$lint_tmp/rendered" 2>"$lint_tmp/groff.log" &&
[[ ! -s $lint_tmp/groff.log ]] &&
(($(wc -l <"$lint_tmp/rendered") > 50)); then
pass 'groff renders the manpage without diagnostics'
else
fail 'groff manpage rendering failed or emitted diagnostics'
sed -n '1,10p' "$lint_tmp/groff.log"
fi
else
skip 'mandoc and groff are unavailable'
fi
rm -rf -- "$lint_tmp"
trap - EXIT HUP INT TERM
fi
printf '\nSummary: PASS=%d FAIL=%d SKIP=%d\n' "$pass_count" "$fail_count" "$skip_count"
((fail_count == 0))

View file

@ -2,7 +2,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* Constant-time verification of zupt_ct_memeq (v3.5.0) dudect-style.
* Constant-time timing regression measurement for zupt_ct_memeq (v3.5.0).
*
* The MAC-tag comparison is the most timing-sensitive operation in the
* codebase: if "wrong on byte 0" were measurably faster than "wrong on
@ -194,8 +194,8 @@ int main(void) {
if (t_memcmp < CONTROL_STRONG) {
printf(" - control |t|=%.1f below %.0f: host under contention this run;\n", t_memcmp, CONTROL_STRONG);
printf(" control and ct_memeq are in a common noise band, ratio not meaningful\n");
printf(" - INCONCLUSIVE this run (zupt_ct_memeq is OR-accumulate, no branch; rerun on a quiet host)\n");
printf(" Constant-time: 0 passed, 0 failed (inconclusive — measurement env)\n");
printf(" SKIP: timing measurement inconclusive on this host; rerun on a quiet host\n");
printf(" Constant-time timing gate: SKIP (measurement environment)\n");
return 0;
}
printf(" \xE2\x9C\x93 control: memcmp leaks strongly (|t|=%.1f, harness is sensitive)\n", t_memcmp);
@ -204,11 +204,11 @@ int main(void) {
double ratio = t_ct / t_memcmp;
printf(" ratio zupt_ct_memeq/memcmp = %.3f (must be <= %.2f)\n", ratio, MAX_RATIO);
if (ratio <= MAX_RATIO) {
printf(" \xE2\x9C\x93 zupt_ct_memeq shows no data-dependent timing (%.1f%% of leak signal)\n",
printf(" \xE2\x9C\x93 no timing-regression signal observed for zupt_ct_memeq (%.1f%% of control)\n",
ratio * 100.0);
pass++;
} else {
printf(" \xE2\x9C\x97 zupt_ct_memeq timing tracks the data (%.1f%% of leak signal) — NOT constant-time\n",
printf(" \xE2\x9C\x97 zupt_ct_memeq timing tracks the input classes (%.1f%% of control)\n",
ratio * 100.0);
fail++;
}
@ -226,15 +226,13 @@ int main(void) {
* over 1088 bytes is no longer a cleanly-leaking control (its own
* timing is data-dependent in ways unrelated to early-exit). The
* environment-relative ratio that is meaningful at 32 bytes is not
* meaningful here on a shared vCPU. What actually establishes the
* property is: (a) the 32-byte pass/fail check above proves
* zupt_ct_memeq is constant-time, and (b) zupt_ct_memeq is
* length-independent by construction (OR-accumulate, no early exit,
* no data-dependent branch same code path for every byte and every
* length). The decaps compare uses exactly this primitive (verified
* by the source-routing assertion in tests/test_ct_timing.sh), so its
* constant-timeness follows from (a)+(b). We print the 1088B numbers
* for transparency but do not gate on them. */
* meaningful here on a shared vCPU. The 32-byte gate is only regression
* evidence when its control is conclusive; it is not a constant-time
* proof. Source inspection shows an OR-accumulate loop without intended
* data-dependent exit or access, and tests/test_ct_timing.sh confirms that
* decapsulation routes through this primitive. Exact compiled behavior
* remains compiler- and platform-dependent. We print the 1088B numbers for
* transparency but do not gate on them. */
printf("\n -- ML-KEM ciphertext compare (1088 bytes, informational) --\n");
double mc1088_runs[5], ct1088_runs[5];
for (int r = 0; r < 5; r++) {
@ -246,12 +244,12 @@ int main(void) {
printf(" memcmp 1088B: |t| = %8.2f (not a clean control at this size)\n",
mc1088_runs[2]);
printf(" zupt_ct_memeq 1088B: |t| = %8.2f\n", ct1088_runs[2]);
printf(" note: constant-timeness of the 1088B decaps compare follows from the\n");
printf(" 32B pass above + zupt_ct_memeq being length-independent by\n");
printf(" construction; the decaps path uses this exact primitive.\n");
printf(" note: the 1088B result is informational; source routing uses the same\n");
printf(" fixed-length OR-accumulate primitive, but this is not a proof of\n");
printf(" constant-time behavior for the compiled target.\n");
printf("\n ───────────────────────────────────────\n");
printf(" Constant-time: %d passed, %d failed\n", pass, fail);
printf(" Timing regression checks: %d passed, %d failed\n", pass, fail);
printf(" ───────────────────────────────────────\n");
return fail ? 1 : 0;
}

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# dudect-style constant-time verification of zupt_ct_memeq (v3.5.0).
# dudect-style timing regression measurement for zupt_ct_memeq (v3.5.0).
# Builds at -O2 (the shipped optimisation level — so this tests the code
# as users run it, including that the volatile accumulator survives the
# optimiser) and runs the Welch t-test harness.
@ -15,7 +15,6 @@
# passing vacuously.
set -u
SDK_DIR="${ZUPTSDK_DIR:-vendor/zuptsdk}"
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then
SHANI="-msha -mssse3 -msse4.1"
@ -24,19 +23,14 @@ else
fi
TMP=$(mktemp -d)
# This test uses only native CT primitives (zupt_ct_memeq, ML-KEM CT compare);
# the libzuptsdk linkage is vestigial. Link it only when the vendored library
# is present (WITH_SDK builds); source-only builds compile+run without it.
SDK_LINK=""
if ls "$SDK_DIR"/libzuptsdk.so* >/dev/null 2>&1; then
SDK_LINK="-L$SDK_DIR -lzuptsdk -Wl,-rpath,$(cd "$SDK_DIR" && pwd)"
fi
if gcc -Iinclude -Isrc -I"$SDK_DIR/include" -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
# This test uses only native CT primitives; it deliberately has no optional
# SDK linkage so the baseline source build is the exact path under test.
if "${CC:-cc}" -Iinclude -Isrc -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
tests/test_ct_timing.c \
src/zupt_crypto.c src/zupt_sha256.c src/zupt_sha256_shani.c src/zupt_aes256.c \
src/zupt_xxh.c src/zupt_keccak.c src/zupt_x25519.c src/zupt_mlkem.c \
src/zupt_cpuid.c src/zupt_mlock.c \
$SDK_LINK -lm \
-lm \
-o "$TMP/t" 2>"$TMP/cc.log"; then
"$TMP/t"; rc=$?
else
@ -48,8 +42,8 @@ rm -rf "$TMP"
# Source-routing guard: the security-critical compares must use the single
# audited zupt_ct_memeq primitive, not a reintroduced inline byte-OR loop.
# This is what makes the 32-byte timing proof transfer to the ML-KEM
# 1088-byte decaps compare (same function, length-independent).
# This confirms that the measured primitive is also used by the ML-KEM
# 1088-byte decapsulation comparison; it is not a formal timing proof.
echo ""
echo " -- source routing (audited primitive) --"
ROUTE_OK=0

View file

@ -11,11 +11,12 @@
# value per block. This test asserts every encrypted DATA block in a
# dedup-encrypted archive carries a distinct stored nonce.
set -u
ZUPT="${ZUPT_BIN:-./zupt}"
ZUPT=${1:-${ZUPT_BIN:-./zupt}}
echo "Dedup nonce uniqueness (keystream-reuse regression)"
if ! command -v python3 >/dev/null 2>&1; then
echo " - skipped: python3 not available"; exit 0
echo " FAIL: python3 is required for the dedup nonce gate" >&2
exit 1
fi
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT

View file

@ -6,10 +6,16 @@
# (a) compressed output is correct (byte-exact roundtrip) and
# (b) dedup actually saves space when duplicates are present.
ZUPT_BIN="$(realpath ./zupt)"
REPO_ROOT=$(pwd -P)
ZUPT_BIN=${1:-./zupt}
case $ZUPT_BIN in
/*) ;;
*) ZUPT_BIN=$PWD/${ZUPT_BIN#./} ;;
esac
ARCHIVE_SURGERY="$REPO_ROOT/tests/archive_surgery.py"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR" || exit 1
PASS=0; FAIL=0
chk() {
@ -24,24 +30,27 @@ echo " [P1. Dedup roundtrip preserves all bytes]"
mkdir input
for i in $(seq 1 10); do
dd if=/dev/urandom of=input/file_$i.bin bs=4K count=$((RANDOM % 8 + 1)) 2>/dev/null
dd if=/dev/urandom of="input/file_$i.bin" bs=4K \
count=$((RANDOM % 8 + 1)) 2>/dev/null
done
# 5 exact duplicates (same content as file_1..5)
for i in 1 2 3 4 5; do
cp input/file_$i.bin input/dup_$i.bin
cp "input/file_$i.bin" "input/dup_$i.bin"
done
"$ZUPT_BIN" c --dedup test_dedup.zupt input/*.bin > /dev/null 2>&1
chk "Compress with --dedup succeeds"
mkdir extracted && cd extracted
mkdir extracted
cd extracted || exit 1
"$ZUPT_BIN" x ../test_dedup.zupt > /dev/null 2>&1
chk "Extract --dedup archive succeeds"
all_match=1
for i in $(seq 1 10); do
if ! diff -q ../input/file_$i.bin tmp*/input/file_$i.bin > /dev/null 2>&1 \
&& ! diff -q ../input/file_$i.bin input/file_$i.bin > /dev/null 2>&1; then
candidate=$(find . -type f -path "*/input/file_$i.bin" -print -quit)
if [ -z "$candidate" ] ||
! cmp "../input/file_$i.bin" "$candidate" >/dev/null 2>&1; then
all_match=0; break
fi
done
@ -50,13 +59,12 @@ chk "All 10 base files roundtrip byte-exact"
dup_match=1
for i in 1 2 3 4 5; do
found=0
for d in tmp*/input input; do
if [ -f "$d/dup_$i.bin" ] && diff -q ../input/dup_$i.bin "$d/dup_$i.bin" > /dev/null 2>&1; then
found=1; break
fi
done
[ $found -eq 1 ] || { dup_match=0; break; }
candidate=$(find . -type f -path "*/input/dup_$i.bin" -print -quit)
if [ -z "$candidate" ] ||
! cmp "../input/dup_$i.bin" "$candidate" >/dev/null 2>&1; then
dup_match=0
break
fi
done
[ $dup_match -eq 1 ]
chk "All 5 duplicate files roundtrip byte-exact"
@ -68,7 +76,7 @@ echo " [P2. Dedup compresses better than non-dedup on duplicate-heavy data]"
mkdir dups
for i in $(seq 1 20); do
cp input/file_1.bin dups/copy_$i.bin
cp input/file_1.bin "dups/copy_$i.bin"
done
"$ZUPT_BIN" c no_dedup.zupt dups/*.bin > /dev/null 2>&1
@ -87,7 +95,8 @@ chk "Dedup achieves >50% reduction (got $ratio% of original)"
# ─── Property 3: dedup roundtrip preserves data on duplicate-only sets ──
echo " [P3. 100% duplicate file set extracts correctly]"
mkdir extr_dups && cd extr_dups
mkdir extr_dups
cd extr_dups || exit 1
"$ZUPT_BIN" x ../with_dedup.zupt > /dev/null 2>&1
chk "Extract heavy-duplicate archive succeeds"
@ -96,25 +105,28 @@ n_extracted=$(find . -name "copy_*.bin" 2>/dev/null | wc -l)
chk "All 20 duplicate copies extracted (got $n_extracted)"
all_dup_match=1
for f in $(find . -name "copy_*.bin"); do
if ! diff -q "$f" ../input/file_1.bin > /dev/null 2>&1; then
while IFS= read -r f; do
if ! cmp "$f" ../input/file_1.bin >/dev/null 2>&1; then
all_dup_match=0; break
fi
done
done < <(find . -type f -name 'copy_*.bin' -print)
[ $all_dup_match -eq 1 ]
chk "All extracted duplicates byte-exact match the original"
cd ..
# ─── Property 4: dedup + encryption coexist correctly ───────────────────
echo " [P4. Dedup + SDK encryption work together]"
echo " [P4. Dedup + password encryption work together]"
"$ZUPT_BIN" keygen --sdk -o k.priv > /dev/null 2>&1
"$ZUPT_BIN" c --dedup --pq-sdk k.priv.pub enc_dedup.zupt dups/*.bin > /dev/null 2>&1
"$ZUPT_BIN" c --dedup -p dedup-test-password enc_dedup.zupt dups/*.bin > /dev/null 2>&1
chk "Encrypt + dedup compress succeeds"
mkdir extr_enc && cd extr_enc
"$ZUPT_BIN" x --pq-sdk ../k.priv ../enc_dedup.zupt > /dev/null 2>&1
"$ZUPT_BIN" t -p dedup-test-password enc_dedup.zupt > /dev/null 2>&1
chk "Encrypt + dedup archive test succeeds"
mkdir extr_enc
cd extr_enc || exit 1
"$ZUPT_BIN" x -p dedup-test-password ../enc_dedup.zupt > /dev/null 2>&1
chk "Encrypt + dedup extract succeeds"
n=$(find . -name "copy_*.bin" 2>/dev/null | wc -l)
@ -123,6 +135,21 @@ chk "All 20 copies recovered after enc+dedup ($n found)"
cd ..
# The offset inside a new encrypted DEDUP_REF is itself authenticated. A
# payload-only mutation must fail before it can redirect extraction.
if python3 "$ARCHIVE_SURGERY" flip-payload enc_dedup.zupt \
tampered_ref.zupt --kind ref --require-encrypted; then
if "$ZUPT_BIN" t -p dedup-test-password tampered_ref.zupt \
> /dev/null 2>&1; then
false
else
true
fi
else
false
fi
chk "Encrypted dedup reference offset rejects tampering"
echo
echo " ───────────────────────────────────────"
echo " Dedup property results: $PASS passed, $FAIL failed"

View file

@ -0,0 +1,80 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
bin=${1:-./zupt}
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-device-capacity.XXXXXXXX")
loop_device=
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
if [[ -n $loop_device ]]; then
losetup -d "$loop_device" >/dev/null 2>&1 || true
fi
rm -rf -- "$tmp"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
printf 'SKIP: raw-device capacity ioctl tests are POSIX-only\n'
exit 0
;;
esac
dd if=/dev/zero bs=65536 count=2 2>/dev/null | tr '\000' 'C' > "$tmp/source.img"
"$bin" disk backup -s -b 65536 "$tmp/source.zupt" "$tmp/source.img" \
>/dev/null 2>&1 || fail 'could not build device-capacity fixture'
# Character devices without a demonstrable media size must be rejected before
# any write. /dev/null provides an unprivileged regression for that policy.
if [[ -w /dev/null ]]; then
if "$bin" disk restore "$tmp/source.zupt" /dev/null \
>/dev/null 2>"$tmp/unknown-capacity.err"; then
fail 'disk restore accepted a character device of unknown capacity'
fi
grep -Fq 'cannot determine restore device capacity safely' \
"$tmp/unknown-capacity.err" ||
fail 'character-device rejection did not exercise the capacity guard'
printf 'disk device unknown-capacity guard: PASS\n'
else
printf 'SKIP: unknown-capacity character-device test cannot write /dev/null\n'
fi
if [[ $(uname -s) != Linux ]]; then
printf 'SKIP: undersized loop-device test is Linux-specific\n'
exit 0
fi
if [[ $(id -u) -ne 0 || ! -e /dev/loop-control ]] ||
! command -v losetup >/dev/null 2>&1 ||
! losetup --find >/dev/null 2>&1; then
printf 'SKIP: undersized loop-device test needs root and an available loop device\n'
exit 0
fi
dd if=/dev/zero of="$tmp/small-backing.img" bs=65536 count=1 2>/dev/null
cp "$tmp/small-backing.img" "$tmp/small-backing.expected"
loop_device=$(losetup --find --show "$tmp/small-backing.img") || {
loop_device=
printf 'SKIP: could not attach an undersized loop device\n'
exit 0
}
if "$bin" disk restore "$tmp/source.zupt" "$loop_device" \
>/dev/null 2>"$tmp/undersized.err"; then
fail 'disk restore accepted an image larger than the target device'
fi
grep -Fq 'exceeds restore device capacity' "$tmp/undersized.err" ||
fail 'loop-device rejection did not exercise the size guard'
losetup -d "$loop_device"
loop_device=
cmp "$tmp/small-backing.expected" "$tmp/small-backing.img" ||
fail 'undersized restore wrote to the device before rejecting it'
printf 'disk device capacity guard: PASS (undersized device unchanged)\n'

View file

@ -1,159 +1,111 @@
#!/bin/bash
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Sprint 2.4.4 regression test: `make dist` reproducibility.
#
# Asserts that running `make dist` twice on the same source tree
# produces byte-identical tarballs (same sha256, same size). This is
# the foundational property for downstream Debian / AUR / Homebrew
# packaging — without it, distros can't pin a sha256 for the source
# tarball in their recipes.
#
# Also asserts that the dist tarball contains the right things:
# - source code (src/, include/, tests/)
# - the three libzuptsdk symlinks + the real .so file
# - no built binaries (zupt, test_vectors, *.o)
# - no .git/ tree
#
# Exit non-zero on first failure.
set -u
set -Eeuo pipefail
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
export LC_ALL=C
umask 077
# Run from the project root regardless of where the test was invoked.
cd "$(dirname "$0")/.."
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' "$root/include/zupt.h")
[[ -n $version ]] || { printf 'FAIL: cannot determine version\n' >&2; exit 1; }
# 1. First dist build
make dist >/tmp/dist1.log 2>&1
RC=$?
if [ $RC -ne 0 ]; then
echo " ✗ make dist failed on first run; see /tmp/dist1.log"
tail -10 /tmp/dist1.log
exit 1
fi
VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
# v3.0.0: TARGET=vaptvupt, so the tarball is now /tmp/vaptvupt-${VERSION}.tar.gz.
# Test both possible filenames so this works on any future rename.
TARBALL="/tmp/vaptvupt-${VERSION}.tar.gz"
[ ! -f "$TARBALL" ] && TARBALL="/tmp/zupt-${VERSION}.tar.gz"
# Derive top-level dir inside the tarball from the filename
TARBALL_BASE=$(basename "$TARBALL" .tar.gz) # e.g. vaptvupt-3.0.0
if [ ! -f "$TARBALL" ]; then
echo " ✗ expected $TARBALL not produced"
exit 1
fi
P "first make dist produced $TARBALL"
SHA1=$(sha256sum "$TARBALL" | awk '{print $1}')
SIZE1=$(wc -c < "$TARBALL")
cp "$TARBALL" "${TARBALL%.tar.gz}.first.tar.gz"
# 2. Second dist build — should produce byte-identical tarball
make dist >/tmp/dist2.log 2>&1
RC=$?
if [ $RC -ne 0 ]; then
echo " ✗ make dist failed on second run; see /tmp/dist2.log"
tail -10 /tmp/dist2.log
exit 1
fi
SHA2=$(sha256sum "$TARBALL" | awk '{print $1}')
SIZE2=$(wc -c < "$TARBALL")
if [ "$SHA1" = "$SHA2" ]; then
P "byte-identical sha256 across two runs: $SHA1"
else
F "sha256 diverged: $SHA1 vs $SHA2"
fi
if [ "$SIZE1" = "$SIZE2" ]; then
P "byte-identical size: $SIZE1"
else
F "size diverged: $SIZE1 vs $SIZE2"
fi
# 3. Content checks
NUM_FILES=$(tar tzf "$TARBALL" | wc -l)
if [ "$NUM_FILES" -gt 100 ]; then
P "tarball has $NUM_FILES entries (sanity: > 100)"
else
F "tarball suspiciously small: $NUM_FILES entries"
fi
if tar tzf "$TARBALL" | grep -q "${TARBALL_BASE}/src/zupt_format.c"; then
P "src/zupt_format.c present"
else
F "src/zupt_format.c missing"
fi
if tar tzf "$TARBALL" | grep -q "${TARBALL_BASE}/include/zupt.h"; then
P "include/zupt.h present"
else
F "include/zupt.h missing"
fi
# All three libzuptsdk variants
SO_REAL=$(tar tzf "$TARBALL" | grep -c "libzuptsdk.so.2.0.0$")
SO_LINKS=$(tar tzf "$TARBALL" | grep -cE "libzuptsdk.so$|libzuptsdk.so.2$")
if [ "$SO_REAL" = "1" ] && [ "$SO_LINKS" = "2" ]; then
P "libzuptsdk: 1 real .so + 2 symlinks"
else
F "libzuptsdk shipping wrong: real=$SO_REAL links=$SO_LINKS (expected 1 + 2)"
fi
# No built binaries (vaptvupt or legacy zupt symlink or test_* harnesses)
if tar tzf "$TARBALL" | grep -qE "(vaptvupt|zupt)-${VERSION}/(vaptvupt|zupt)(\$|_asan\$)|(vaptvupt|zupt)-${VERSION}/test_vectors\$|(vaptvupt|zupt)-${VERSION}/test_vaptvupt\$"; then
F "tarball contains built binaries"
else
P "tarball contains no built binaries"
fi
# No .o files
if tar tzf "$TARBALL" | grep -qE "\.o$"; then
F "tarball contains stale .o files"
else
P "tarball contains no .o files"
fi
# No .git
if tar tzf "$TARBALL" | grep -q "\.git/"; then
F "tarball contains .git/ tree"
else
P "tarball contains no .git/ tree"
fi
# 4. Build & smoke-test from the dist tarball
WORK=$(mktemp -d)
( cd "$WORK" && tar xzf "$TARBALL" && cd "${TARBALL_BASE}" && make -j"$(nproc)" >/tmp/distbuild.log 2>&1 ) || {
F "build from dist tarball failed; see /tmp/distbuild.log"
rm -rf "$WORK"
[ "$FAIL" = 0 ] || exit 1
}
# v3.0.0: binary may be named `vaptvupt` (default) or legacy `zupt`.
# Pick whichever the dist-tarball build produced.
DISTBIN=""
for cand in vaptvupt zupt; do
if [ -x "$WORK/${TARBALL_BASE}/$cand" ]; then DISTBIN="$WORK/${TARBALL_BASE}/$cand"; break; fi
done
if [ -n "$DISTBIN" ]; then
P "binary builds from dist tarball ($(basename "$DISTBIN"))"
"$DISTBIN" version > /tmp/distver.txt 2>&1
if grep -q "$VERSION" /tmp/distver.txt; then
P "built binary reports correct version ($VERSION)"
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
F "binary version mismatch: $(cat /tmp/distver.txt)"
printf 'FAIL: sha256sum or shasum is required\n' >&2
return 1
fi
else
F "no binary produced from dist build"
}
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-dist-test.XXXXXXXX")
trap 'chmod -R u+rwX "$tmp" 2>/dev/null || true; rm -rf -- "$tmp"' EXIT HUP INT TERM
first=$tmp/zupt-$version.first.tar.gz
second=$tmp/zupt-$version.second.tar.gz
make -C "$root" DIST_TARBALL="$first" dist
make -C "$root" DIST_TARBALL="$second" dist
first_sha=$(sha256_file "$first")
second_sha=$(sha256_file "$second")
[[ $first_sha == "$second_sha" ]] || {
printf 'FAIL: source archive hashes differ: %s %s\n' "$first_sha" "$second_sha" >&2
exit 1
}
cmp -- "$first" "$second"
printf 'PASS: two source archives are byte-identical (%s)\n' "$first_sha"
# Git adds the archived commit ID to a PAX header when given a commit object.
# Exercise the real dist rule in an isolated repository and prove that changing
# only an export-ignored checksum recipe cannot perturb the release tarball.
ignored_repo=$tmp/export-ignored-repo
mkdir -p "$ignored_repo/include" "$ignored_repo/packaging/homebrew" \
"$ignored_repo/sdk"
cp -- "$root/Makefile" "$ignored_repo/Makefile"
cp -- "$root/include/zupt.h" "$ignored_repo/include/zupt.h"
cp -- "$root/sdk/Makefile.sdk" "$ignored_repo/sdk/Makefile.sdk"
printf '/packaging/homebrew/** export-ignore\n' >"$ignored_repo/.gitattributes"
printf '1788134400\n' >"$ignored_repo/.source-date-epoch"
printf '#!/usr/bin/env bash\nexit 0\n' >"$ignored_repo/source-audit.sh"
printf 'normal exported source\n' >"$ignored_repo/source.txt"
printf 'sha256 "REPLACE_AFTER_FINAL_RELEASE_ARCHIVE_IS_BUILT"\n' \
>"$ignored_repo/packaging/homebrew/zupt.rb"
chmod +x "$ignored_repo/source-audit.sh"
git -C "$ignored_repo" init -q
git -C "$ignored_repo" add -- .
git -C "$ignored_repo" -c user.name='ZUPT release test' \
-c user.email='release-test@invalid.example' commit -qm 'initial source'
ignored_first=$tmp/export-ignored.first.tar.gz
ignored_second=$tmp/export-ignored.second.tar.gz
make -C "$ignored_repo" --no-print-directory \
SOURCE_AUDIT=source-audit.sh DIST_TARBALL="$ignored_first" dist
printf 'sha256 "final-release-digest"\n' \
>"$ignored_repo/packaging/homebrew/zupt.rb"
git -C "$ignored_repo" add -- packaging/homebrew/zupt.rb
git -C "$ignored_repo" -c user.name='ZUPT release test' \
-c user.email='release-test@invalid.example' commit -qm 'pin release checksum'
make -C "$ignored_repo" --no-print-directory \
SOURCE_AUDIT=source-audit.sh DIST_TARBALL="$ignored_second" dist
cmp -- "$ignored_first" "$ignored_second" || {
printf 'FAIL: export-ignored-only commit changed source archive bytes\n' >&2
exit 1
}
printf 'PASS: export-ignored-only commit leaves source archive byte-identical (%s)\n' \
"$(sha256_file "$ignored_first")"
bash "$root/scripts/check-source-only.sh" --archive "$first"
members_file=$tmp/archive-members.txt
tar -tzf "$first" >"$members_file"
member_count=$(wc -l <"$members_file")
((member_count > 100)) || { printf 'FAIL: source archive has too few entries\n' >&2; exit 1; }
prefix=zupt-$version/
for required in src/zupt_main.c include/zupt.h Makefile scripts/check-source-only.sh; do
grep -Fxq "$prefix$required" "$members_file" || {
printf 'FAIL: source archive is missing %s\n' "$required" >&2
exit 1
}
done
if grep -Eq '/(\.git|build|dist|out|target)(/|$)' "$members_file"; then
printf 'FAIL: source archive contains an internal/generated directory\n' >&2
exit 1
fi
rm -rf "$WORK"
printf 'PASS: source archive layout and required sources\n'
# Cleanup
rm -f "/tmp/zupt-${VERSION}.first.tar.gz"
echo ""
echo " ───────────────────────────────────────"
echo " dist reproducibility: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1
tar -xzf "$first" -C "$tmp"
tree=$tmp/zupt-$version
bash "$tree/scripts/check-source-only.sh" --tree "$tree"
make -C "$tree" clean
make -C "$tree" -j"${JOBS:-$(getconf _NPROCESSORS_ONLN 2>/dev/null || printf 2)}" \
WITH_SDK=0 WITH_PQBOX=0 V=1
make -C "$tree" WITH_SDK=0 WITH_PQBOX=0 check
bash "$tree/scripts/test-installed-zupt.sh" "$tree/zupt"
make -C "$tree" clean
bash "$tree/scripts/check-source-only.sh" --tree "$tree"
printf 'PASS: clean source archive builds, checks and passes the functional smoke test\n'

View file

@ -1,7 +1,7 @@
/* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
*
* F-06 regression test (Zupt 2.2.5).
* F-06 regression test (ZUPT 2.2.5).
*
* The original combined-diff in zupt_decrypt_buffer was
* uint64_t diff = diff_v2 & diff_v1;

View file

@ -2,36 +2,20 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-08 regression test (Zupt 2.3.0).
# F-08 regression test (VaptVupt 2.3.0).
#
# Two directions:
# 1. v1.5 archive: tamper at each previously-cosmetic header/footer byte
# MUST be detected (top-MAC verifies header+footer[0..23]).
# 2. v1.4 archive (built by Zupt 2.2.5 binary, embedded as a fixture):
# MUST extract cleanly with the legacy-downgrade warning on stderr.
#
# The v1.4 fixture is built at test time IF a 2.2.5 binary is available
# under tests/fixtures/, else direction #2 is skipped with a NOTE.
# A v1.5+ archive is tampered at each previously-cosmetic header/footer byte;
# every mutation MUST be detected (top-MAC verifies header+footer[0..23]).
# Removing the AIT entirely must also fail closed without a compatibility opt-in.
# Legacy v1.4 compatibility needs a reproducible source-generated fixture and
# is reported as skipped until one is available; compiled fixtures are banned.
set -u
set -Eeuo pipefail
PASS=0
FAIL=0
ZUPT="${ZUPT_BIN:-./zupt}"
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
fi
rm -rf "$_sdkck"
# Resolve to absolute path so the test continues to find the binary after cd.
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
ROOT="$PWD"
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
ZUPT=${ZUPT_BIN:-$repo_root/zupt}
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
@ -40,16 +24,75 @@ if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo ' ✗ python3 is required for structural archive mutations' >&2
exit 1
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
echo " [Direction 1: v1.5 archive detects header+footer tamper]"
echo " [AIT removal is rejected by default]"
printf 'data\n' > input.txt
printf 'source-only-test-password\n' > password.txt
chmod 600 password.txt
if "$ZUPT" c --kdf pbkdf2 --pass-file password.txt \
password.zupt input.txt >/dev/null 2>&1 &&
"$ZUPT" t --pass-file password.txt password.zupt >/dev/null 2>&1; then
P "clean password archive passes authentication"
else
F "clean password archive could not be authenticated"
fi
if python3 "$repo_root/tests/archive_surgery.py" strip-ait \
password.zupt stripped-ait.zupt; then
if "$ZUPT" t --pass-file password.txt stripped-ait.zupt \
>/dev/null 2>&1; then
F "archive with its AIT removed was accepted by default"
else
P "archive with its AIT removed is rejected by default"
fi
if "$ZUPT" list --pass-file password.txt stripped-ait.zupt \
>/dev/null 2>&1; then
F "list accepted an archive with its AIT removed"
else
P "list rejects an archive with its AIT removed"
fi
mkdir stripped-output
printf 'existing extraction target\n' > stripped-output/sentinel
cp stripped-output/sentinel stripped-output.expected
if "$ZUPT" extract --pass-file password.txt -o stripped-output \
stripped-ait.zupt >/dev/null 2>&1; then
F "extract accepted an archive with its AIT removed"
elif cmp stripped-output.expected stripped-output/sentinel >/dev/null 2>&1 &&
[ ! -e stripped-output/input.txt ]; then
P "AIT-removal rejection preserves the extraction destination"
else
F "AIT-removal rejection changed the extraction destination"
fi
else
F "could not construct archive with a structurally removed AIT"
fi
version=$("$ZUPT" --version 2>&1)
if ! grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
echo ' SKIP: exhaustive SDK top-MAC sweep needs WITH_SDK=1 and system libvuptsdk'
echo
echo " ───────────────────────────────────────"
echo " F-08 regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1
exit 0
fi
echo " [v1.5+ archive detects header+footer tamper]"
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
echo "data" > input.txt
"$ZUPT" c --pq-sdk k.priv.pub a.zupt input.txt >/dev/null 2>&1
SZ=$(wc -c < a.zupt)
@ -85,8 +128,7 @@ b=bytearray(open('t.zupt','rb').read())
b[$POS] ^= 1
open('t.zupt','wb').write(bytes(b))"
rm -rf out && mkdir out
( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1 )
if [ -f out/input.txt ]; then
if (cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1); then
ALL_DETECTED=0
echo " silent-accepted tamper at byte $POS"
fi
@ -109,7 +151,7 @@ b=bytearray(open('t.zupt','rb').read())
b[20] ^= 1 # archive_id byte
open('t.zupt','wb').write(bytes(b))"
rm -rf out && mkdir out
ERR=$( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt 2>&1 || true )
ERR=$( (cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt) 2>&1 || true )
if echo "$ERR" | grep -qE "Authentication failed|top-MAC"; then
P "tamper produces a clear auth/integrity error"
else
@ -118,7 +160,7 @@ fi
# Verbose mode: top-MAC wording must still surface for debugging
rm -rf out && mkdir out
ERR_V=$( cd out && "$ZUPT" x --verbose --pq-sdk ../k.priv ../t.zupt 2>&1 || true )
ERR_V=$( (cd out && "$ZUPT" x --verbose --pq-sdk ../k.priv ../t.zupt) 2>&1 || true )
if echo "$ERR_V" | grep -q "top-MAC"; then
P "tamper with --verbose surfaces top-MAC detail"
else
@ -126,35 +168,8 @@ else
fi
echo ""
echo " [Direction 2: v1.4 backward-compat]"
FIXTURE_BIN="$ROOT/tests/fixtures/zupt-2.2.5"
if [ -x "$FIXTURE_BIN" ]; then
# Build v1.4 archive using the 2.2.5 binary.
"$FIXTURE_BIN" keygen --sdk -o k14.priv >/dev/null 2>&1
"$FIXTURE_BIN" c --pq-sdk k14.priv.pub a14.zupt input.txt >/dev/null 2>&1
# v2.3.0 info should say v1.4 / no top-MAC.
INFO14=$("$ZUPT" info a14.zupt 2>&1)
if echo "$INFO14" | grep -q "Format: *v1.4" && echo "$INFO14" | grep -q "Top-MAC: *no"; then
P "v1.4 archive reported as v1.4 / no top-MAC"
else
F "v1.4 info report wrong"
fi
# v2.3.0 extract should succeed with warning.
mkdir out14
OUT=$( cd out14 && "$ZUPT" x --pq-sdk ../k14.priv ../a14.zupt 2>&1 )
if [ -f out14/input.txt ] && echo "$OUT" | grep -qi "legacy v1.4 archive"; then
P "v1.4 archive extracts with legacy warning"
else
F "v1.4 backward-compat broken: $OUT"
fi
else
echo " NOTE: tests/fixtures/zupt-2.2.5 not present — direction 2 skipped"
echo " (build it once with: cd tests/fixtures && tar xzf zupt-2.2.5.tar.gz"
echo " && cd zupt-2.2.5 && make && cp zupt ../zupt-2.2.5)"
fi
echo " SKIP: v1.4 compatibility needs a reproducible source-generated fixture"
echo " (compiled historical fixtures are not permitted in this repository)"
echo ""
echo " ───────────────────────────────────────"

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-09 regression test (Zupt 2.3.1).
# F-09 regression test (VaptVupt 2.3.1).
#
# F-09 closed the per-block frame preface tamper window by:
# 1. Binding the canonical preface (block_type, codec_id, block_flags,
@ -12,18 +12,20 @@
# block's frame preface in read_enc_header (same pattern as F-07
# for the index block in v2.2.5).
#
# This test does the full exhaustive byte sweep on a small v1.6 PQ-SDK
# archive: every byte from 0 to N-1 is flipped one at a time, and we
# assert the extract fails for ALL of them. With pre-F-09 code this
# would show 15-18 silent acceptances; post-F-09 it must show zero.
# This test flips every serialized block-preface byte in a small v1.6 PBKDF2
# archive and asserts that each mutation is rejected. With pre-F-09 code this
# would show silent acceptances; post-F-09 it must show zero. A PQ-SDK archive
# receives the historical full-archive byte sweep when system libvuptsdk is
# enabled.
#
# Why limit to PQ-SDK encrypted: plaintext archives have no HMAC at
# all (XXH64 best-effort only), so per-byte coverage is intentionally
# weaker and a different, separately-tracked promise.
# Plaintext archives have no HMAC (XXH64 best-effort only), so per-byte
# coverage is intentionally weaker and a different, separately-tracked
# promise.
set -u
set -Eeuo pipefail
ZUPT="${ZUPT_BIN:-./zupt}"
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
ZUPT="${ZUPT_BIN:-$repo_root/zupt}"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
@ -33,58 +35,129 @@ if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
echo ' ✗ python3 is required for byte-level archive mutations' >&2
exit 1
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
cd "$TMPDIR" || exit 1
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
echo "F-09 regression test payload" > input.txt
"$ZUPT" c --pq-sdk k.priv.pub a.zupt input.txt >/dev/null 2>&1
printf 'F-09 regression test payload\n' > input.txt
printf 'source-only-preface-password\n' > password.txt
chmod 600 password.txt
SZ=$(wc -c < a.zupt)
if [ "$SZ" -lt 100 ] || [ "$SZ" -gt 10000 ]; then
echo " ✗ unexpected archive size $SZ" >&2
exit 1
fi
run_sweep() {
local label=$1
local archive=$2
local scope=$3
shift 3
local -a auth_options=("$@")
local -a positions=()
local size
local position
local positions_output
local tested=0
local undetected_positions=''
local undetected_count
local clean_dir="clean-$label"
# Sanity: clean archive extracts.
mkdir -p clean
( cd clean && "$ZUPT" x --pq-sdk ../k.priv ../a.zupt >/dev/null 2>&1 )
if [ ! -f clean/input.txt ]; then
echo " ✗ clean v1.6 PQ-SDK archive doesn't extract" >&2
exit 1
fi
# Exhaustive sweep.
echo " [F-09: exhaustive byte sweep of $SZ-byte v1.6 PQ-SDK archive]"
UNDETECTED_POSITIONS=""
TAMPER_SAMPLED=0
for POS in $(seq 0 $((SZ - 1))); do
cp a.zupt t.zupt
python3 -c "
b=bytearray(open('t.zupt','rb').read())
b[$POS] ^= 1
open('t.zupt','wb').write(bytes(b))"
rm -rf out && mkdir out
( cd out && "$ZUPT" x --pq-sdk ../k.priv ../t.zupt >/dev/null 2>&1 )
TAMPER_SAMPLED=$((TAMPER_SAMPLED + 1))
if [ -f out/input.txt ]; then
UNDETECTED_POSITIONS="$UNDETECTED_POSITIONS $POS"
size=$(wc -c < "$archive")
if [ "$size" -lt 100 ] || [ "$size" -gt 10000 ]; then
echo "$label archive has unexpected size $size" >&2
return 1
fi
done
UNDETECTED_COUNT=$(echo $UNDETECTED_POSITIONS | wc -w)
mkdir "$clean_dir"
if ! "$ZUPT" extract "${auth_options[@]}" -o "$clean_dir" "$archive" \
>/dev/null 2>&1 ||
! cmp input.txt "$clean_dir/input.txt" >/dev/null 2>&1; then
echo " ✗ clean $label archive does not extract byte-exact" >&2
return 1
fi
echo ""
echo " ───────────────────────────────────────"
if [ "$UNDETECTED_COUNT" = 0 ]; then
echo " F-09 regression: $TAMPER_SAMPLED tamper positions tested, 0 silent-accepted ✓"
echo " ───────────────────────────────────────"
exit 0
else
echo " F-09 regression: $UNDETECTED_COUNT silent-accepted positions (must be 0)"
echo " positions:$UNDETECTED_POSITIONS"
echo " ───────────────────────────────────────"
if [ "$scope" = preface ]; then
if ! positions_output=$(python3 \
"$repo_root/tests/archive_surgery.py" preface-positions \
"$archive") || [ -z "$positions_output" ]; then
echo " ✗ could not locate $label block prefaces" >&2
return 1
fi
while IFS= read -r position; do
[ -n "$position" ] && positions+=("$position")
done <<<"$positions_output"
echo " [F-09: all block-preface bytes in $size-byte $label archive]"
elif [ "$scope" = full ]; then
for ((position = 0; position < size; position++)); do
positions+=("$position")
done
echo " [F-09: exhaustive byte sweep of $size-byte $label archive]"
else
echo " ✗ internal error: unknown sweep scope $scope" >&2
return 1
fi
for position in "${positions[@]}"; do
if ! python3 - "$archive" t.zupt "$position" <<'PY'
import pathlib
import sys
source = pathlib.Path(sys.argv[1]).read_bytes()
mutated = bytearray(source)
mutated[int(sys.argv[3])] ^= 0x01
pathlib.Path(sys.argv[2]).write_bytes(mutated)
PY
then
echo " ✗ could not mutate $label archive byte $position" >&2
return 1
fi
tested=$((tested + 1))
if "$ZUPT" test "${auth_options[@]}" t.zupt >/dev/null 2>&1; then
undetected_positions="$undetected_positions $position"
fi
done
undetected_count=$(printf '%s\n' "$undetected_positions" | wc -w)
if [ "$undetected_count" -ne 0 ]; then
echo "$label: $undetected_count silent-accepted positions (must be 0)"
echo " positions:$undetected_positions"
return 1
fi
echo "$label: $tested tamper positions tested, 0 accepted"
}
FAIL=0
if ! "$ZUPT" compress --store --kdf pbkdf2 --pass-file password.txt \
pbkdf2.zupt input.txt >/dev/null 2>&1; then
echo ' ✗ could not create source-only PBKDF2 archive' >&2
exit 1
fi
if ! run_sweep PBKDF2 pbkdf2.zupt preface --pass-file password.txt; then
FAIL=$((FAIL + 1))
fi
version=$("$ZUPT" --version 2>&1)
if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
if ! "$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1 ||
! "$ZUPT" compress --store --pq-sdk k.priv.pub pq-sdk.zupt \
input.txt >/dev/null 2>&1; then
echo ' ✗ could not create PQ-SDK archive' >&2
FAIL=$((FAIL + 1))
elif ! run_sweep PQ-SDK pq-sdk.zupt full --pq-sdk k.priv; then
FAIL=$((FAIL + 1))
fi
else
echo ' SKIP: additional PQ-SDK sweep needs WITH_SDK=1 and system libvuptsdk'
fi
echo
echo " ───────────────────────────────────────"
if [ "$FAIL" -eq 0 ]; then
echo " F-09 regression: PASS"
else
echo " F-09 regression: FAIL ($FAIL archive variants)"
fi
echo " ───────────────────────────────────────"
[ "$FAIL" -eq 0 ]

View file

@ -1,148 +1,143 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-10 regression test (Zupt 2.4.1).
#
# F-10: default password-mode KDF flipped from PBKDF2-SHA256 to Argon2id.
# PBKDF2 remains available via --kdf pbkdf2 for compatibility with
# v2.4.0-and-older readers.
#
# Three assertions:
# 1. `zupt c -p PW out.zupt input` writes an enc-header with type byte
# 0x04 (ZUPT_ENC_PW_ARGON2), and the stderr message says Argon2id.
# 2. `zupt c -p PW --kdf pbkdf2 out.zupt input` writes type byte 0x01
# (ZUPT_ENC_PBKDF2), and the stderr message says PBKDF2.
# 3. Both archive types roundtrip byte-exact via `zupt x -p PW`.
# 4. Wrong password is rejected for both archive types.
# F-10: KDF defaults must reflect whether system libvuptsdk is enabled.
set -u
set -Eeuo pipefail
ZUPT="${ZUPT_BIN:-./zupt}"
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
fi
rm -rf "$_sdkck"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
zupt=${ZUPT_BIN:-$repo_root/zupt}
if [[ ! -x $zupt ]]; then
printf ' FAIL: %s not found; build ZUPT first\n' "$zupt" >&2
exit 1
fi
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
version=$("$zupt" --version 2>&1)
sdk_enabled=0
if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
sdk_enabled=1
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
passed=0
failed=0
pass() { printf ' ✓ %s\n' "$1"; passed=$((passed + 1)); }
fail() { printf ' ✗ %s\n' "$1"; failed=$((failed + 1)); }
echo "F-10 regression: password-mode KDF default"
tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT
cd "$tmpdir"
# Helper: read the enc_type byte (payload[0] of the enc-header block).
enc_type_of() {
python3 -c "
python3 - "$1" <<'PY'
from pathlib import Path
import sys
b = open('$1','rb').read()
off = int.from_bytes(b[36:44],'little')
def vread(buf,o):
v=0;s=0
data = Path(sys.argv[1]).read_bytes()
offset = int.from_bytes(data[36:44], "little")
def read_varint(buf, pos):
value = 0
shift = 0
while True:
x=buf[o]; o+=1; v|=(x&0x7f)<<s
if not (x&0x80): break
s+=7
return v,o
v1,p = vread(b, off+7); v2,p2 = vread(b, p)
print(f'{b[p2+8]:02x}')
"
byte = buf[pos]
pos += 1
value |= (byte & 0x7f) << shift
if not byte & 0x80:
return value, pos
shift += 7
_, pos = read_varint(data, offset + 7)
_, pos = read_varint(data, pos)
print(f"{data[pos + 8]:02x}")
PY
}
echo "secret payload for KDF test" > input.txt
echo 'F-10 regression: password-mode KDF default'
printf 'secret payload for KDF test\n' >input.txt
# 1. Default → Argon2id (0x04)
STDERR_DEFAULT=$("$ZUPT" c -p secret default.zupt input.txt 2>&1)
ETYPE=$(enc_type_of default.zupt)
if [ "$ETYPE" = "04" ]; then
P "default: enc_type = 0x04 (ZUPT_ENC_PW_ARGON2)"
default_stderr=$("$zupt" c -p secret default.zupt input.txt 2>&1)
default_type=$(enc_type_of default.zupt)
if ((sdk_enabled)); then
if [[ $default_type == 04 ]]; then
pass 'WITH_SDK=1 default uses Argon2id (enc_type 0x04)'
else
fail "WITH_SDK=1 default enc_type is 0x$default_type, expected 0x04"
fi
if grep -qi 'Argon2id' <<<"$default_stderr"; then
pass 'default message names Argon2id'
else
fail 'default message does not name Argon2id'
fi
else
F "default: enc_type = 0x$ETYPE (expected 0x04)"
fi
if echo "$STDERR_DEFAULT" | grep -qi "Argon2id"; then
P "default: stderr message names Argon2id"
else
F "default: stderr message doesn't name Argon2id"
if [[ $default_type == 01 ]]; then
pass 'source-only default uses PBKDF2 (enc_type 0x01)'
else
fail "source-only default enc_type is 0x$default_type, expected 0x01"
fi
if grep -qi 'PBKDF2' <<<"$default_stderr"; then
pass 'source-only default message names PBKDF2'
else
fail 'source-only default message does not name PBKDF2'
fi
echo ' SKIP: Argon2id default/explicit coverage needs system libvuptsdk (WITH_SDK=1)'
fi
# 2. --kdf pbkdf2 → PBKDF2 (0x01)
STDERR_PB=$("$ZUPT" c -p secret --kdf pbkdf2 legacy.zupt input.txt 2>&1)
ETYPE2=$(enc_type_of legacy.zupt)
if [ "$ETYPE2" = "01" ]; then
P "--kdf pbkdf2: enc_type = 0x01 (ZUPT_ENC_PBKDF2)"
mkdir default-out
if (cd default-out && "$zupt" x -p secret ../default.zupt >/dev/null 2>&1) &&
cmp -s input.txt default-out/input.txt; then
pass 'default-KDF archive roundtrips byte-exact'
else
F "--kdf pbkdf2: enc_type = 0x$ETYPE2 (expected 0x01)"
fail 'default-KDF archive roundtrips byte-exact'
fi
if echo "$STDERR_PB" | grep -qi "PBKDF2"; then
P "--kdf pbkdf2: stderr message names PBKDF2"
mkdir default-wrong
if (cd default-wrong && "$zupt" x -p wrong ../default.zupt >/dev/null 2>&1); then
fail 'default-KDF archive rejects a wrong password'
else
F "--kdf pbkdf2: stderr message doesn't name PBKDF2"
pass 'default-KDF archive rejects a wrong password'
fi
# 3. Roundtrips
mkdir out_a && (cd out_a && "$ZUPT" x -p secret ../default.zupt >/dev/null 2>&1)
if [ -f out_a/input.txt ] && diff -q input.txt out_a/input.txt >/dev/null 2>&1; then
P "Argon2id archive roundtrips byte-exact"
pbkdf_stderr=$("$zupt" c -p secret --kdf pbkdf2 pbkdf.zupt input.txt 2>&1)
pbkdf_type=$(enc_type_of pbkdf.zupt)
if [[ $pbkdf_type == 01 ]]; then
pass '--kdf pbkdf2 uses enc_type 0x01'
else
F "Argon2id roundtrip"
fail "--kdf pbkdf2 enc_type is 0x$pbkdf_type, expected 0x01"
fi
if grep -qi 'PBKDF2' <<<"$pbkdf_stderr"; then
pass '--kdf pbkdf2 message names PBKDF2'
else
fail '--kdf pbkdf2 message does not name PBKDF2'
fi
mkdir out_p && (cd out_p && "$ZUPT" x -p secret ../legacy.zupt >/dev/null 2>&1)
if [ -f out_p/input.txt ] && diff -q input.txt out_p/input.txt >/dev/null 2>&1; then
P "PBKDF2 archive roundtrips byte-exact"
mkdir pbkdf-out
if (cd pbkdf-out && "$zupt" x -p secret ../pbkdf.zupt >/dev/null 2>&1) &&
cmp -s input.txt pbkdf-out/input.txt; then
pass 'PBKDF2 archive roundtrips byte-exact'
else
F "PBKDF2 roundtrip"
fail 'PBKDF2 archive roundtrips byte-exact'
fi
mkdir pbkdf-wrong
if (cd pbkdf-wrong && "$zupt" x -p wrong ../pbkdf.zupt >/dev/null 2>&1); then
fail 'PBKDF2 archive rejects a wrong password'
else
pass 'PBKDF2 archive rejects a wrong password'
fi
# 4. Wrong password rejected (both)
mkdir out_wa && (cd out_wa && "$ZUPT" x -p wrong ../default.zupt >/dev/null 2>&1)
if [ ! -f out_wa/input.txt ]; then
P "Argon2id: wrong password rejected"
else
F "Argon2id: wrong password accepted"
fi
mkdir out_wp && (cd out_wp && "$ZUPT" x -p wrong ../legacy.zupt >/dev/null 2>&1)
if [ ! -f out_wp/input.txt ]; then
P "PBKDF2: wrong password rejected"
else
F "PBKDF2: wrong password accepted"
if ((sdk_enabled)); then
"$zupt" c -p secret --kdf argon2id explicit.zupt input.txt >/dev/null 2>&1
explicit_type=$(enc_type_of explicit.zupt)
if [[ $explicit_type == 04 ]]; then
pass '--kdf argon2id uses enc_type 0x04'
else
fail "--kdf argon2id enc_type is 0x$explicit_type, expected 0x04"
fi
fi
# 5. --kdf argon2id (explicit form) → same as default
STDERR_E=$("$ZUPT" c -p secret --kdf argon2id explicit.zupt input.txt 2>&1)
ETYPE3=$(enc_type_of explicit.zupt)
if [ "$ETYPE3" = "04" ]; then
P "--kdf argon2id (explicit): enc_type = 0x04"
if "$zupt" c -p secret --kdf invalid invalid.zupt input.txt >/dev/null 2>&1; then
fail 'unknown --kdf value is rejected'
else
F "--kdf argon2id (explicit): enc_type = 0x$ETYPE3"
pass 'unknown --kdf value is rejected'
fi
# 6. --kdf garbage → reject
if "$ZUPT" c -p secret --kdf garbage garbage.zupt input.txt >/dev/null 2>&1; then
F "--kdf garbage was accepted (should reject)"
else
P "--kdf garbage rejected"
fi
echo ""
echo " ───────────────────────────────────────"
echo " F-10 regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1
printf '\n F-10 regression: %d passed, %d failed\n' "$passed" "$failed"
((failed == 0))

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-11 regression test (Zupt 2.4.2).
# F-11 regression test (VaptVupt 2.4.2).
#
# F-11: pre-2.4.2 the AIT-fail message said "archive header or footer has
# been tampered with" in both the actual-tamper case AND the wrong-password
@ -16,32 +16,39 @@
# message for both cases eliminates a verbal probe-oracle. Plaintext-mode
# tamper detection (no key involvement) keeps detailed wording.
set -u
set -Eeuo pipefail
ZUPT="${ZUPT_BIN:-./zupt}"
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
fi
rm -rf "$_sdkck"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
ZUPT=${ZUPT_BIN:-$repo_root/zupt}
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
version=$("$ZUPT" --version 2>&1)
SDK_ENABLED=0
if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
SDK_ENABLED=1
fi
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
capture_expected_failure() {
local output_name=$1 label=$2 directory=$3 output status
shift 3
set +e
output=$(cd "$directory" && "$@" 2>&1)
status=$?
set -e
if ((status == 0)); then
F "$label returned success"
fi
printf -v "$output_name" '%s' "$output"
}
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
cd "$TMPDIR"
@ -50,46 +57,49 @@ echo "F-11 regression: error-message hygiene"
echo "F-11 payload" > input.txt
# Test 1: wrong-password message on Argon2id default (no --verbose)
# Test 1: wrong-password message on the build's default KDF (no --verbose)
"$ZUPT" c -p correct argon.zupt input.txt >/dev/null 2>&1
mkdir out1
ERR=$( (cd out1 && "$ZUPT" x -p wrong ../argon.zupt) 2>&1 || true )
capture_expected_failure ERR 'default KDF wrong-pw' out1 \
"$ZUPT" x -p wrong ../argon.zupt
if echo "$ERR" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "Argon2id wrong-pw default: generic auth-fail message"
P "default KDF wrong-pw: generic auth-fail message"
else
F "Argon2id wrong-pw default: message wrong: '$ERR'"
F "default KDF wrong-pw: message wrong: '$ERR'"
fi
# Must NOT contain the standalone "header or footer has been tampered with"
if ! echo "$ERR" | grep -q "header or footer has been tampered with"; then
P "Argon2id wrong-pw default: no standalone tamper claim"
P "default KDF wrong-pw: no standalone tamper claim"
else
F "Argon2id wrong-pw default: still claims archive tampered"
F "default KDF wrong-pw: still claims archive tampered"
fi
# Must NOT contain the verbose top-MAC line
if ! echo "$ERR" | grep -q "archive-integrity-trailer (top-MAC)"; then
P "Argon2id wrong-pw default: no top-MAC technical detail"
P "default KDF wrong-pw: no top-MAC technical detail"
else
F "Argon2id wrong-pw default: top-MAC leaked without --verbose"
F "default KDF wrong-pw: top-MAC leaked without --verbose"
fi
# Test 2: --verbose surfaces the technical detail
mkdir out2
ERR_V=$( (cd out2 && "$ZUPT" x -p wrong --verbose ../argon.zupt) 2>&1 || true )
capture_expected_failure ERR_V 'default KDF wrong-pw --verbose' out2 \
"$ZUPT" x -p wrong --verbose ../argon.zupt
if echo "$ERR_V" | grep -q "top-MAC"; then
P "Argon2id wrong-pw --verbose: top-MAC detail shown"
P "default KDF wrong-pw --verbose: top-MAC detail shown"
else
F "Argon2id wrong-pw --verbose: top-MAC missing"
F "default KDF wrong-pw --verbose: top-MAC missing"
fi
if echo "$ERR_V" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "Argon2id wrong-pw --verbose: still has the generic line"
P "default KDF wrong-pw --verbose: still has the generic line"
else
F "Argon2id wrong-pw --verbose: missing generic line"
F "default KDF wrong-pw --verbose: missing generic line"
fi
# Test 3: PBKDF2 archive same behaviour
"$ZUPT" c -p correct --kdf pbkdf2 pbkdf.zupt input.txt >/dev/null 2>&1
mkdir out3
ERR3=$( (cd out3 && "$ZUPT" x -p wrong ../pbkdf.zupt) 2>&1 || true )
capture_expected_failure ERR3 'PBKDF2 wrong-pw' out3 \
"$ZUPT" x -p wrong ../pbkdf.zupt
if echo "$ERR3" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "PBKDF2 wrong-pw default: generic auth-fail message"
else
@ -104,7 +114,8 @@ b = bytearray(open('tampered.zupt','rb').read())
b[15] ^= 1 # creation_time byte
open('tampered.zupt','wb').write(bytes(b))"
mkdir out4
ERR4=$( (cd out4 && "$ZUPT" x -p correct ../tampered.zupt) 2>&1 || true )
capture_expected_failure ERR4 'encrypted header tamper' out4 \
"$ZUPT" x -p correct ../tampered.zupt
if echo "$ERR4" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "Actual tamper (encrypted): same generic message — no verbal oracle"
else
@ -125,7 +136,8 @@ b = bytearray(open('ptamp.zupt','rb').read())
b[10] ^= 1
open('ptamp.zupt','wb').write(bytes(b))"
mkdir out5
ERR5=$( (cd out5 && "$ZUPT" x ../ptamp.zupt) 2>&1 || true )
capture_expected_failure ERR5 'plaintext header tamper' out5 \
"$ZUPT" x ../ptamp.zupt
if echo "$ERR5" | grep -q "corrupted or tampered"; then
P "Plaintext tamper: detailed XXH64-failure message kept"
else
@ -146,16 +158,21 @@ else
F "Correct password: regression — extract broken"
fi
# Test 7: PQ-SDK wrong key triggers the same generic message
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
"$ZUPT" keygen --sdk -o other.priv >/dev/null 2>&1
"$ZUPT" c --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1
mkdir out7
ERR7=$( (cd out7 && "$ZUPT" x --pq-sdk ../other.priv ../pq.zupt) 2>&1 || true )
if echo "$ERR7" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "PQ-SDK wrong-key: generic auth-fail message"
# Test 7: when enabled, PQ-SDK wrong key triggers the same generic message.
if ((SDK_ENABLED)); then
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
"$ZUPT" keygen --sdk -o other.priv >/dev/null 2>&1
"$ZUPT" c --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1
mkdir out7
capture_expected_failure ERR7 'PQ-SDK wrong key' out7 \
"$ZUPT" x --pq-sdk ../other.priv ../pq.zupt
if echo "$ERR7" | grep -q "Authentication failed (wrong key, wrong password, or tampered archive)"; then
P "PQ-SDK wrong-key: generic auth-fail message"
else
F "PQ-SDK wrong-key: didn't get generic message: '$ERR7'"
fi
else
F "PQ-SDK wrong-key: didn't get generic message: '$ERR7'"
echo ' SKIP: PQ-SDK wrong-key message needs system libvuptsdk (WITH_SDK=1)'
fi
echo ""

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-12 regression test (Zupt 2.4.3).
# F-12 regression test (ZUPT 2.4.3, hardened in 5.2.2).
#
# F-12: implement the reserved `comment_offset` field in zupt_archive_header_t.
# Adds ZUPT_BLOCK_COMMENT (0x05) block type written between data blocks and
@ -13,7 +13,7 @@
#
# Assertions:
# 1. Roundtrip the comment text in plaintext mode.
# 2. Roundtrip the comment text in Argon2id-password mode.
# 2. Roundtrip the comment text in the build's default password mode.
# 3. Roundtrip the comment text in PBKDF2-password mode.
# 4. Roundtrip the comment text in PQ-SDK mode.
# 5. `zupt info` reports the presence of a comment without revealing it
@ -23,26 +23,21 @@
# 8. An archive without a comment shows no Comment: line in info.
# 9. --comment-file path reads the comment from disk.
# 10. Empty comment string is treated as no-comment (header offset stays 0).
# 11. Terminal control bytes are escaped when a comment is displayed.
set -u
set -Eeuo pipefail
ZUPT="${ZUPT_BIN:-./zupt}"
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! "$ZUPT" keygen --sdk -o "$_sdkck/p" >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
fi
rm -rf "$_sdkck"
case "$ZUPT" in
/*) ;;
*) ZUPT="$PWD/$ZUPT" ;;
esac
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
ZUPT=${ZUPT_BIN:-$repo_root/zupt}
if [ ! -x "$ZUPT" ]; then
echo "$ZUPT not found — run 'make' first" >&2
exit 1
fi
version=$("$ZUPT" --version 2>&1)
SDK_ENABLED=0
if grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
SDK_ENABLED=1
fi
PASS=0
FAIL=0
@ -68,14 +63,15 @@ else
F "plaintext: comment not shown on extract"
fi
# Test 2: Argon2id-password roundtrip
# Test 2: default password-KDF roundtrip (PBKDF2 in the source-only build,
# Argon2id when system libvuptsdk is enabled).
"$ZUPT" c -c "$COMMENT" -p secret arg.zupt input.txt >/dev/null 2>&1
mkdir out_a
OUT=$( (cd out_a && "$ZUPT" x -p secret ../arg.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "Argon2id: comment roundtrips"
P "default password KDF: comment roundtrips"
else
F "Argon2id: comment not shown"
F "default password KDF: comment not shown"
fi
# Test 3: PBKDF2-password roundtrip
@ -88,15 +84,19 @@ else
F "PBKDF2: comment not shown"
fi
# Test 4: PQ-SDK roundtrip
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
"$ZUPT" c -c "$COMMENT" --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1
mkdir out_pq
OUT=$( (cd out_pq && "$ZUPT" x --pq-sdk ../k.priv ../pq.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "PQ-SDK: comment roundtrips"
# Test 4: optional PQ-SDK roundtrip.
if ((SDK_ENABLED)); then
"$ZUPT" keygen --sdk -o k.priv >/dev/null 2>&1
"$ZUPT" c -c "$COMMENT" --pq-sdk k.priv.pub pq.zupt input.txt >/dev/null 2>&1
mkdir out_pq
OUT=$( (cd out_pq && "$ZUPT" x --pq-sdk ../k.priv ../pq.zupt) 2>&1 )
if echo "$OUT" | grep -qF "$COMMENT"; then
P "PQ-SDK: comment roundtrips"
else
F "PQ-SDK: comment not shown"
fi
else
F "PQ-SDK: comment not shown"
echo ' SKIP: PQ-SDK comment roundtrip needs system libvuptsdk (WITH_SDK=1)'
fi
# Test 5: info doesn't leak comment plaintext for encrypted archives
@ -115,33 +115,39 @@ fi
# Test 6: tampering the comment block payload is rejected
# Find the comment block offset: it's stored in hdr[44..51] (comment_offset).
COMM_OFF=$(python3 -c "
b = open('pq.zupt','rb').read()
b = open('arg.zupt','rb').read()
print(int.from_bytes(b[44:52],'little'))
")
# Tamper a byte inside the comment block payload (skip the 2-byte magic).
# Pick offset COMM_OFF + 20 which should land inside encrypted payload bytes.
cp pq.zupt tamp_comment.zupt
cp arg.zupt tamp_comment.zupt
python3 -c "
b = bytearray(open('tamp_comment.zupt','rb').read())
b[$COMM_OFF + 20] ^= 1
open('tamp_comment.zupt','wb').write(bytes(b))"
mkdir out_tc
ERR=$( (cd out_tc && "$ZUPT" x --pq-sdk ../k.priv ../tamp_comment.zupt) 2>&1 || true )
if [ ! -f out_tc/input.txt ]; then
set +e
(cd out_tc && "$ZUPT" x -p secret ../tamp_comment.zupt >/dev/null 2>&1)
tampered_comment_status=$?
set -e
if [ "$tampered_comment_status" -ne 0 ] && [ ! -f out_tc/input.txt ]; then
P "comment-block tamper rejected (per-block HMAC)"
else
F "comment-block tamper silently accepted"
fi
# Test 7: tampering hdr.comment_offset is rejected (covered by AIT)
cp pq.zupt tamp_offset.zupt
cp arg.zupt tamp_offset.zupt
python3 -c "
b = bytearray(open('tamp_offset.zupt','rb').read())
b[44] ^= 1 # low byte of comment_offset field
open('tamp_offset.zupt','wb').write(bytes(b))"
mkdir out_to
ERR=$( (cd out_to && "$ZUPT" x --pq-sdk ../k.priv ../tamp_offset.zupt) 2>&1 || true )
if [ ! -f out_to/input.txt ]; then
set +e
(cd out_to && "$ZUPT" x -p secret ../tamp_offset.zupt >/dev/null 2>&1)
tampered_offset_status=$?
set -e
if [ "$tampered_offset_status" -ne 0 ] && [ ! -f out_to/input.txt ]; then
P "comment_offset tamper rejected (AIT covers header)"
else
F "comment_offset tamper silently accepted"
@ -176,6 +182,19 @@ else
F "empty -c written as a comment block (should be no-op)"
fi
# Test 11: authenticated comments are still untrusted terminal input. Newline,
# ESC/OSC, DEL, and C1 controls must be rendered as visible escapes.
printf 'trusted ação 安全\nforged\033]52;c;Y2xpcGJvYXJk\007\177\302\200' > control.txt
"$ZUPT" c --comment-file control.txt control.zupt input.txt >/dev/null 2>&1
mkdir out_control
OUT=$( (cd out_control && "$ZUPT" x ../control.zupt) 2>&1 )
if [[ $OUT != *$'\033'* ]] &&
grep -Fq 'Comment: trusted ação 安全\x0Aforged\x1B]52;c;Y2xpcGJvYXJk\x07\x7F\xC2\x80' <<<"$OUT"; then
P "terminal controls are escaped while printable UTF-8 is preserved"
else
F "terminal controls in comments reached output unsanitized: $OUT"
fi
echo ""
echo " ───────────────────────────────────────"
echo " F-12 regression: $PASS passed, $FAIL failed"

View file

@ -0,0 +1,163 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
bin=${1:-$repo_root/zupt}
case "$bin" in
/*) ;;
*) bin="$(pwd -P)/${bin#./}" ;;
esac
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
test -x "$bin" || fail "$bin is not executable"
command -v python3 >/dev/null 2>&1 || fail 'python3 is required'
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-format-le.XXXXXX")
trap 'rm -rf "$tmp"' EXIT
printf 'little-endian format fixture\n' > "$tmp/input"
printf 'format-test-password\n' > "$tmp/password"
chmod 600 "$tmp/password"
"$bin" compress --store --kdf pbkdf2 --pass-file "$tmp/password" \
"$tmp/format.zupt" "$tmp/input" >/dev/null 2>&1 ||
fail 'could not create PBKDF2 archive fixture'
python3 - "$tmp/format.zupt" <<'PY'
import pathlib
import struct
import sys
path = pathlib.Path(sys.argv[1])
data = path.read_bytes()
def reject(message):
raise SystemExit(f"FAIL: {message}")
def varint(offset):
value = 0
shift = 0
start = offset
while offset < len(data) and shift <= 63:
byte = data[offset]
offset += 1
value |= (byte & 0x7f) << shift
if byte & 0x80 == 0:
encoded = data[start:offset]
canonical = bytearray()
remaining = value
while remaining >= 0x80:
canonical.append((remaining & 0x7f) | 0x80)
remaining >>= 7
canonical.append(remaining)
if bytes(canonical) != encoded:
reject("non-canonical varint in generated archive")
return value, offset
shift += 7
reject("unterminated varint")
if len(data) < 64 + 32 + 32:
reject("archive is too small")
if data[:6] != b"ZUPT\x1a\x00" or data[6:8] != bytes((1, 6)):
reject("header magic/version mismatch")
flags = struct.unpack_from("<I", data, 8)[0]
if data[8:12] != flags.to_bytes(4, "little"):
reject("global flags are not little-endian")
required = (1 << 0) | (1 << 8) | (1 << 9)
if flags & required != required:
reject("encrypted AAD policy flags are missing")
creation = struct.unpack_from("<Q", data, 12)[0]
if data[12:20] != creation.to_bytes(8, "little"):
reject("creation time is not little-endian")
enc_offset = struct.unpack_from("<Q", data, 36)[0]
if enc_offset != 64 or data[36:44] != enc_offset.to_bytes(8, "little"):
reject("encryption-header offset is not canonical little-endian")
offset = enc_offset
if data[offset:offset + 3] != b"\xbb\x01\x03":
reject("encryption-header frame is missing")
codec, block_flags = struct.unpack_from("<HH", data, offset + 3)
if codec != 0 or block_flags != 0:
reject("encryption-header frame metadata is invalid")
offset += 7
plain_size, offset = varint(offset)
payload_size, offset = varint(offset)
offset += 8
if plain_size != 53 or payload_size != 53 or data[offset] != 0x01:
reject("PBKDF2 header layout is invalid")
iterations = struct.unpack_from("<I", data, offset + 49)[0]
if iterations != 600000:
reject("PBKDF2 iteration count is not canonical little-endian")
footer_offset = len(data) - 64
index_offset, total_blocks, archive_checksum = struct.unpack_from(
"<QQQ", data, footer_offset
)
if data[footer_offset + 24:footer_offset + 28] != b"ZEND":
reject("footer magic is missing")
if data[footer_offset + 28:footer_offset + 32] != (1).to_bytes(4, "little"):
reject("footer version is not little-endian")
if not (64 < index_offset < footer_offset):
reject("footer index offset is outside the archive")
if data[index_offset:index_offset + 3] != b"\xbb\x01\x02":
reject("footer does not point to the index frame")
for value, start in (
(index_offset, footer_offset),
(total_blocks, footer_offset + 8),
(archive_checksum, footer_offset + 16),
):
if data[start:start + 8] != value.to_bytes(8, "little"):
reject("footer scalar is not little-endian")
print("portable little-endian header/footer/KDF serialization: PASS")
PY
# Exercise the C stream decoder with wire encodings that previously wrapped or
# admitted two representations of the same scalar. Strip the AIT deliberately
# and use the explicit legacy switch so rejection comes from the block parser,
# not from the trailer policy.
printf 'x' > "$tmp/one-byte"
"$bin" compress --store "$tmp/varint-base.zupt" "$tmp/one-byte" \
>/dev/null 2>&1 || fail 'could not create varint fixture'
python3 - "$tmp/varint-base.zupt" "$tmp" <<'PY'
import pathlib
import struct
import sys
source = pathlib.Path(sys.argv[1]).read_bytes()
out = pathlib.Path(sys.argv[2])
if len(source) < 128 or source[64:67] != b"\xbb\x01\x00":
raise SystemExit("FAIL: unexpected varint fixture layout")
footer = len(source) - 64
index_offset = struct.unpack_from("<Q", source, footer)[0]
varint_offset = 64 + 7
if source[varint_offset] != 1:
raise SystemExit("FAIL: fixture size does not use a one-byte varint")
def write_mutation(name, replacement):
# Discard the 32-byte integrity trailer, expand the first size field, and
# keep the legacy footer's index pointer structurally consistent.
changed = bytearray(source[:-32])
changed[varint_offset:varint_offset + 1] = replacement
delta = len(replacement) - 1
changed_footer = footer + delta
struct.pack_into("<Q", changed, changed_footer, index_offset + delta)
(out / name).write_bytes(changed)
write_mutation("varint-overlong.zupt", b"\x81\x00")
write_mutation("varint-overflow.zupt", b"\x81" + b"\x80" * 8 + b"\x02")
PY
for malformed in "$tmp/varint-overlong.zupt" "$tmp/varint-overflow.zupt"; do
if "$bin" test --allow-legacy-no-ait "$malformed" >/dev/null 2>&1; then
fail "non-canonical or overflowing varint was accepted: ${malformed##*/}"
fi
done
printf 'non-canonical and overflowing uint64 varints: PASS\n'

View file

@ -2,19 +2,20 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Regression test for GUI branding + licensing.
# Regression test for ZUPT GUI branding + licensing.
#
# History: in v3.0.0 the GUI shipped with two real bugs:
# 1. An MIT license credit line in the about panel — the GUI is
# AGPL-3.0-or-later with commercial dual-licensing; "MIT" was
# false and inherited from an early templating mistake.
# History: in v3.0.0 the GUI shipped with two documentation/code bugs:
# 1. The about panel described the current GUI simply as MIT even though the
# current source carried AGPL-3.0-or-later notices. Published earlier MIT
# grants remain valid for the exact historical material covered by them.
# 2. A version-string parser using `replace("zupt ", "")` which
# matched the wrong substring after the v3.0.0 rename. The
# version banner became `vaptvupt 3.0.0 (formerly zupt;
# renamed in v3.0.0 — INPI Brasil trademark)` and that
# `replace` chewed up "zupt " inside the parenthetical too.
#
# This test asserts both classes of bug stay fixed.
# This test keeps the current about-panel statement aligned with current SPDX
# notices without denying the historical license record, and covers the parser.
set -u
PASS=0; FAIL=0
@ -26,13 +27,14 @@ GUI=gui/src/zupt_gui.py
echo "GUI branding + licensing"
# ─── MIT reference checks ───
# Any MIT credit line in the GUI source is a bug.
# ─── Current and historical license checks ───
# The current about-panel implementation must not advertise the current GUI as
# MIT-only. Historical license information belongs in the license notice.
if grep -nE '"MIT"|"MIT [Ll]icense"| MIT[^A-Za-z]' "$GUI" >/dev/null 2>&1; then
F "GUI source contains an MIT reference"
F "GUI source advertises the current GUI as MIT"
grep -nE '"MIT"|"MIT [Ll]icense"| MIT[^A-Za-z]' "$GUI" | sed 's/^/ /'
else
P "GUI source contains no MIT references"
P "GUI source does not advertise the current GUI as MIT"
fi
# The GUI's own LICENSE-GUI file must be AGPL (or pointed to AGPL).
@ -42,11 +44,18 @@ if [ -f gui/LICENSE-GUI ]; then
else
F "gui/LICENSE-GUI is not AGPL — got: $(head -1 gui/LICENSE-GUI)"
fi
# Specifically, it shouldn't START with "MIT License"
# The current notice starts with AGPL, while retaining the factual erratum.
if head -1 gui/LICENSE-GUI | grep -qE "^MIT License"; then
F "gui/LICENSE-GUI starts with 'MIT License' — that's the bug we just fixed"
F "gui/LICENSE-GUI presents MIT as the current license"
else
P "gui/LICENSE-GUI does not start with 'MIT License'"
P "gui/LICENSE-GUI presents AGPL as the current license"
fi
if grep -q 'd4660e6539c8b6eeba81751c018217d978fdd618' gui/LICENSE-GUI &&
grep -q 'v2.2.2' gui/LICENSE-GUI &&
grep -q 'does not revoke or reinterpret a historical grant' gui/LICENSE-GUI; then
P "gui/LICENSE-GUI preserves the evidenced historical MIT grant"
else
F "gui/LICENSE-GUI is missing the factual historical-license erratum"
fi
fi
@ -78,12 +87,12 @@ else
fi
# ─── Brand-string check ───
# Splash and about-panel headers should say VAPTVUPT (the v3.0.0 name),
# not ZUPT.
if grep -q 'QLabel("ZUPT")' "$GUI"; then
F "GUI still uses QLabel(\"ZUPT\") — should be QLabel(\"VAPTVUPT\")"
# Release 5.2.2 restores the original ZUPT identity in every current panel.
if grep -q 'QLabel("ZUPT")' "$GUI" &&
! grep -q 'QLabel("VAPTVUPT")' "$GUI"; then
P "GUI uses ZUPT in current QLabel headers"
else
P "GUI uses VAPTVUPT (not ZUPT) in QLabel headers"
F "GUI current headers are not consistently branded ZUPT"
fi
# Crypto stack should include Argon2id (the default since v2.4.1).
@ -109,9 +118,8 @@ fi
# ─── Functional check ───
# If the CLI binary is available, exercise _VERSION_RE end-to-end.
if [ -x ./vaptvupt ] || [ -x ./zupt ]; then
BIN=./vaptvupt
[ ! -x "$BIN" ] && BIN=./zupt
BIN=${1:-${ZUPT_BIN:-./zupt}}
if [ -x "$BIN" ]; then
OUT=$("$BIN" version 2>&1 | head -1)
EXTRACTED=$(python3 -c "
import re, sys
@ -126,7 +134,7 @@ print(m.group(1) if m else 'NONE')
F "version regex extracted '$EXTRACTED', expected '$EXPECTED'"
fi
else
echo " - skipped: ./vaptvupt not built — skipping functional version test"
echo " - skipped: ZUPT binary not built — skipping functional version test"
fi
echo ""

View file

@ -2,16 +2,17 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Regression test for the `vaptvupt help` output.
# Regression test for the `zupt help` output.
#
# History:
# F-13 (v3.0.2): the usage() string literal exceeded C99's 4095-char
# limit (4121 chars), triggering -Woverlength-strings. Also, the
# help text had drifted out of date during the v3.0.0 rename:
# - Examples still said `zupt compress`, `zupt extract`, etc.
# help text had drifted out of date during the former v3.0.0 rename.
# Release 5.2.2 restores ZUPT/zupt as the public product and command:
# - "Compression: LZ77 (1MB window) + Huffman entropy coding" —
# false; the default codec is now VaptVupt LZ + ANS 2.48.5
# - "License: AGPL-3.0-or-later (Zupt)" — should be (VaptVupt)
# - the first-party license label must say ZUPT while retaining the
# separately attributed VaptVupt codec name.
#
# This test asserts the help output stays consistent with reality.
# Run from repo root after a build.
@ -21,8 +22,7 @@ PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
BIN=./vaptvupt
[ -x ./vaptvupt ] || BIN=./zupt
BIN=${1:-${ZUPT_BIN:-./zupt}}
[ -x "$BIN" ] || { echo "ERROR: no built binary found"; exit 2; }
HELP=$("$BIN" help 2>&1)
@ -62,20 +62,20 @@ else
fi
# ─── Brand consistency ───
# The help output must use the new binary name in examples, not the old one.
if echo "$HELP" | grep -qE '^\s+vaptvupt (compress|extract|list|test|bench|keygen|info|disk)'; then
P "examples use 'vaptvupt' command name"
# The help output must use the restored primary binary name in examples.
if echo "$HELP" | grep -qE '^\s+zupt (compress|extract|list|test|bench|keygen|info|disk)'; then
P "examples use 'zupt' command name"
else
F "examples don't use 'vaptvupt' — still saying 'zupt'?"
F "examples don't use the primary 'zupt' command"
fi
# Conversely, the example lines shouldn't start with `zupt ` (the
# bare legacy name in example commands is the drift we just fixed).
LEGACY_EX=$(echo "$HELP" | grep -cE '^\s{1,4}zupt (compress|extract|list|test|bench|keygen) ')
# The former public command may be offered as a compatibility symlink, but
# current examples must not make it the primary interface.
LEGACY_EX=$(echo "$HELP" | grep -cE '^\s{1,4}vaptvupt (compress|extract|list|test|bench|keygen) ')
if [ "$LEGACY_EX" -eq 0 ]; then
P "no examples use the bare legacy 'zupt' command name"
P "no examples use the former 'vaptvupt' command name"
else
F "$LEGACY_EX example lines still use the legacy 'zupt' command name"
F "$LEGACY_EX example lines still use the former 'vaptvupt' command name"
fi
# ─── Codec consistency ───
@ -95,10 +95,10 @@ else
fi
# ─── License consistency ───
if echo "$HELP" | grep -q "AGPL-3.0-or-later (VaptVupt)"; then
P "help shows the correct license attribution (VaptVupt)"
if echo "$HELP" | grep -q "AGPL-3.0-or-later (ZUPT)"; then
P "help shows the correct first-party license attribution (ZUPT)"
else
F "help has wrong license attribution — should say AGPL-3.0-or-later (VaptVupt)"
F "help has wrong license attribution — should say AGPL-3.0-or-later (ZUPT)"
fi
# Commercial-licensing contact visible.
@ -133,9 +133,9 @@ fi
# ─── Functional check: help command works ───
if "$BIN" help >/dev/null 2>&1; then
P "vaptvupt help exits successfully"
P "zupt help exits successfully"
else
F "vaptvupt help exits with non-zero status"
F "zupt help exits with non-zero status"
fi
echo ""

View file

@ -20,17 +20,16 @@
* keys for both (so old archives keep opening).
* 3. decrypt-init REFUSES an unknown profile rather than guessing a
* derivation (fail-closed).
* 4. The underlying libzuptsdk Argon2id KDF is deterministic and
* memory-hard (a coarse cost floor) this catches an SDK that has
* been swapped for a fast/weak stand-in at build time, before a
* user discovers their backup won't open or is under-protected.
* 4. The system libzuptsdk Argon2id KDF is deterministic and memory-hard
* (a coarse cost floor). This detects an unexpectedly weak system
* implementation before users depend on archives produced by it.
*/
#include "zupt.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
/* easy-derive is the only KDF symbol the vendored SDK exports. */
/* easy-derive is the KDF symbol exposed by the system SDK integration. */
int zuptsdk_easy_derive_key(const char *password, const uint8_t salt[16], uint8_t key_out[32]);
int zupt_sdk_password_encrypt_init(zupt_keyring_t *kr, const char *password,
uint8_t *enc_hdr, size_t *enc_hdr_len);
@ -112,7 +111,7 @@ int main(void) {
ok("Argon2id KDF cost floor met (memory-hard preset active)");
else {
char buf[96];
snprintf(buf, sizeof buf, "Argon2id KDF suspiciously fast (%.1f ms) — weak/stub SDK?", ms);
snprintf(buf, sizeof buf, "Argon2id KDF suspiciously fast (%.1f ms) — weak/stub system SDK?", ms);
bad(buf);
}
}

View file

@ -3,17 +3,20 @@
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# F-15 — Argon2id KDF parameter transparency (v3.4.0).
# Builds and runs tests/test_kdf_transparency.c against the vendored SDK.
# Builds and runs tests/test_kdf_transparency.c against a system libvuptsdk.
set -u
SDK_DIR="${ZUPTSDK_DIR:-vendor/zuptsdk}"
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! ls "$SDK_DIR"/libzuptsdk.so* >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
SDK_CFLAGS=${SDK_CFLAGS:-}
SDK_LIBS=${SDK_LIBS:-}
if [ -z "$SDK_CFLAGS$SDK_LIBS" ] && command -v pkg-config >/dev/null 2>&1 && \
pkg-config --exists libvuptsdk; then
SDK_CFLAGS=$(pkg-config --cflags libvuptsdk)
SDK_LIBS=$(pkg-config --libs libvuptsdk)
fi
if [ -z "$SDK_LIBS" ]; then
echo " SKIP: system libvuptsdk development package unavailable"
exit 0
fi
rm -rf "$_sdkck"
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ] || [ "$ARCH" = "i686" ]; then
@ -23,12 +26,13 @@ else
fi
TMP=$(mktemp -d)
if gcc -Iinclude -Isrc -I"$SDK_DIR/include" -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
# shellcheck disable=SC2086 # SDK flags intentionally expand to compiler words.
if "${CC:-cc}" -Iinclude -Isrc $SDK_CFLAGS -Wall -Wextra -Werror $SHANI -O2 -std=c11 \
tests/test_kdf_transparency.c \
src/zupt_crypto_sdk.c src/zupt_crypto.c src/zupt_sha256.c src/zupt_sha256_shani.c \
src/zupt_aes256.c src/zupt_xxh.c src/zupt_keccak.c src/zupt_x25519.c \
src/zupt_mlkem.c src/zupt_cpuid.c src/zupt_mlock.c \
-L"$SDK_DIR" -lzuptsdk -Wl,-rpath,"$(cd "$SDK_DIR" && pwd)" -lm \
$SDK_LIBS -lm \
-o "$TMP/t" 2>"$TMP/cc.log"; then
"$TMP/t"; rc=$?
else

371
tests/test_key_files.sh Normal file
View file

@ -0,0 +1,371 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2026 Cristian Cezar Moises
#
# Key-file security regression coverage for the native ZKEY and ZPQK formats.
set -uo pipefail
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
zupt_bin=${1:-$repo_root/zupt}
if [[ $zupt_bin != /* ]]; then
zupt_bin=$(CDPATH='' cd -- "$(dirname -- "$zupt_bin")" 2>/dev/null && pwd -P)/$(basename -- "$zupt_bin")
fi
if [[ ! -x $zupt_bin ]]; then
printf 'FAIL: executable not found: %s\n' "$zupt_bin" >&2
exit 1
fi
test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-key-files.XXXXXXXX") || exit 1
trap 'chmod -R u+rwX "$test_root" 2>/dev/null || true; rm -rf -- "$test_root"' EXIT HUP INT TERM
passes=0
failures=0
case_number=0
pass() {
passes=$((passes + 1))
printf ' PASS: %s\n' "$1"
}
fail() {
failures=$((failures + 1))
printf ' FAIL: %s\n' "$1" >&2
}
file_mode() {
if stat -c '%a' "$1" >/dev/null 2>&1; then
stat -c '%a' "$1"
else
stat -f '%Lp' "$1"
fi
}
generate_with_mode() {
local label=$1 mask=$2 output=$3
shift 3
if (umask "$mask"; "$zupt_bin" keygen "$@" -o "$output" >/dev/null 2>&1); then
local mode
mode=$(file_mode "$output")
if [[ $mode == 600 ]]; then
pass "$label is mode 0600 under umask $mask"
else
fail "$label mode under umask $mask is $mode, expected 600"
fi
else
fail "$label generation failed under umask $mask"
fi
}
expect_generation_refused() {
local label=$1 output=$2 expected=$3
shift 3
if "$zupt_bin" keygen "$@" -o "$output" >/dev/null 2>&1; then
fail "$label unexpectedly replaced an existing destination"
elif [[ -f $output && ! -L $output && $(<"$output") == "$expected" ]]; then
pass "$label refuses an existing file without modifying it"
else
fail "$label changed or removed an existing file"
fi
}
expect_symlink_refused() {
local label=$1 link=$2 target=$3 expected=$4
shift 4
if "$zupt_bin" keygen "$@" -o "$link" >/dev/null 2>&1; then
fail "$label unexpectedly followed an output symlink"
elif [[ -L $link && -f $target && $(<"$target") == "$expected" ]]; then
pass "$label refuses a symlink without modifying its target"
else
fail "$label changed the symlink or its target"
fi
}
# Mutate a valid native key. Header-only mutations receive a newly calculated
# XXH64 so they prove the parser checks magic/version/flags/reserved/role rather
# than merely reaching the checksum rejection. XXH64 remains a corruption check,
# not authentication of an intentionally substituted public key.
mutate_key() {
python3 - "$1" "$2" "$3" <<'PY'
import struct
import sys
MASK = (1 << 64) - 1
P1 = 11400714785074694791
P2 = 14029467366897019727
P3 = 1609587929392839161
P4 = 9650029242287828579
P5 = 2870177450012600261
def rol(value, bits):
return ((value << bits) | (value >> (64 - bits))) & MASK
def round64(acc, value):
acc = (acc + value * P2) & MASK
acc = rol(acc, 31)
return (acc * P1) & MASK
def merge_round(acc, value):
acc ^= round64(0, value)
return (acc * P1 + P4) & MASK
def xxh64(data, seed=0):
length = len(data)
pos = 0
if length >= 32:
v1 = (seed + P1 + P2) & MASK
v2 = (seed + P2) & MASK
v3 = seed & MASK
v4 = (seed - P1) & MASK
limit = length - 32
while pos <= limit:
v1 = round64(v1, struct.unpack_from('<Q', data, pos)[0]); pos += 8
v2 = round64(v2, struct.unpack_from('<Q', data, pos)[0]); pos += 8
v3 = round64(v3, struct.unpack_from('<Q', data, pos)[0]); pos += 8
v4 = round64(v4, struct.unpack_from('<Q', data, pos)[0]); pos += 8
result = rol(v1, 1) + rol(v2, 7) + rol(v3, 12) + rol(v4, 18)
result &= MASK
result = merge_round(result, v1)
result = merge_round(result, v2)
result = merge_round(result, v3)
result = merge_round(result, v4)
else:
result = (seed + P5) & MASK
result = (result + length) & MASK
while pos + 8 <= length:
lane = round64(0, struct.unpack_from('<Q', data, pos)[0])
result ^= lane
result = (rol(result, 27) * P1 + P4) & MASK
pos += 8
if pos + 4 <= length:
result ^= (struct.unpack_from('<I', data, pos)[0] * P1) & MASK
result = (rol(result, 23) * P2 + P3) & MASK
pos += 4
while pos < length:
result ^= (data[pos] * P5) & MASK
result = (rol(result, 11) * P1) & MASK
pos += 1
result ^= result >> 33
result = (result * P2) & MASK
result ^= result >> 29
result = (result * P3) & MASK
result ^= result >> 32
return result & MASK
source, destination, mutation = sys.argv[1:]
data = bytearray(open(source, 'rb').read())
if len(data) < 16:
raise SystemExit('source key is unexpectedly short')
stored = int.from_bytes(data[-8:], 'little')
if stored != xxh64(data[:-8]):
raise SystemExit('source key checksum does not match the format')
recheck = False
if mutation == 'magic':
data[0] ^= 0x20
recheck = True
elif mutation == 'version':
data[4] = 2
recheck = True
elif mutation == 'flag':
data[5] = 0x80
recheck = True
elif mutation == 'reserved':
data[6] = 1
recheck = True
elif mutation == 'role':
data[5] ^= 1
recheck = True
elif mutation == 'key':
data[16] ^= 1
elif mutation == 'secret':
if data[5] != 1:
raise SystemExit('secret mutation requires a private key')
data[-16] ^= 1
elif mutation == 'checksum':
data[-1] ^= 1
elif mutation == 'truncated':
del data[-1]
elif mutation == 'appended':
data.append(0x41)
else:
raise SystemExit('unknown mutation: ' + mutation)
if recheck:
data[-8:] = xxh64(data[:-8]).to_bytes(8, 'little')
open(destination, 'wb').write(data)
PY
}
expect_public_rejected() {
local format=$1 option=$2 key=$3 label=$4
case_number=$((case_number + 1))
local archive=$test_root/rejected-public-$case_number.zupt
if "$zupt_bin" compress "$option" "$key" "$archive" \
"$test_root/input.txt" >/dev/null 2>&1; then
fail "$format public key accepts $label"
elif [[ -e $archive ]]; then
fail "$format public key rejection published an archive for $label"
else
pass "$format public key rejects $label"
fi
}
expect_private_rejected() {
local format=$1 key=$2 label=$3
case_number=$((case_number + 1))
local public=$test_root/rejected-private-$case_number.pub
if [[ $format == ZKEY ]]; then
if "$zupt_bin" keygen --pub -o "$public" -k "$key" >/dev/null 2>&1; then
fail "$format private key accepts $label"
return
fi
else
if "$zupt_bin" keygen --pub --pq-only -o "$public" -k "$key" \
>/dev/null 2>&1; then
fail "$format private key accepts $label"
return
fi
fi
if [[ -e $public ]]; then
fail "$format private key rejection published output for $label"
else
pass "$format private key rejects $label"
fi
}
printf 'key-file security regression input\n' >"$test_root/input.txt"
printf 'Key-file permissions and no-replace publication\n'
generate_with_mode 'ZKEY private key' 022 "$test_root/hybrid-022.key"
generate_with_mode 'ZKEY private key' 000 "$test_root/hybrid-000.key"
generate_with_mode 'ZPQK private key' 022 "$test_root/pq-022.key" --pq-only
generate_with_mode 'ZPQK private key' 000 "$test_root/pq-000.key" --pq-only
printf 'hybrid sentinel' >"$test_root/existing-hybrid.key"
expect_generation_refused 'ZKEY generation' "$test_root/existing-hybrid.key" \
'hybrid sentinel'
printf 'pq sentinel' >"$test_root/existing-pq.key"
expect_generation_refused 'ZPQK generation' "$test_root/existing-pq.key" \
'pq sentinel' --pq-only
if ln -s "$test_root/hybrid-target" "$test_root/hybrid-link" 2>/dev/null; then
printf 'hybrid target sentinel' >"$test_root/hybrid-target"
expect_symlink_refused 'ZKEY generation' "$test_root/hybrid-link" \
"$test_root/hybrid-target" 'hybrid target sentinel'
else
printf ' SKIP: symlinks unavailable for ZKEY no-follow test\n'
fi
if ln -s "$test_root/pq-target" "$test_root/pq-link" 2>/dev/null; then
printf 'pq target sentinel' >"$test_root/pq-target"
expect_symlink_refused 'ZPQK generation' "$test_root/pq-link" \
"$test_root/pq-target" 'pq target sentinel' --pq-only
else
printf ' SKIP: symlinks unavailable for ZPQK no-follow test\n'
fi
cp "$test_root/hybrid-022.key" "$test_root/hybrid-before-same-path.key"
if "$zupt_bin" keygen --pub -o "$test_root/hybrid-022.key" \
-k "$test_root/hybrid-022.key" >/dev/null 2>&1; then
fail 'ZKEY public export accepted the private input as its output path'
elif cmp -s "$test_root/hybrid-before-same-path.key" \
"$test_root/hybrid-022.key"; then
pass 'ZKEY same-path public export preserves the private key'
else
fail 'ZKEY same-path public export modified the private key'
fi
cp "$test_root/pq-022.key" "$test_root/pq-before-same-path.key"
if "$zupt_bin" keygen --pub --pq-only -o "$test_root/pq-022.key" \
-k "$test_root/pq-022.key" >/dev/null 2>&1; then
fail 'ZPQK public export accepted the private input as its output path'
elif cmp -s "$test_root/pq-before-same-path.key" "$test_root/pq-022.key"; then
pass 'ZPQK same-path public export preserves the private key'
else
fail 'ZPQK same-path public export modified the private key'
fi
printf '\nValid key workflows\n'
if "$zupt_bin" keygen --pub -o "$test_root/hybrid.pub" \
-k "$test_root/hybrid-022.key" >/dev/null 2>&1 &&
"$zupt_bin" compress --pq "$test_root/hybrid.pub" \
"$test_root/hybrid.zupt" "$test_root/input.txt" >/dev/null 2>&1 &&
"$zupt_bin" extract --pq "$test_root/hybrid-022.key" \
-o "$test_root/hybrid-out" "$test_root/hybrid.zupt" >/dev/null 2>&1 &&
hybrid_extracted=$(find "$test_root/hybrid-out" -name input.txt -type f \
-print -quit) && [[ -n $hybrid_extracted ]] &&
cmp -s "$test_root/input.txt" "$hybrid_extracted"; then
pass 'valid ZKEY public/private round trip'
else
fail 'valid ZKEY public/private round trip'
fi
if "$zupt_bin" keygen --pub --pq-only -o "$test_root/pq.pub" \
-k "$test_root/pq-022.key" >/dev/null 2>&1 &&
"$zupt_bin" compress --pq-only "$test_root/pq.pub" \
"$test_root/pq.zupt" "$test_root/input.txt" >/dev/null 2>&1 &&
"$zupt_bin" extract --pq-only "$test_root/pq-022.key" \
-o "$test_root/pq-out" "$test_root/pq.zupt" >/dev/null 2>&1 &&
pq_extracted=$(find "$test_root/pq-out" -name input.txt -type f \
-print -quit) && [[ -n $pq_extracted ]] &&
cmp -s "$test_root/input.txt" "$pq_extracted"; then
pass 'valid ZPQK public/private round trip'
else
fail 'valid ZPQK public/private round trip'
fi
# Compatibility: native readers historically allowed the private file itself
# wherever a public recipient key was accepted.
if "$zupt_bin" compress --pq "$test_root/hybrid-022.key" \
"$test_root/hybrid-private-recipient.zupt" "$test_root/input.txt" \
>/dev/null 2>&1; then
pass 'valid private ZKEY remains accepted as recipient input'
else
fail 'valid private ZKEY recipient compatibility'
fi
if "$zupt_bin" compress --pq-only "$test_root/pq-022.key" \
"$test_root/pq-private-recipient.zupt" "$test_root/input.txt" \
>/dev/null 2>&1; then
pass 'valid private ZPQK remains accepted as recipient input'
else
fail 'valid private ZPQK recipient compatibility'
fi
printf '\nMalformed native key rejection\n'
metadata_mutations=(magic version flag reserved role key checksum truncated appended)
for mutation in "${metadata_mutations[@]}"; do
hybrid_bad=$test_root/hybrid-public-$mutation.key
if mutate_key "$test_root/hybrid.pub" "$hybrid_bad" "$mutation"; then
expect_public_rejected ZKEY --pq "$hybrid_bad" "$mutation"
else
fail "could not create ZKEY public mutation: $mutation"
fi
pq_bad=$test_root/pq-public-$mutation.key
if mutate_key "$test_root/pq.pub" "$pq_bad" "$mutation"; then
expect_public_rejected ZPQK --pq-only "$pq_bad" "$mutation"
else
fail "could not create ZPQK public mutation: $mutation"
fi
done
private_mutations=(magic version flag reserved role key secret checksum truncated appended)
for mutation in "${private_mutations[@]}"; do
hybrid_bad=$test_root/hybrid-private-$mutation.key
if mutate_key "$test_root/hybrid-022.key" "$hybrid_bad" "$mutation"; then
expect_private_rejected ZKEY "$hybrid_bad" "$mutation"
else
fail "could not create ZKEY private mutation: $mutation"
fi
pq_bad=$test_root/pq-private-$mutation.key
if mutate_key "$test_root/pq-022.key" "$pq_bad" "$mutation"; then
expect_private_rejected ZPQK "$pq_bad" "$mutation"
else
fail "could not create ZPQK private mutation: $mutation"
fi
done
printf '\nKey-file results: %d passed, %d failed\n' "$passes" "$failures"
((failures == 0))

47
tests/test_legacy_disk_5_2_1.sh Executable file
View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
bin=${1:-./zupt}
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
fixture="$repo_root/tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex"
tmp=$(mktemp -d "${TMPDIR:-/tmp}/zupt-legacy-disk.XXXXXXXX")
trap 'rm -rf -- "$tmp"' EXIT HUP INT TERM
fail() {
printf 'FAIL: %s\n' "$*" >&2
exit 1
}
"${CC:-cc}" -std=c11 -Wall -Wextra -Werror \
"$repo_root/tests/fixture_hex_decode.c" -o "$tmp/fixture-decode" ||
fail 'could not build fixture decoder'
"$tmp/fixture-decode" "$fixture" "$tmp/legacy.zupt" ||
fail 'could not decode v5.2.1 fixture'
{
dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'A'
dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'B'
dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'B'
dd if=/dev/zero bs=65536 count=1 2>/dev/null | tr '\000' 'C'
} > "$tmp/expected.img"
printf '%s\n' 'vaptvupt-5.2.1-fixture' > "$tmp/password"
chmod 600 "$tmp/password"
"$bin" list --pass-file "$tmp/password" "$tmp/legacy.zupt" >/dev/null 2>&1 ||
fail 'v5.2.1 encrypted+dedup disk fixture could not be listed'
"$bin" test --pass-file "$tmp/password" "$tmp/legacy.zupt" >/dev/null 2>&1 ||
fail 'v5.2.1 encrypted+dedup disk fixture failed validation'
mkdir "$tmp/extracted"
"$bin" extract --pass-file "$tmp/password" -o "$tmp/extracted" \
"$tmp/legacy.zupt" >/dev/null 2>&1 ||
fail 'v5.2.1 encrypted+dedup disk fixture could not be extracted'
cmp "$tmp/expected.img" "$tmp/extracted/legacy-abbc.img" ||
fail 'v5.2.1 encrypted+dedup generic extraction mismatch'
"$bin" disk restore --pass-file "$tmp/password" \
"$tmp/legacy.zupt" "$tmp/restored.img" >/dev/null 2>&1 ||
fail 'v5.2.1 encrypted+dedup disk fixture could not be restored'
cmp "$tmp/expected.img" "$tmp/restored.img" ||
fail 'v5.2.1 encrypted+dedup disk restore mismatch'
printf 'v5.2.1 encrypted+dedup disk list/test/extract/restore compatibility: PASS\n'

View file

@ -18,23 +18,25 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
CC="${CC:-cc}"
command -v openssl >/dev/null 2>&1 || { echo " - skipped: no openssl"; exit 0; }
command -v openssl >/dev/null 2>&1 || { echo " SKIP: no openssl"; exit 0; }
if ! openssl list -kem-algorithms 2>/dev/null | grep -qiE "ML-KEM-768|MLKEM768"; then
echo " - skipped: openssl has no ML-KEM-768 (need 3.5+)"; exit 0
echo " SKIP: openssl has no ML-KEM-768 (need 3.5+)"; exit 0
fi
command -v "$CC" >/dev/null 2>&1 || CC=gcc
command -v "$CC" >/dev/null 2>&1 || { echo " - skipped: no C compiler"; exit 0; }
command -v od >/dev/null 2>&1 || { echo " - skipped: no od"; exit 0; }
command -v "$CC" >/dev/null 2>&1 || { echo " SKIP: no C compiler"; exit 0; }
command -v od >/dev/null 2>&1 || { echo " SKIP: no od"; exit 0; }
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
H="$T/harness"
if ! "$CC" -O2 -I"$ROOT/include" -I"$ROOT/src" "$HERE/mlkem_fips203_harness.c" \
"$ROOT/src/zupt_mlkem.c" "$ROOT/src/zupt_keccak.c" -o "$H" 2>"$T/cc.err"; then
echo " - skipped: harness build failed"; sed 's/^/ /' "$T/cc.err" | head -3; exit 0
echo " FAIL: ML-KEM interoperability harness build failed" >&2
sed 's/^/ /' "$T/cc.err" | head -3 >&2
exit 1
fi
hx(){ od -A n -v -t x1 "$1" | tr -d ' \n'; }
P=0; F=0; ok(){ echo "$1"; P=$((P+1)); }; bad(){ echo "$1"; F=$((F+1)); }
cd "$T"
cd "$T" || exit 1
# 1) deterministic keygen ek match
head -c 64 /dev/urandom > dz.bin
@ -43,21 +45,33 @@ openssl genpkey -algorithm ML-KEM-768 -pkeyopt hexseed:"$SEED" -out osl.pem 2>/d
openssl pkey -in osl.pem -pubout -outform DER -out osl_pub.der 2>/dev/null
tail -c 1184 osl_pub.der > osl_ek.bin
MLKEM_RAND="$T/dz.bin" "$H" keygen
cmp -s ek.bin osl_ek.bin && ok "keygen ek == OpenSSL (byte-for-byte, same seed)" || bad "keygen ek differs from OpenSSL"
if cmp -s ek.bin osl_ek.bin; then
ok "keygen ek == OpenSSL (byte-for-byte, same seed)"
else
bad "keygen ek differs from OpenSSL"
fi
# 2) my encaps -> openssl decap
unset MLKEM_RAND
"$H" encaps osl_ek.bin >/dev/null 2>&1; cp ss.bin ss_mine.bin
openssl pkeyutl -decap -inkey osl.pem -in ct.bin -secret ss_osl.bin 2>/dev/null
cmp -s ss_mine.bin ss_osl.bin && ok "my encaps -> OpenSSL decap: shared secret matches" || bad "my encaps not interoperable"
if cmp -s ss_mine.bin ss_osl.bin; then
ok "my encaps -> OpenSSL decap: shared secret matches"
else
bad "my encaps not interoperable"
fi
# 3) openssl encap -> my decap
HDR=$(( $(stat -c%s osl_pub.der) - 1184 )); head -c "$HDR" osl_pub.der > hdr.bin
HDR=$(( $(wc -c < osl_pub.der) - 1184 )); head -c "$HDR" osl_pub.der > hdr.bin
"$H" keygen
cat hdr.bin ek.bin > my_pub.der
openssl pkeyutl -encap -pubin -inkey my_pub.der -secret ss_osl2.bin -out ct2.bin 2>/dev/null
"$H" decaps dk.bin ct2.bin >/dev/null 2>&1; cp ss.bin ss_mine2.bin
cmp -s ss_mine2.bin ss_osl2.bin && ok "OpenSSL encap -> my decap: shared secret matches" || bad "my decap not interoperable"
if cmp -s ss_mine2.bin ss_osl2.bin; then
ok "OpenSSL encap -> my decap: shared secret matches"
else
bad "my decap not interoperable"
fi
echo " Conformance: $P passed, $F failed"
[ "$F" -eq 0 ] && exit 0 || exit 1

View file

@ -1,339 +1,353 @@
#!/bin/bash
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
#
# Sprint 2.4.5 regression: packaging-recipe syntax checks.
#
# Ensures the recipes under packaging/{aur,debian,rpm,homebrew,nix}/
# are syntactically valid. Doesn't try to actually build the packages
# (that needs distro-specific tooling), but catches:
# - shell syntax errors in PKGBUILD
# - malformed Debian control / changelog / copyright
# - missing fields in RPM spec
# - Ruby syntax errors in the Homebrew formula (if ruby is available)
# - Nix flake parse errors (if nix is available)
#
# Plus structural checks that don't need external tools:
# - debian/rules is executable
# - all recipes reference the same version as include/zupt.h
set -u
set -Eeuo pipefail
export LC_ALL=C
PASS=0
FAIL=0
P() { PASS=$((PASS+1)); echo "$1"; }
F() { FAIL=$((FAIL+1)); echo "$1"; }
SKIP() { echo " - skipped: $1"; }
root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
cd -- "$root"
cd "$(dirname "$0")/.."
pass_count=0
fail_count=0
skip_count=0
pass() { pass_count=$((pass_count + 1)); printf 'PASS: %s\n' "$*"; }
fail() { fail_count=$((fail_count + 1)); printf 'FAIL: %s\n' "$*" >&2; }
skip() { skip_count=$((skip_count + 1)); printf 'SKIP: %s\n' "$*"; }
VERSION=$(grep '^#define ZUPT_VERSION_STRING' include/zupt.h | awk -F'"' '{print $2}')
echo "Packaging syntax checks (zupt $VERSION)"
version=$(sed -n 's/^#define ZUPT_VERSION_STRING "\([^"]*\)".*/\1/p' include/zupt.h)
[[ -n $version ]] || { printf 'FAIL: cannot determine upstream version\n' >&2; exit 1; }
# ─── AUR PKGBUILD ───
if [ -f packaging/aur/PKGBUILD ]; then
if bash -n packaging/aur/PKGBUILD 2>/dev/null; then
P "AUR PKGBUILD: bash syntax clean"
else
F "AUR PKGBUILD: bash syntax error"
fi
if grep -q "^pkgver=$VERSION$" packaging/aur/PKGBUILD; then
P "AUR PKGBUILD: pkgver matches include/zupt.h ($VERSION)"
else
F "AUR PKGBUILD: pkgver mismatch (expected $VERSION; got $(grep '^pkgver=' packaging/aur/PKGBUILD))"
fi
for field in pkgname pkgver pkgrel pkgdesc arch url license depends; do
if grep -qE "^$field=" packaging/aur/PKGBUILD; then
:
else
F "AUR PKGBUILD: missing required field '$field'"
continue
fi
done
P "AUR PKGBUILD: required fields present (pkgname, pkgver, pkgrel, pkgdesc, arch, url, license, depends)"
else
F "AUR PKGBUILD: file missing"
fi
# ─── Debian source package ───
for f in control rules changelog copyright source/format; do
if [ -f "packaging/debian/$f" ]; then
:
else
F "Debian: packaging/debian/$f missing"
fi
for script in packaging/build-deb.sh packaging/build-rpm.sh \
packaging/build-appimage.sh packaging/build-gui-appimage.sh \
packaging/build-gui-deb.sh packaging/build-gui-rpm.sh \
gui/packaging/appimage/build-appimage.sh packaging/build-dmg.sh \
scripts/check-source-only.sh scripts/export-opensuse-package.sh \
scripts/test-installed-zupt.sh packaging/opensuse/source-audit.sh; do
if bash -n "$script"; then pass "$script shell syntax"; else fail "$script shell syntax"; fi
done
if [ -f packaging/debian/control ] && [ -f packaging/debian/rules ]; then
P "Debian: control, rules, changelog, copyright, source/format all present"
fi
if [ -x packaging/debian/rules ]; then
P "Debian: rules is executable"
if ! command -v make >/dev/null 2>&1; then
skip 'make unavailable for Debian rules syntax'
elif make -n -f packaging/debian/rules override_dh_auto_build >/dev/null; then
pass 'Debian rules make syntax'
else
F "Debian: rules is not executable"
fail 'Debian rules make syntax'
fi
if grep -qE "^Source: (vaptvupt|zupt)$" packaging/debian/control; then
P "Debian control: Source field correct"
check_recipe_version() {
local recipe=$1 recipe_version=$2
if [[ $recipe_version == "$version" ]]; then
pass "$recipe version is $version"
else
fail "$recipe version is '$recipe_version' (expected $version)"
fi
}
check_recipe_version AUR \
"$(sed -n 's/^pkgver=//p' packaging/aur/PKGBUILD)"
check_recipe_version Debian \
"$(sed -n '1s/^zupt (\([^-]*\)-.*/\1/p' packaging/debian/changelog)"
check_recipe_version Fedora \
"$(awk '/^Version:/{print $2; exit}' packaging/rpm/zupt.spec)"
check_recipe_version Homebrew \
"$(sed -n 's/^[[:space:]]*version "\([^"]*\)".*/\1/p' packaging/homebrew/zupt.rb)"
check_recipe_version Nix \
"$(sed -n 's/^[[:space:]]*version = "\([^"]*\)";.*/\1/p' packaging/nix/flake.nix | head -1)"
check_recipe_version Guix \
"$(sed -n 's/^(define %zupt-version "\([^"]*\)")/\1/p' packaging/guix/zupt.scm)"
check_recipe_version openSUSE \
"$(awk '/^Version:/{print $2; exit}' packaging/opensuse/zupt.spec)"
if grep -En 'REPLACE_AFTER|REPLACE_WITH|sha256sums=\(.SKIP.|base32 .REPLACE' \
packaging/aur/PKGBUILD packaging/homebrew/zupt.rb packaging/guix/zupt.scm; then
if git tag --points-at HEAD 2>/dev/null | grep -Fxq "v$version"; then
fail 'tagged release recipes contain an unpinned source checksum'
else
pass 'release recipe checksums are explicitly pending final archive generation'
fi
else
F "Debian control: Source field wrong/missing"
pass 'release recipe source checksums are pinned'
fi
if grep -qE "^(vaptvupt|zupt) \($VERSION-[0-9]+\) " packaging/debian/changelog; then
P "Debian changelog: top entry matches $VERSION"
if [[ -x packaging/debian/rules ]]; then
pass 'Debian rules is executable'
else
F "Debian changelog: top entry version doesn't match include/zupt.h"
fail 'Debian rules is not executable'
fi
if command -v dpkg-parsechangelog >/dev/null 2>&1; then
if dpkg-parsechangelog -l packaging/debian/changelog >/dev/null 2>&1; then
P "Debian changelog: dpkg-parsechangelog accepts it"
if dpkg-parsechangelog -l packaging/debian/changelog >/dev/null; then
pass 'Debian changelog parses'
else
F "Debian changelog: dpkg-parsechangelog rejected it"
fail 'Debian changelog does not parse'
fi
else
SKIP "dpkg-parsechangelog not available (dpkg-dev not installed)"
fi
if [ "$(cat packaging/debian/source/format)" = "3.0 (quilt)" ]; then
P "Debian source/format: 3.0 (quilt)"
else
F "Debian source/format: wrong content"
skip 'dpkg-parsechangelog unavailable'
fi
# ─── RPM spec ───
if [ -f packaging/rpm/vaptvupt.spec ]; then
for field in Name Version Release Summary License URL Source0; do
if grep -qE "^$field:" packaging/rpm/vaptvupt.spec; then
:
else
F "RPM spec: missing tag '$field:'"
fi
done
P "RPM spec: required header tags present"
SPEC_VER=$(grep -E "^Version:" packaging/rpm/vaptvupt.spec | awk '{print $2}')
if [ "$SPEC_VER" = "$VERSION" ]; then
P "RPM spec: Version: matches include/zupt.h ($VERSION)"
if command -v ruby >/dev/null 2>&1; then
if ruby -c packaging/homebrew/zupt.rb >/dev/null; then
pass 'Homebrew formula Ruby syntax'
else
F "RPM spec: Version: '$SPEC_VER' != include/zupt.h '$VERSION'"
fi
for section in "%prep" "%build" "%install" "%files" "%changelog"; do
if grep -qF "$section" packaging/rpm/vaptvupt.spec; then
:
else
F "RPM spec: missing section '$section'"
fi
done
P "RPM spec: %prep, %build, %install, %files, %changelog sections present"
if command -v rpmlint >/dev/null 2>&1; then
rpmlint packaging/rpm/vaptvupt.spec >/tmp/rpmlint.out 2>&1
if [ -s /tmp/rpmlint.out ] && grep -qE " E: " /tmp/rpmlint.out; then
F "RPM spec: rpmlint errors (see /tmp/rpmlint.out):"
grep " E: " /tmp/rpmlint.out | head -3
else
P "RPM spec: rpmlint clean (warnings allowed)"
fi
else
SKIP "rpmlint not available"
fail 'Homebrew formula Ruby syntax'
fi
else
F "RPM spec: file missing"
skip 'Ruby unavailable for Homebrew syntax'
fi
# ─── Homebrew formula ───
if [ -f packaging/homebrew/vaptvupt.rb ]; then
HB_VER=$(grep -E '^\s*version\s' packaging/homebrew/vaptvupt.rb | head -1 | awk -F'"' '{print $2}')
if [ "$HB_VER" = "$VERSION" ]; then
P "Homebrew formula: version matches include/zupt.h ($VERSION)"
if command -v nix-instantiate >/dev/null 2>&1; then
if nix-instantiate --parse packaging/nix/flake.nix >/dev/null; then
pass 'Nix flake syntax'
else
F "Homebrew formula: version '$HB_VER' != include/zupt.h '$VERSION'"
fail 'Nix flake syntax'
fi
if command -v ruby >/dev/null 2>&1; then
if ruby -c packaging/homebrew/vaptvupt.rb >/dev/null 2>&1; then
P "Homebrew formula: ruby syntax clean"
else
F "Homebrew formula: ruby syntax error"
ruby -c packaging/homebrew/vaptvupt.rb 2>&1 | head -3
fi
else
SKIP "ruby not available — skipping Homebrew syntax parse"
fi
for kw in 'class (Vaptvupt|Zupt)' 'desc ' 'homepage ' 'url ' 'version ' 'sha256 ' 'license '; do
if grep -qE "^\s*${kw}" packaging/homebrew/vaptvupt.rb; then
:
else
F "Homebrew formula: missing DSL line starting with '$kw'"
fi
done
# install is a method definition; test is a block
if grep -qE "^\s*def\s+install\b" packaging/homebrew/vaptvupt.rb; then
:
else
F "Homebrew formula: missing method 'def install'"
fi
if grep -qE "^\s*test\s+do\b" packaging/homebrew/vaptvupt.rb; then
:
else
F "Homebrew formula: missing 'test do' block"
fi
P "Homebrew formula: class + required DSL keywords + install method + test block present"
else
F "Homebrew formula: file missing"
skip 'nix-instantiate unavailable for flake syntax'
fi
# ─── Nix flake ───
if [ -f packaging/nix/flake.nix ]; then
if command -v nix >/dev/null 2>&1 && nix --version 2>/dev/null | grep -qE "nix \(Nix\) [2-9]"; then
if nix flake metadata packaging/nix --no-update-lock-file >/dev/null 2>&1; then
P "Nix flake: nix accepts metadata"
else
F "Nix flake: nix flake metadata failed"
fi
if command -v guile >/dev/null 2>&1; then
if guile -c '(use-modules (guix gexp)) (call-with-input-file "packaging/guix/zupt.scm" (lambda (p) (let loop ((x (read p))) (unless (eof-object? x) (loop (read p))))))'; then
pass 'Guix recipe reader syntax'
else
SKIP "nix not available — skipping flake check"
fi
NIX_VER=$(grep -E 'version = "' packaging/nix/flake.nix | head -1 | awk -F'"' '{print $2}')
if [ "$NIX_VER" = "$VERSION" ]; then
P "Nix flake: version matches include/zupt.h ($VERSION)"
else
F "Nix flake: version '$NIX_VER' != include/zupt.h '$VERSION'"
fi
# Structural check: must have outputs and a zupt package definition
if grep -qE "outputs\s*=" packaging/nix/flake.nix && \
grep -qE 'pname = "(vaptvupt|zupt)"' packaging/nix/flake.nix; then
P "Nix flake: outputs + zupt package definition present"
else
F "Nix flake: structure incomplete"
fail 'Guix recipe reader syntax'
fi
else
F "Nix flake: file missing"
skip 'Guile unavailable for Guix syntax'
fi
# ─── openSUSE OBS recipe (renamed zupt.* -> vaptvupt.* in 3.2.0) ───
if [ -f packaging/opensuse/vaptvupt.spec ] && [ -f packaging/opensuse/vaptvupt.changes ] && [ -f packaging/opensuse/_service ]; then
P "openSUSE OBS files: all three present (vaptvupt.spec, vaptvupt.changes, _service)"
# Validate the spec parses
if command -v rpm >/dev/null 2>&1; then
if rpm --specfile packaging/opensuse/vaptvupt.spec >/dev/null 2>&1; then
P "openSUSE vaptvupt.spec: rpm --specfile parses cleanly"
else
F "openSUSE vaptvupt.spec: rpm --specfile rejected it"
fi
SUSE_VER=$(grep -E "^Version:" packaging/opensuse/vaptvupt.spec | awk '{print $2}')
if [ "$SUSE_VER" = "$VERSION" ]; then
P "openSUSE vaptvupt.spec: Version matches include/zupt.h ($VERSION)"
else
F "openSUSE vaptvupt.spec: Version '$SUSE_VER' != include/zupt.h '$VERSION'"
fi
# Name must be vaptvupt, and it must supersede the old zupt package.
if grep -qE "^Name:[[:space:]]+vaptvupt$" packaging/opensuse/vaptvupt.spec; then
P "openSUSE vaptvupt.spec: Name is vaptvupt"
else
F "openSUSE vaptvupt.spec: Name is not vaptvupt"
fi
if grep -qE "^Provides:[[:space:]]+zupt" packaging/opensuse/vaptvupt.spec && \
grep -qE "^Obsoletes:[[:space:]]+zupt" packaging/opensuse/vaptvupt.spec; then
P "openSUSE vaptvupt.spec: Provides/Obsoletes zupt (clean upgrade)"
else
F "openSUSE vaptvupt.spec: missing Provides/Obsoletes zupt"
fi
if command -v xmllint >/dev/null 2>&1; then
if xmllint --noout packaging/opensuse/_service; then
pass 'openSUSE service XML'
else
SKIP "rpm not available — skipping openSUSE spec parse"
fi
# Validate _service is well-formed XML
if command -v python3 >/dev/null 2>&1; then
if python3 -c "import xml.etree.ElementTree as ET; ET.parse('packaging/opensuse/_service')" 2>/dev/null; then
P "openSUSE _service: XML well-formed"
else
F "openSUSE _service: XML parse error"
fi
fi
# _service filename should be vaptvupt now
if grep -qE "<param name=\"filename\">vaptvupt</param>" packaging/opensuse/_service; then
P "openSUSE _service: filename is vaptvupt"
else
F "openSUSE _service: filename not updated to vaptvupt"
fi
# .changes: check standard 67-dash separator (openSUSE convention is exactly 67)
SEP_COUNT=$(grep -cE "^-{67}$" packaging/opensuse/vaptvupt.changes)
if [ "$SEP_COUNT" -ge 1 ]; then
P "openSUSE vaptvupt.changes: $SEP_COUNT entries with proper separator"
else
F "openSUSE vaptvupt.changes: missing or wrong separator format"
fail 'openSUSE service XML'
fi
elif python3 -c 'import xml.etree.ElementTree as E; E.parse("packaging/opensuse/_service")' 2>/dev/null; then
pass 'openSUSE service XML (Python parser)'
else
F "openSUSE OBS files incomplete (need vaptvupt.spec, vaptvupt.changes, _service)"
fi
if [ -f DISTRIBUTION.md ]; then
P "DISTRIBUTION.md present"
for distro in "Arch Linux" "Debian / Ubuntu" "Fedora" "macOS" "NixOS"; do
if grep -q "$distro" DISTRIBUTION.md; then
:
else
F "DISTRIBUTION.md: doesn't mention '$distro'"
fi
done
P "DISTRIBUTION.md: covers all 5 distros"
else
F "DISTRIBUTION.md missing"
fail 'openSUSE service XML'
fi
# ─── GitHub Actions CI workflow ───
if [ -f .github/workflows/ci.yml ]; then
if command -v python3 >/dev/null 2>&1; then
# Write the validator to a temp file rather than inline -c so quoting/
# indentation can't bite.
cat > /tmp/ci_validate.py << 'PYEOF'
import yaml, sys
try:
with open('.github/workflows/ci.yml') as f:
doc = yaml.safe_load(f)
except Exception as e:
sys.stderr.write(f"YAML_PARSE_ERROR: {e}\n")
sys.exit(1)
jobs = list(doc.get('jobs', {}).keys())
expected = ['build-and-test', 'strict-warnings', 'sanitizers',
'dist-reproducibility', 'packaging-syntax', 'release']
missing = [j for j in expected if j not in jobs]
if missing:
sys.stderr.write(f"MISSING_JOBS: {missing}\n")
sys.exit(1)
print(f"JOBS_OK ({len(jobs)} jobs)")
PYEOF
if python3 /tmp/ci_validate.py 2>/tmp/ci_check.err; then
P "CI workflow: YAML valid + expected jobs present"
else
F "CI workflow: $(cat /tmp/ci_check.err)"
fi
rm -f /tmp/ci_validate.py /tmp/ci_check.err
else
SKIP "python3 unavailable — skipping CI YAML check"
fi
service=packaging/opensuse/_service
spec=packaging/opensuse/zupt.spec
if grep -Fq '<service name="obs_scm"' "$service" && \
grep -Fq '<param name="url">https://github.com/cristiancmoises/zupt.git</param>' "$service" && \
grep -Fq "<param name=\"revision\">refs/tags/v$version</param>" "$service" && \
grep -Fq '<param name="submodules">disable</param>' "$service" && \
grep -Fq '<param name="lfs">disable</param>' "$service" && \
grep -Fq '<service name="tar" mode="buildtime"' "$service" && \
grep -Fq '<service name="recompress" mode="buildtime"' "$service"; then
pass 'openSUSE services use canonical immutable source-only input'
else
F "CI workflow .github/workflows/ci.yml missing"
fail 'openSUSE services do not use the required immutable source-only input'
fi
# ─── THREAT_MODEL.md ───
if [ -f THREAT_MODEL.md ]; then
P "THREAT_MODEL.md present"
# Verify the document is substantive (>3000 bytes) and covers the
# required sections per userPreferences ("plain English. State
# explicitly what the system does NOT protect against.")
SZ=$(wc -c < THREAT_MODEL.md)
if [ "$SZ" -ge 3000 ]; then
P "THREAT_MODEL.md: substantive ($SZ bytes)"
else
F "THREAT_MODEL.md: too short ($SZ bytes, expected >= 3000)"
fi
for section in "What VaptVupt protects against" "What VaptVupt does NOT protect against" "Cryptographic assumptions"; do
if grep -qF "$section" THREAT_MODEL.md; then
:
else
F "THREAT_MODEL.md: missing section '$section'"
fi
done
P "THREAT_MODEL.md: required sections present"
if grep -Fq 'APPIMAGE_RUNTIME_COMPLIANCE_FILE' packaging/build-appimage.sh && \
grep -Fq 'APPIMAGE_RUNTIME_COMPLIANCE_FILE' packaging/build-gui-appimage.sh && \
grep -Fq 'AppImage-runtime-compliance.txt' packaging/build-appimage.sh && \
grep -Fq 'AppImage-runtime-compliance.txt' packaging/build-gui-appimage.sh; then
pass 'AppImage helpers require and bundle external compliance evidence'
else
F "THREAT_MODEL.md missing"
fail 'AppImage helper compliance policy is incomplete'
fi
echo ""
echo " ───────────────────────────────────────"
echo " packaging syntax: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ "$FAIL" = 0 ] || exit 1
if ! grep -Eiq 'release-appimage|appimagetool|APPIMAGE_RUNTIME_FILE|[.]AppImage' \
.github/workflows/ci.yml .github/workflows/cross-platform.yml \
.github/workflows/promote-release.yml; then
pass 'release workflows do not build or promote AppImage assets'
else
fail 'a release workflow still builds or promotes an AppImage asset'
fi
if grep -Fq 'archive declared-size limit exceeded before extraction' scripts/check-source-only.sh && \
grep -Fq 'global archive declared-size budget exceeded' scripts/check-source-only.sh && \
grep -Fq 'SOURCE_AUDIT_ARCHIVE_SECONDS' scripts/check-source-only.sh && \
grep -Fq 'compressed archive declared size is rejected before extraction' tests/test_source_only.sh; then
pass 'source scanner bounds archive members, expansion and CPU time before extraction'
else
fail 'source scanner archive resource bounds or regressions are incomplete'
fi
# These are literal shell expressions required inside the promotion workflow.
# shellcheck disable=SC2016
if grep -Fq 'zupt-gui_${VERSION}_all.deb' .github/workflows/promote-release.yml && \
grep -Fq 'zupt-gui-$VERSION-1.noarch.rpm' .github/workflows/promote-release.yml && \
grep -Fq 'zupt-gui-$VERSION-1.src.rpm' .github/workflows/promote-release.yml && \
grep -Fq 'zupt-$VERSION-linux-x86_64.tar.xz' .github/workflows/promote-release.yml && \
grep -Fq 'zupt-gui-$VERSION-portable.zip' .github/workflows/promote-release.yml && \
grep -Fq 'zupt >= $VERSION' .github/workflows/promote-release.yml; then
pass 'release promotion allowlists gated CLI and GUI package formats'
else
fail 'release promotion is missing a gated package format or dependency check'
fi
if ! grep -Eqi 'git[.]securityops[.]co|forgejo|canonical server|GitHub mirror' \
.github/workflows/promote-release.yml && \
grep -Fq 'GitHub is the canonical upstream release' \
.github/workflows/promote-release.yml; then
pass 'GitHub is the sole canonical release target'
else
fail 'release promotion still depends on a non-GitHub canonical forge'
fi
if grep -Fq 'path: out/*.zip' .github/workflows/cross-platform.yml && \
! grep -Fq 'out/*.exe' .github/workflows/cross-platform.yml && \
grep -Fq 'windows_zip_name=' .github/workflows/promote-release.yml && \
! grep -Fq 'windows_exe_name=' .github/workflows/promote-release.yml; then
pass 'Windows release policy promotes the notice-bearing ZIP only'
else
fail 'Windows workflow still permits a bare EXE release asset'
fi
windows_notices_ok=1
for notice in MINGW-CRT-COPYING.txt COPYING.MinGW-w64-runtime.txt \
COPYING.MinGW-w64.txt GCC-COPYING3.txt \
GCC-RUNTIME-LIBRARY-EXCEPTION.txt; do
grep -Fq "$notice" .github/workflows/cross-platform.yml || \
windows_notices_ok=0
grep -Fq "$notice" .github/workflows/promote-release.yml || \
windows_notices_ok=0
done
if ((windows_notices_ok)); then
pass 'static Windows bundle preserves MinGW and GCC runtime notices'
else
fail 'static Windows bundle omits MinGW or GCC runtime notices'
fi
if grep -Eiq 'AppImage.*(not|excluded|outside)' SECURITY.md && \
grep -Eiq 'AppImage.*(not|excluded|outside)' THREAT_MODEL.md && \
grep -Eiq 'AppImage.*(not|excluded|outside)' AUDIT.md && \
grep -Eiq 'AppImage.*(not|excluded|outside)' doc/zupt.1; then
pass 'current security and user documentation records the 5.2.2 exclusions'
else
fail 'current documentation still permits a 5.2.2 AppImage or bare EXE claim'
fi
handoff_legal_ok=1
for legal_file in 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; do
grep -Fq "$legal_file" scripts/export-opensuse-package.sh || \
handoff_legal_ok=0
done
if ((handoff_legal_ok)) && \
grep -Fq 'handoff legal file is missing or empty' scripts/export-opensuse-package.sh; then
pass 'openSUSE handoff includes and checks complete public legal payload'
else
fail 'openSUSE handoff omits or does not validate a public legal file'
fi
if grep -Fq 'PYTHON-NOTICE.txt' gui/packaging/windows/build-windows.bat && \
grep -Fq 'PYINSTALLER-NOTICE.txt' gui/packaging/windows/build-windows.bat && \
grep -Fq 'QT-NOTICE.txt' gui/packaging/windows/build-windows.bat && \
grep -Fq 'PYSIDE6-NOTICE.txt' gui/packaging/windows/build-windows.bat && \
grep -Fq 'PYQT6-NOTICE.txt' gui/packaging/windows/build-windows.bat; then
pass 'downstream Windows GUI helper requires every runtime notice class'
else
fail 'downstream Windows GUI helper permits incomplete runtime notices'
fi
if grep -Eq '^Name:[[:space:]]+zupt$' "$spec" && \
grep -Eq '^Source0:[[:space:]]+%\{name\}-%\{version\}\.tar\.gz$' "$spec" && \
grep -Eq '^License:[[:space:]]+AGPL-3\.0-or-later AND GPL-3\.0-or-later AND BSD-2-Clause AND BSD-3-Clause AND CC0-1\.0$' "$spec" && \
grep -Eq '^Provides:[[:space:]]+bundled\(vaptvupt-codec\) = 2\.65\.3$' "$spec" && \
grep -Eq '^Provides:[[:space:]]+vaptvupt = %\{version\}-%\{release\}$' "$spec" && \
grep -Eq '^Obsoletes:[[:space:]]+vaptvupt < %\{version\}$' "$spec" && \
grep -Fq 'WITH_SDK=0 WITH_PQBOX=0' "$spec" && \
grep -Fq 'INSTALL_LEGACY_ALIAS=0' "$spec" && \
grep -Fq 'INSTALL_LICENSES=0' "$spec" && \
grep -Fq '%{_bindir}/zupt' "$spec" && \
! grep -Fq '%{_bindir}/vaptvupt' "$spec"; then
pass 'openSUSE spec source, license, features and alias policy'
else
fail 'openSUSE spec source, license, features or alias policy'
fi
if grep -Fq 'LICENSE-BSD-3-Clause' packaging/build-deb.sh && \
grep -Fq 'LICENSE-CC0-1.0' packaging/build-deb.sh && \
[[ $(grep -Fc 'LICENSE-BSD-3-Clause' packaging/build-dmg.sh) -ge 2 ]] && \
[[ $(grep -Fc 'LICENSE-CC0-1.0' packaging/build-dmg.sh) -ge 2 ]] && \
grep -Fq 'LICENSE-BSD-3-Clause' packaging/build-appimage.sh && \
grep -Fq 'LICENSE-CC0-1.0' packaging/build-appimage.sh && \
grep -Fq 'LICENSE-BSD-3-Clause' packaging/build-gui-appimage.sh && \
grep -Fq 'LICENSE-CC0-1.0' packaging/build-gui-appimage.sh && \
grep -Fq 'LICENSE-BSD-3-Clause' .github/workflows/cross-platform.yml && \
grep -Fq 'LICENSE-CC0-1.0' .github/workflows/cross-platform.yml && \
grep -Fq 'LICENSE-BSD-3-Clause' .github/workflows/promote-release.yml && \
grep -Fq 'LICENSE-CC0-1.0' .github/workflows/promote-release.yml && \
grep -Fq 'LICENSE-BSD-3-Clause' packaging/debian/zupt.docs && \
grep -Fq 'LICENSE-CC0-1.0' packaging/debian/zupt.docs; then
pass 'binary bundle paths preserve BSD-3-Clause and CC0-1.0 texts'
else
fail 'a binary bundle path omits BSD-3-Clause or CC0-1.0 text'
fi
if grep -Fq 'LICENSE-BSD-3-Clause' gui/packaging/flatpak/dev.zupt.gui.yml && \
grep -Fq 'LICENSE-CC0-1.0' gui/packaging/flatpak/dev.zupt.gui.yml && \
grep -Fq 'ZUPT_WINDOWS_RUNTIME_NOTICES_DIR' gui/packaging/windows/build-windows.bat && \
grep -Fq 'MANIFEST.txt' gui/packaging/windows/build-windows.bat && \
grep -Fq 'LICENSE-BSD-3-Clause' packaging/windows/zupt-gui.iss && \
grep -Fq 'LICENSE-CC0-1.0' packaging/windows/zupt-gui.iss && \
grep -Fq 'RuntimeNoticesDir' packaging/windows/zupt-gui.iss && \
grep -Fq 'LICENSE-BSD-3-Clause' sdk/Makefile.sdk && \
grep -Fq 'LICENSE-CC0-1.0' sdk/Makefile.sdk && \
grep -Fq 'LICENSE-BSD-3-Clause' packaging/homebrew/zupt.rb && \
grep -Fq 'LICENSE-CC0-1.0' packaging/nix/flake.nix && \
grep -Fq 'LICENSEDIR' Makefile && \
grep -Fq 'LICENSE-BSD-3-Clause' Makefile && \
grep -Fq 'LICENSE-GUI' gui/install.sh; then
pass 'auxiliary bundles install project and external-runtime notices'
else
fail 'an auxiliary bundle omits project or external-runtime notices'
fi
if command -v rpmspec >/dev/null 2>&1; then
if rpmspec -P "$spec" >/dev/null; then
pass 'openSUSE spec parses with rpmspec'
else
fail 'openSUSE spec rpmspec parse'
fi
else
skip 'rpmspec unavailable'
fi
if command -v shellcheck >/dev/null 2>&1; then
shell_files=(
packaging/build-deb.sh
packaging/build-rpm.sh
packaging/build-appimage.sh
packaging/build-gui-appimage.sh
packaging/build-gui-deb.sh
packaging/build-gui-rpm.sh
gui/packaging/appimage/build-appimage.sh
packaging/build-dmg.sh
packaging/opensuse/source-audit.sh
scripts/check-source-only.sh
scripts/export-opensuse-package.sh
scripts/test-installed-zupt.sh
tests/test_source_only.sh
tests/test_packaging_syntax.sh
)
if shellcheck -x "${shell_files[@]}"; then
pass 'ShellCheck tracked shell scripts'
else
fail 'ShellCheck tracked shell scripts'
fi
else
skip 'ShellCheck unavailable'
fi
for workflow in .github/workflows/*.yml; do
if grep -Eq 'pull_request_target:' "$workflow"; then
fail "$workflow uses pull_request_target"
else
pass "$workflow avoids pull_request_target"
fi
if grep -Eqi 'git[[:space:]]+push|credential\.helper[[:space:]]+store' "$workflow"; then
fail "$workflow contains unsafe publishing command"
else
pass "$workflow avoids direct Git credential mutation"
fi
done
printf 'SUMMARY: PASS=%d FAIL=%d SKIP=%d\n' "$pass_count" "$fail_count" "$skip_count"
((fail_count == 0))

View file

@ -0,0 +1,85 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2026 Cristian Cezar Moisés
# A signal received while a password is read must not leave terminal echo off.
set -Eeuo pipefail
bin=${1:-./zupt}
if [[ ! -x $bin ]]; then
printf 'FAIL: ZUPT binary is not executable: %s\n' "$bin" >&2
exit 1
fi
case $(uname -s) in
MINGW*|MSYS*|CYGWIN*)
printf '%s\n' \
'SKIP: POSIX pseudo-terminal signal restoration test is unavailable on Windows'
exit 0
;;
esac
if ! command -v python3 >/dev/null 2>&1; then
printf 'SKIP: password-prompt signal test needs python3 with pty support\n'
exit 0
fi
bin=$(cd "$(dirname "$bin")" && pwd -P)/$(basename "$bin")
python3 - "$bin" <<'PY'
import os
import pty
import select
import signal
import subprocess
import tempfile
import termios
import time
import sys
binary = sys.argv[1]
with tempfile.TemporaryDirectory(prefix="zupt-password-signal-") as work:
source = os.path.join(work, "input.txt")
archive = os.path.join(work, "interrupted.zupt")
with open(source, "w", encoding="utf-8") as stream:
stream.write("terminal restoration regression\n")
master, slave = pty.openpty()
initial = termios.tcgetattr(slave)
process = subprocess.Popen(
[binary, "compress", "--password-prompt", archive, source],
stdin=slave,
stdout=slave,
stderr=slave,
close_fds=True,
)
transcript = bytearray()
deadline = time.monotonic() + 10
try:
while b"Password:" not in transcript and time.monotonic() < deadline:
readable, _, _ = select.select([master], [], [], 0.1)
if readable:
transcript.extend(os.read(master, 4096))
if process.poll() is not None:
break
if b"Password:" not in transcript:
raise SystemExit("password prompt was not reached")
hidden = termios.tcgetattr(slave)
if hidden[3] & termios.ECHO:
raise SystemExit("terminal echo was not disabled during prompt")
process.send_signal(signal.SIGINT)
process.wait(timeout=10)
restored = termios.tcgetattr(slave)
if (restored[3] & termios.ECHO) != (initial[3] & termios.ECHO):
raise SystemExit("terminal echo state was not restored after SIGINT")
if not (restored[3] & termios.ECHO):
raise SystemExit("terminal echo is disabled after interrupted prompt")
if os.path.exists(archive):
raise SystemExit("interrupted password prompt left an archive")
finally:
if process.poll() is None:
process.kill()
process.wait()
os.close(master)
os.close(slave)
print("password prompt signal restoration: PASS")
PY

View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
binary=${1:-./zupt}
case $binary in
/*) ;;
*) binary=$PWD/${binary#./} ;;
esac
test_root=$(mktemp -d "${TMPDIR:-/tmp}/zupt-password.XXXXXXXX")
cleanup() {
chmod -R u+rwX "$test_root" 2>/dev/null || true
rm -rf -- "$test_root"
}
trap cleanup EXIT HUP INT TERM
cd "$test_root"
printf 'password source round-trip\n' > 'entrada ação.txt'
printf 'Correct-Horse-Battery-Staple-2026!\n' > 'senha segura.txt'
chmod 600 'senha segura.txt'
"$binary" compress --pass-file 'senha segura.txt' archive.zupt \
'entrada ação.txt' >/dev/null 2>&1
"$binary" test --pass-file 'senha segura.txt' archive.zupt >/dev/null 2>&1
mkdir extracted
"$binary" extract --pass-file 'senha segura.txt' -o extracted \
archive.zupt >/dev/null 2>&1
cmp 'entrada ação.txt' 'extracted/entrada ação.txt'
case $(uname -s) in
MINGW*|MSYS*|CYGWIN*)
printf '%s\n' 'SKIP: inherited POSIX descriptor mapping is not portable in MSYS'
;;
*)
exec 9<'senha segura.txt'
"$binary" test --pass-fd 9 archive.zupt >/dev/null 2>&1
exec 9<&-
;;
esac
printf '\n' > empty-password
if "$binary" test --pass-file empty-password archive.zupt >/dev/null 2>&1; then
printf '%s\n' 'FAIL: accepted an empty password file' >&2
exit 1
fi
printf 'bad\0password\n' > nul-password
if "$binary" test --pass-file nul-password archive.zupt >/dev/null 2>&1; then
printf '%s\n' 'FAIL: accepted a password file containing NUL' >&2
exit 1
fi
if "$binary" test --pass-fd not-a-number archive.zupt >/dev/null 2>&1; then
printf '%s\n' 'FAIL: accepted an invalid password descriptor' >&2
exit 1
fi
if "$binary" test --password-prompt archive.zupt </dev/null >/dev/null 2>&1; then
printf '%s\n' 'FAIL: non-interactive password prompt unexpectedly succeeded' >&2
exit 1
fi
case $(uname -s) in
MINGW*|MSYS*|CYGWIN*)
printf '%s\n' 'SKIP: POSIX pseudo-terminal password overflow test is unavailable in MSYS'
;;
*)
if command -v python3 >/dev/null 2>&1; then
python3 - "$binary" "$test_root" <<'PY'
import errno
import os
import pty
import select
import sys
import time
binary, root = sys.argv[1:]
archive = os.path.join(root, "prompt-overflow.zupt")
source = os.path.join(root, "entrada ação.txt")
pid, descriptor = pty.fork()
if pid == 0:
os.execv(binary, [binary, "compress", "--password-prompt", archive, source])
deadline = time.monotonic() + 20
output = bytearray()
sent = False
status = None
while time.monotonic() < deadline:
ready, _, _ = select.select([descriptor], [], [], 0.1)
if ready:
try:
chunk = os.read(descriptor, 4096)
except OSError as error:
if error.errno != errno.EIO:
raise
chunk = b""
output.extend(chunk)
if not sent and b"Password:" in output:
os.write(descriptor, b"A" * 510 + b"\n")
sent = True
waited, status = os.waitpid(pid, os.WNOHANG)
if waited == pid:
break
else:
os.kill(pid, 9)
os.waitpid(pid, 0)
raise SystemExit("password overflow prompt timed out")
success = status is not None and os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0
if not sent or success:
raise SystemExit("overlong interactive password was accepted")
if os.path.exists(archive):
raise SystemExit("overlong interactive password published an archive")
PY
else
printf '%s\n' 'SKIP: python3 is unavailable for pseudo-terminal password overflow test'
fi
;;
esac
if "$binary" test -p incorrect archive.zupt >/dev/null 2>&1; then
printf '%s\n' 'FAIL: incorrect password unexpectedly succeeded' >&2
exit 1
fi
if "$binary" --version | grep -q 'libvuptsdk=disabled'; then
if "$binary" compress -p secret --kdf argon2id downgrade.zupt \
'entrada ação.txt' >/dev/null 2>&1; then
printf '%s\n' 'FAIL: source-only build silently accepted unavailable Argon2id' >&2
exit 1
fi
fi
printf '%s\n' 'PASS: password prompt/file/fd validation and encrypted round-trip'

View file

@ -1,156 +1,264 @@
#!/bin/bash
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Path traversal / Zip Slip regression tests.
#
# Verifies that the v2.2.2 audit fixes for CVE-pattern path traversal
# (Snyk Zip Slip 2018) and symlink-following on extract are working.
#
# Tests construct malicious archives in two ways:
# (A) compress with a relative path then post-mutate the index (manual fuzz)
# (B) try to extract into a directory containing a symlink with the same
# name as an archive entry — should be refused due to O_NOFOLLOW.
# Extraction confinement and atomic-output regression tests.
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
cd "$TMPDIR"
set -Eeuo pipefail
PASS=0; FAIL=0
chk() {
if [ $? -eq 0 ]; then echo "$1"; PASS=$((PASS+1))
else echo "$1"; FAIL=$((FAIL+1)); fi
REPO_ROOT=$(pwd -P)
ZUPT_BIN=${1:-$REPO_ROOT/zupt}
case $ZUPT_BIN in
/*) ;;
*) ZUPT_BIN=$REPO_ROOT/${ZUPT_BIN#./} ;;
esac
TEST_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/zupt-path-traversal.XXXXXX")
cleanup() {
local status=$?
chmod -R u+rwX "$TEST_ROOT" 2>/dev/null || true
rm -rf -- "$TEST_ROOT"
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' HUP INT TERM
PASS=0
FAIL=0
SKIP=0
pass() { printf ' PASS: %s\n' "$1"; PASS=$((PASS + 1)); }
fail() { printf ' FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); }
skip() { printf ' SKIP: %s\n' "$1"; SKIP=$((SKIP + 1)); }
WINDOWS_NATIVE=0
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*) WINDOWS_NATIVE=1 ;;
esac
FIXTURE=$TEST_ROOT/archive-path-fixture
# Compiler and linker flag variables intentionally expand into argument lists,
# matching the Makefile command-line contract.
# shellcheck disable=SC2086
"${CC:-cc}" ${CPPFLAGS:-} ${CFLAGS:-} -std=c11 -I"$REPO_ROOT/include" \
"$REPO_ROOT/tests/archive_path_fixture.c" "$REPO_ROOT/src/zupt_xxh.c" \
${LDFLAGS:-} ${LDLIBS:-} -o "$FIXTURE"
make_fixture() {
MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$1" "--entry=$2"
# Prove that the archive passed header, trailer, index-block, index checksum,
# decompression, and index parsing before using it as a negative fixture.
"$ZUPT_BIN" list "$1" > "$TEST_ROOT/list.log" 2>&1
grep -F -- "$2" "$TEST_ROOT/list.log" >/dev/null
}
# ─── Property 1: archive with ".." entry must not extract above target ──
# Strategy: compress an innocent file, then patch the archive's index to
# replace the path with "../../escaped.txt". Extract into a subdir;
# verify the file appears nowhere outside the subdir.
echo " [P1. Zip Slip — relative path traversal blocked]"
expect_unsafe_path() {
local label=$1 archive=$2 entry=$3 output=$4 log=$TEST_ROOT/extract.log rc
make_fixture "$archive" "$entry"
mkdir -p "$output"
set +e
"$ZUPT_BIN" extract -o "$output" "$archive" > "$log" 2>&1
rc=$?
set -e
if ((rc != 0)) && grep -F 'rejected unsafe path' "$log" >/dev/null; then
pass "$label"
else
fail "$label"
fi
}
mkdir input output_safe
echo "secret content" > input/innocent.txt
"$ZUPT_BIN" c slip.zupt input/innocent.txt > /dev/null 2>&1
cd "$TEST_ROOT"
# Patch the archive: replace "input/innocent.txt" path string with
# "../escape.txt" in the index. We use a python helper because the index
# is varint-prefixed and we need to keep length consistent.
python3 << 'PYEOF'
expect_unsafe_path 'relative .. entry is rejected after a valid index parse' \
"$TEST_ROOT/relative.zupt" '../escaped.txt' "$TEST_ROOT/relative-out"
[[ ! -e $TEST_ROOT/escaped.txt ]] || fail 'relative traversal wrote outside root'
ABSOLUTE_TARGET=$TEST_ROOT/absolute-owned
expect_unsafe_path 'absolute entry is rejected after a valid index parse' \
"$TEST_ROOT/absolute.zupt" "$ABSOLUTE_TARGET" "$TEST_ROOT/absolute-out"
if [[ ! -e $ABSOLUTE_TARGET ]]; then
pass 'absolute target remains absent'
else
fail 'absolute target remains absent'
fi
for case_data in \
'trailing-space|dir/.. ' \
'alternate-data-stream|dir/name:stream' \
'reserved-device|dir/CON' \
'reserved-device-extension|dir/LPT1.txt' \
'trailing-dot|dir/file.'; do
label=${case_data%%|*}
entry=${case_data#*|}
expect_unsafe_path "Windows-normalized $label path is rejected" \
"$TEST_ROOT/$label.zupt" "$entry" "$TEST_ROOT/$label-out"
done
control_entry=$'safe\033[31mRED\033[0m.txt'
MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$TEST_ROOT/control.zupt" \
"--entry=$control_entry"
set +e
"$ZUPT_BIN" list "$TEST_ROOT/control.zupt" \
> "$TEST_ROOT/control.log" 2>&1
control_status=$?
set -e
if ((control_status != 0)) && ! grep -q $'\033' "$TEST_ROOT/control.log"; then
pass 'control-byte archive path is rejected without terminal injection'
else
fail 'control-byte archive path is rejected without terminal injection'
fi
expect_display_unsafe_path_rejected() {
local label=$1 name=$2 entry=$3
local archive=$TEST_ROOT/$name.zupt log=$TEST_ROOT/$name.log status
MSYS2_ARG_CONV_EXCL='--entry=' "$FIXTURE" "$archive" "--entry=$entry"
set +e
"$ZUPT_BIN" list "$archive" > "$log" 2>&1
status=$?
set -e
if ((status != 0)) && ! LC_ALL=C grep -Fq -- "$entry" "$log"; then
pass "$label"
else
fail "$label"
fi
}
expect_display_unsafe_path_rejected \
'raw C1 archive path is rejected without terminal injection' \
raw-c1 $'safe\23331m.txt'
expect_display_unsafe_path_rejected \
'UTF-8 C1 archive path is rejected without terminal injection' \
utf8-c1 $'safe\302\23331m.txt'
expect_display_unsafe_path_rejected \
'Unicode bidi-control archive path is rejected without display spoofing' \
bidi $'safe\342\200\256exe.txt'
expect_display_unsafe_path_rejected \
'invalid UTF-8 archive path is rejected without raw display' \
invalid-utf8 $'safe\300\257.txt'
make_fixture "$TEST_ROOT/leaf.zupt" 'innocent.txt'
printf '%s\n' DO_NOT_OVERWRITE > "$TEST_ROOT/sentinel"
mkdir "$TEST_ROOT/leaf-out"
if ln -s "$TEST_ROOT/sentinel" "$TEST_ROOT/leaf-out/innocent.txt" \
2>/dev/null && [[ -L $TEST_ROOT/leaf-out/innocent.txt ]]; then
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/leaf-out" "$TEST_ROOT/leaf.zupt" \
> "$TEST_ROOT/leaf.log" 2>&1 &&
[[ $(<"$TEST_ROOT/sentinel") == DO_NOT_OVERWRITE ]]; then
pass 'leaf symlink is refused without changing its target'
else
fail 'leaf symlink is refused without changing its target'
fi
else
skip 'leaf symlink test is unsupported by this runner'
fi
mkdir "$TEST_ROOT/regular-out"
printf '%s\n' EXISTING > "$TEST_ROOT/regular-out/innocent.txt"
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/regular-out" "$TEST_ROOT/leaf.zupt" \
> "$TEST_ROOT/regular.log" 2>&1 &&
[[ $(<"$TEST_ROOT/regular-out/innocent.txt") == EXISTING ]]; then
pass 'existing regular file is never overwritten'
else
fail 'existing regular file is never overwritten'
fi
mkdir "$TEST_ROOT/hardlink-out"
printf '%s\n' HARDLINK_SENTINEL > "$TEST_ROOT/hardlink-target"
if ln "$TEST_ROOT/hardlink-target" "$TEST_ROOT/hardlink-out/innocent.txt" 2>/dev/null; then
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/hardlink-out" "$TEST_ROOT/leaf.zupt" \
> "$TEST_ROOT/hardlink.log" 2>&1 &&
[[ $(<"$TEST_ROOT/hardlink-target") == HARDLINK_SENTINEL ]]; then
pass 'existing hardlink is never overwritten'
else
fail 'existing hardlink is never overwritten'
fi
else
skip 'hardlink test is unsupported by the temporary filesystem'
fi
make_fixture "$TEST_ROOT/parent.zupt" 'nested/file.txt'
mkdir "$TEST_ROOT/parent-out" "$TEST_ROOT/parent-outside"
if ln -s "$TEST_ROOT/parent-outside" "$TEST_ROOT/parent-out/nested" \
2>/dev/null && [[ -L $TEST_ROOT/parent-out/nested ]]; then
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/parent-out" "$TEST_ROOT/parent.zupt" \
> "$TEST_ROOT/parent.log" 2>&1 &&
[[ -z $(find "$TEST_ROOT/parent-outside" -mindepth 1 -print -quit) ]]; then
pass 'intermediate symlink cannot redirect extraction or directory creation'
else
fail 'intermediate symlink cannot redirect extraction or directory creation'
fi
else
skip 'intermediate symlink test is unsupported by this runner'
fi
mkdir "$TEST_ROOT/root-outside"
if ln -s "$TEST_ROOT/root-outside" "$TEST_ROOT/root-link" 2>/dev/null &&
[[ -L $TEST_ROOT/root-link ]]; then
if ((WINDOWS_NATIVE)); then
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \
"$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 &&
[[ -z $(find "$TEST_ROOT/root-outside" -mindepth 1 -print -quit) ]]; then
pass 'Windows rejects a reparse-point output-root ancestor'
else
fail 'Windows rejects a reparse-point output-root ancestor'
fi
elif "$ZUPT_BIN" extract -o "$TEST_ROOT/root-link/child" \
"$TEST_ROOT/leaf.zupt" > "$TEST_ROOT/root-link.log" 2>&1 &&
[[ $(<"$TEST_ROOT/root-outside/child/innocent.txt") == 'fixture content' ]]; then
pass 'user-selected POSIX output-root symlink is resolved once'
else
fail 'user-selected POSIX output-root symlink is resolved once'
fi
else
skip 'output-root symlink test is unsupported by this runner'
fi
make_fixture "$TEST_ROOT/backslash.zupt" 'back\slash.txt'
mkdir "$TEST_ROOT/backslash-out"
if "$ZUPT_BIN" extract -o "$TEST_ROOT/backslash-out" "$TEST_ROOT/backslash.zupt" \
> "$TEST_ROOT/backslash.log" 2>&1 &&
[[ -f $TEST_ROOT/backslash-out/back/slash.txt ]]; then
pass 'backslash separators are normalized within the extraction root'
else
fail 'backslash separators are normalized within the extraction root'
fi
make_fixture "$TEST_ROOT/legitimate.zupt" 'safe dir/ação.txt'
mkdir "$TEST_ROOT/legitimate-out"
if "$ZUPT_BIN" extract -o "$TEST_ROOT/legitimate-out" \
"$TEST_ROOT/legitimate.zupt" > "$TEST_ROOT/legitimate.log" 2>&1 &&
[[ $(<"$TEST_ROOT/legitimate-out/safe dir/ação.txt") == 'fixture content' ]]; then
pass 'safe nested UTF-8 path extracts normally'
else
fail 'safe nested UTF-8 path extracts normally'
fi
mkdir -p "$TEST_ROOT/relative-root/work"
if (cd "$TEST_ROOT/relative-root/work" &&
"$ZUPT_BIN" extract -o ../restore "$TEST_ROOT/leaf.zupt" \
> "$TEST_ROOT/relative-root.log" 2>&1) &&
[[ -f $TEST_ROOT/relative-root/restore/innocent.txt ]]; then
pass 'user-selected output root may contain a relative .. component'
else
fail 'user-selected output root may contain a relative .. component'
fi
cp "$TEST_ROOT/leaf.zupt" "$TEST_ROOT/corrupt.zupt"
python3 - "$TEST_ROOT/corrupt.zupt" <<'PY'
import pathlib
import sys
data = bytearray(open('slip.zupt','rb').read())
target = b'input/innocent.txt'
replacement = b'../escape.txt'
# Pad replacement to same length so varint length prefix stays valid
pad = b'\x00' * (len(target) - len(replacement))
i = data.find(target)
if i < 0:
print("ERROR: pattern not in archive")
sys.exit(1)
# Replace the bytes — note this will fail validation below, which is OK,
# we want to see if extract REJECTS the malformed path.
data[i:i+len(target)] = replacement + pad
open('slip_patched.zupt','wb').write(bytes(data))
PYEOF
# Try to extract — even if the patched archive is corrupt, we want to
# verify that NO file appears at "../escape.txt" relative to output_safe.
cd output_safe
"$ZUPT_BIN" x ../slip_patched.zupt > /dev/null 2>&1
cd ..
path = pathlib.Path(sys.argv[1])
data = bytearray(path.read_bytes())
# Header (64) + data-block fixed/varint header (17): first payload byte.
data[81] ^= 0x01
path.write_bytes(data)
PY
mkdir "$TEST_ROOT/corrupt-out"
if ! "$ZUPT_BIN" extract -o "$TEST_ROOT/corrupt-out" "$TEST_ROOT/corrupt.zupt" \
> "$TEST_ROOT/corrupt.log" 2>&1 &&
[[ -z $(find "$TEST_ROOT/corrupt-out" -mindepth 1 -print -quit) ]]; then
pass 'corrupt payload leaves neither a final nor temporary output file'
else
fail 'corrupt payload leaves neither a final nor temporary output file'
fi
# The key invariant: nothing escaped to TMPDIR (parent of output_safe)
[ ! -f "$TMPDIR/escape.txt" ] && [ ! -f escape.txt ]
chk "No escape via patched ../escape.txt path"
# ─── Property 2: archive with absolute path must not write to that path ──
echo " [P2. Absolute path entries blocked]"
# Construct an archive entry with absolute "/tmp/owned.txt" via patching
echo "innocent" > input2.txt
"$ZUPT_BIN" c abs.zupt input2.txt > /dev/null 2>&1
python3 << 'PYEOF'
data = bytearray(open('abs.zupt','rb').read())
target = b'input2.txt'
# Replace with absolute path of equal length
replacement = b'/tmp/owned' # 10 chars vs 10 chars
i = data.find(target)
if i >= 0:
data[i:i+len(target)] = replacement
open('abs_patched.zupt','wb').write(bytes(data))
PYEOF
mkdir abs_extract
cd abs_extract
"$ZUPT_BIN" x ../abs_patched.zupt > /dev/null 2>&1
cd ..
[ ! -f /tmp/owned ]
chk "Absolute /tmp/owned path rejected"
# ─── Property 3: symlink at output target is not followed ──────────────
# Pre-place a symlink in output dir pointing to a sentinel file.
# Extract an archive with the same entry name; verify the sentinel is
# unchanged (i.e. extract refused to follow the symlink).
echo " [P3. Symlink at extract target not followed]"
echo "DO_NOT_OVERWRITE" > sentinel.txt
mkdir symlink_extract
ln -s "$(pwd)/sentinel.txt" symlink_extract/innocent.txt
# Build a fresh non-patched archive with "innocent.txt"
mkdir input3 && echo "evil overwrite content" > input3/innocent.txt
"$ZUPT_BIN" c clean.zupt input3/innocent.txt > /dev/null 2>&1
# Mutate path "input3/innocent.txt" -> "innocent.txt" so it lands at the symlink
python3 << 'PYEOF'
data = bytearray(open('clean.zupt','rb').read())
target = b'input3/innocent.txt'
replacement = b'innocent.txt' + (b'\x00' * (len(target) - len(b'innocent.txt')))
i = data.find(target)
if i >= 0:
data[i:i+len(target)] = replacement
open('clean_patched.zupt','wb').write(bytes(data))
PYEOF
cd symlink_extract
"$ZUPT_BIN" x ../clean_patched.zupt > /dev/null 2>&1
cd ..
# Sentinel must be unchanged — symlink follow would have overwritten it
content=$(cat sentinel.txt)
[ "$content" = "DO_NOT_OVERWRITE" ]
chk "Sentinel via symlink not overwritten"
# ─── Property 4: legitimate paths still extract correctly ─────────────
echo " [P4. Legitimate (safe) paths still extract]"
mkdir legit_input
echo "ok content" > legit_input/normal.txt
"$ZUPT_BIN" c legit.zupt legit_input/normal.txt > /dev/null 2>&1
mkdir legit_extract && cd legit_extract
"$ZUPT_BIN" x ../legit.zupt > /dev/null 2>&1
cd ..
[ -f legit_extract/legit_input/normal.txt ] && \
[ "$(cat legit_extract/legit_input/normal.txt)" = "ok content" ]
chk "Normal extraction still works"
# ─── Property 5: deep path (allowed) but parent dir is created ─────────
echo " [P5. Multi-component safe paths still work]"
mkdir deep && mkdir deep/sub && mkdir deep/sub/sub2
echo "deep" > deep/sub/sub2/file.txt
"$ZUPT_BIN" c deep.zupt deep/sub/sub2/file.txt > /dev/null 2>&1
mkdir deep_extract && cd deep_extract
"$ZUPT_BIN" x ../deep.zupt > /dev/null 2>&1
cd ..
[ -f deep_extract/deep/sub/sub2/file.txt ]
chk "Deep nested path extracted"
echo
echo " ───────────────────────────────────────"
echo " Path-traversal regression: $PASS passed, $FAIL failed"
echo " ───────────────────────────────────────"
[ $FAIL -eq 0 ]
printf '\n Path-confinement regression: %d PASS, %d FAIL, %d SKIP\n' \
"$PASS" "$FAIL" "$SKIP"
((FAIL == 0))

View file

@ -1,88 +1,135 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2026 Cristian Cezar Moisés
#
# ZUPT_ENC_PQ_BOX_V1 (--pq-box, vendored libpqvaptvupt) — functional and
# adversarial coverage: keygen file format, byte-exact roundtrips on both
# frame formats, wrong-key and key-type-confusion rejection, envelope and
# data tampering, and cross-mode isolation.
# Functional and adversarial coverage for the optional system libpqvaptvupt.
set -u
P=0; F=0
ok() { echo "$1"; P=$((P+1)); }
bad() { echo "$1"; F=$((F+1)); }
T=$(mktemp -d)
FX=/tmp/bench/fixtures
BIN=./vaptvupt
# Source-only build (WITH_SDK=0) has no libzuptsdk: the SDK-mode paths this
# test exercises are unavailable, so skip cleanly instead of failing.
_sdkck="$(mktemp -d)"
if ! "$BIN" keygen --box -o "$_sdkck/p" >/dev/null 2>&1; then
rm -rf "$_sdkck"; echo " SKIP: built without libzuptsdk (source-only) - SDK-mode test not applicable"; exit 0
set -Eeuo pipefail
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
zupt=${ZUPT_BIN:-$repo_root/zupt}
if [[ ! -x $zupt ]]; then
printf ' FAIL: %s not found; build ZUPT first\n' "$zupt" >&2
exit 1
fi
rm -rf "$_sdkck"
version=$("$zupt" --version 2>&1)
if ! grep -Fq 'libpqvaptvupt=enabled' <<<"$version"; then
echo ' SKIP: system libpqvaptvupt integration is disabled (build with WITH_PQBOX=1)'
exit 0
fi
echo "pq-box mode (ZUPT_ENC_PQ_BOX_V1)"
tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT
cd "$tmpdir"
# 1. keygen + file format
$BIN keygen --box -o $T/k.key >/dev/null 2>&1
[ "$(stat -c%s $T/k.key 2>/dev/null)" = "2441" ] && ok "secret keyfile size (9+2432)" || bad "secret keyfile size"
[ "$(stat -c%s $T/k.key.pub 2>/dev/null)" = "1225" ] && ok "public keyfile size (9+1216)" || bad "public keyfile size"
head -c8 $T/k.key | grep -q "PQVVBOX1" && ok "keyfile magic" || bad "keyfile magic"
passed=0
failed=0
pass() { printf ' ✓ %s\n' "$1"; passed=$((passed + 1)); }
fail() { printf ' ✗ %s\n' "$1"; failed=$((failed + 1)); }
# 2. roundtrips: L1 (v1 frame) and L9 (format_v2 + auto-filter), text + binary
for case in "1 text" "9 text" "9 binary"; do
set -- $case; L=$1; fx=$2
$BIN c -l $L --pq-box $T/k.key.pub $T/a$L$fx.zupt $FX/$fx.dat >/dev/null 2>&1
rm -rf $T/o$L$fx; mkdir -p $T/o$L$fx
$BIN x --pq-box $T/k.key -o $T/o$L$fx $T/a$L$fx.zupt >/dev/null 2>&1
Fp=$(find $T/o$L$fx -type f | head -1)
[ -n "$Fp" ] && diff -q "$Fp" $FX/$fx.dat >/dev/null 2>&1 \
&& ok "roundtrip L$L $fx byte-exact" || bad "roundtrip L$L $fx"
echo 'pq-box mode (ZUPT_ENC_PQ_BOX_V1)'
if "$zupt" keygen --box -o k.key >/dev/null 2>&1 &&
[[ -f k.key && -f k.key.pub ]]; then
pass 'pq-box keygen produces private and public key files'
else
fail 'pq-box keygen using system libpqvaptvupt'
exit 1
fi
if [[ $(wc -c <k.key) -eq 2441 ]]; then
pass 'secret keyfile size is 9+2432 bytes'
else
fail 'secret keyfile size'
fi
if [[ $(wc -c <k.key.pub) -eq 1225 ]]; then
pass 'public keyfile size is 9+1216 bytes'
else
fail 'public keyfile size'
fi
if [[ $(head -c 8 k.key) == PQVVBOX1 ]]; then
pass 'keyfile magic is PQVVBOX1'
else
fail 'keyfile magic'
fi
printf 'ZUPT pq-box text fixture with UTF-8: segurança\n' >text.dat
dd if=/dev/urandom of=binary.dat bs=65536 count=4 2>/dev/null
for level in 1 9 9; do
if [[ $level -eq 1 ]]; then
fixture=text.dat
label='L1 text'
elif [[ ! -e a9text.zupt ]]; then
fixture=text.dat
label='L9 text'
else
fixture=binary.dat
label='L9 binary'
fi
archive="a${level}${fixture%.dat}.zupt"
outdir="out-${level}-${fixture%.dat}"
if "$zupt" c -l "$level" --pq-box k.key.pub "$archive" "$fixture" >/dev/null 2>&1; then
mkdir "$outdir"
if "$zupt" x --pq-box k.key -o "$outdir" "$archive" >/dev/null 2>&1 &&
cmp -s "$fixture" "$outdir/$fixture"; then
pass "roundtrip $label is byte-exact"
else
fail "roundtrip $label is byte-exact"
fi
else
fail "encrypt $label"
fi
done
# 3. wrong key rejected
$BIN keygen --box -o $T/w.key >/dev/null 2>&1
rm -rf $T/ow; mkdir -p $T/ow
$BIN x --pq-box $T/w.key -o $T/ow $T/a9text.zupt >/dev/null 2>&1 \
&& bad "wrong key accepted" || ok "wrong key rejected"
"$zupt" keygen --box -o wrong.key >/dev/null 2>&1
mkdir wrong-out
if "$zupt" x --pq-box wrong.key -o wrong-out a9text.zupt >/dev/null 2>&1; then
fail 'wrong pq-box key is rejected'
else
pass 'wrong pq-box key is rejected'
fi
# 4. key-type confusion rejected (pub-as-priv, priv-as-pub, legacy key)
rm -rf $T/oc; mkdir -p $T/oc
$BIN x --pq-box $T/k.key.pub -o $T/oc $T/a9text.zupt >/dev/null 2>&1 \
&& bad "PUBLIC key accepted as secret" || ok "public-as-secret rejected"
$BIN c -l 1 --pq-box $T/k.key $T/cc.zupt $FX/text.dat >/dev/null 2>&1 \
&& bad "SECRET key accepted as public" || ok "secret-as-public rejected"
$BIN keygen -o $T/legacy.key >/dev/null 2>&1
rm -rf $T/ol; mkdir -p $T/ol
$BIN x --pq-box $T/legacy.key -o $T/ol $T/a9text.zupt >/dev/null 2>&1 \
&& bad "legacy key accepted on box archive" || ok "legacy-key-on-box rejected"
mkdir confusion-out
if "$zupt" x --pq-box k.key.pub -o confusion-out a9text.zupt >/dev/null 2>&1; then
fail 'public key is rejected as a secret key'
else
pass 'public key is rejected as a secret key'
fi
if "$zupt" c -l 1 --pq-box k.key secret-as-public.zupt text.dat >/dev/null 2>&1; then
fail 'secret key is rejected as a public key'
else
pass 'secret key is rejected as a public key'
fi
"$zupt" keygen -o native.key >/dev/null 2>&1
mkdir native-confusion-out
if "$zupt" x --pq-box native.key -o native-confusion-out a9text.zupt >/dev/null 2>&1; then
fail 'native key is rejected for a pq-box archive'
else
pass 'native key is rejected for a pq-box archive'
fi
# 5. tamper: envelope byte (offset inside the sealed blob) and data region
for spot in 64 -1024; do
cp $T/a9text.zupt $T/t.zupt
python3 - "$T/t.zupt" "$spot" << 'PY'
import sys
p, off = sys.argv[1], int(sys.argv[2])
d = bytearray(open(p,'rb').read())
i = off if off >= 0 else len(d)+off
d[i] ^= 0x01
open(p,'wb').write(d)
PY
rm -rf $T/ot; mkdir -p $T/ot
$BIN x --pq-box $T/k.key -o $T/ot $T/t.zupt >/dev/null 2>&1 \
&& bad "tamper@$spot accepted" || ok "tamper@$spot rejected"
for position in envelope body; do
kind=data
[[ $position == envelope ]] && kind=enc
python3 "$repo_root/tests/archive_surgery.py" flip-payload \
a9text.zupt "tampered-$position.zupt" --kind "$kind" \
--require-encrypted
mkdir "tampered-out-$position"
if "$zupt" x --pq-box k.key -o "tampered-out-$position" \
"tampered-$position.zupt" >/dev/null 2>&1; then
fail "$position tamper is rejected"
else
pass "$position tamper is rejected"
fi
done
# 6. cross-mode isolation: box archive demands box key, not password
rm -rf $T/op; mkdir -p $T/op
$BIN x -p somepass -o $T/op $T/a9text.zupt >/dev/null 2>&1 \
&& bad "password accepted on box archive" || ok "password-on-box rejected"
mkdir password-out
if "$zupt" x -p somepass -o password-out a9text.zupt >/dev/null 2>&1; then
fail 'password mode is rejected for a pq-box archive'
else
pass 'password mode is rejected for a pq-box archive'
fi
echo ""
echo " ───────────────────────────────────────"
echo " pq-box: $P passed, $F failed"
echo " ───────────────────────────────────────"
rm -rf $T
exit $([ $F -eq 0 ] && echo 0 || echo 1)
printf '\n pq-box: %d passed, %d failed\n' "$passed" "$failed"
((failed == 0))

View file

@ -1,75 +1,118 @@
#!/bin/bash
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
# Test zupt SDK-backed PQ encryption (v2.2+)
# Functional and adversarial coverage for the optional system libvuptsdk.
set -Eeuo pipefail
cd "$(dirname "$0")/.."
ZUPT_BIN="$(realpath ./zupt)"
TMPDIR=$(mktemp -d)
trap "rm -rf $TMPDIR" EXIT
repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd -P)
zupt=${ZUPT_BIN:-$repo_root/zupt}
cd "$TMPDIR"
PASS=0; FAIL=0
chk() { if [ $? -eq 0 ]; then echo " OK: $1"; PASS=$((PASS+1)); else echo " FAIL: $1"; FAIL=$((FAIL+1)); fi; }
chk_neg() { if [ $? -ne 0 ]; then echo " OK: $1"; PASS=$((PASS+1)); else echo " FAIL: $1 (should have failed)"; FAIL=$((FAIL+1)); fi; }
if [[ ! -x $zupt ]]; then
printf ' FAIL: %s not found; build ZUPT first\n' "$zupt" >&2
exit 1
fi
# Setup: real keypair
"$ZUPT_BIN" keygen --sdk -o key.priv > /dev/null 2>&1
[ -f key.priv ] && [ -f key.priv.pub ]
chk "SDK keygen produces both files"
version=$("$zupt" --version 2>&1)
if ! grep -Fq 'libvuptsdk=enabled' <<<"$version"; then
echo ' SKIP: system libvuptsdk integration is disabled (build with WITH_SDK=1)'
exit 0
fi
# Test data
echo "Hello SDK PQ encryption" > input.txt
dd if=/dev/urandom of=large.bin bs=64K count=4 2>/dev/null
tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT
cd "$tmpdir"
# Roundtrip small file
"$ZUPT_BIN" c --pq-sdk key.priv.pub small.zupt input.txt > /dev/null 2>&1
chk "SDK encrypt small"
mkdir -p extract1 && cd extract1
"$ZUPT_BIN" x --pq-sdk ../key.priv ../small.zupt > /dev/null 2>&1
chk "SDK decrypt small"
diff -q input.txt ../input.txt > /dev/null 2>&1
chk "SDK small roundtrip byte-exact"
cd ..
passed=0
failed=0
pass() { printf ' OK: %s\n' "$1"; passed=$((passed + 1)); }
fail() { printf ' FAIL: %s\n' "$1"; failed=$((failed + 1)); }
# Roundtrip large file
"$ZUPT_BIN" c --pq-sdk key.priv.pub large.zupt large.bin > /dev/null 2>&1
chk "SDK encrypt large (256KB)"
mkdir -p extract2 && cd extract2
"$ZUPT_BIN" x --pq-sdk ../key.priv ../large.zupt > /dev/null 2>&1
chk "SDK decrypt large"
diff -q large.bin ../large.bin > /dev/null 2>&1
chk "SDK large roundtrip byte-exact"
cd ..
if "$zupt" keygen --sdk -o key.priv >/dev/null 2>&1 &&
[[ -f key.priv && -f key.priv.pub ]]; then
pass 'SDK keygen produces private and public key files'
else
fail 'SDK keygen using system libvuptsdk'
exit 1
fi
# Wrong key rejected
"$ZUPT_BIN" keygen --sdk -o other.priv > /dev/null 2>&1
"$ZUPT_BIN" x --pq-sdk other.priv small.zupt > /dev/null 2>&1
chk_neg "SDK wrong key rejected"
printf 'Hello SDK PQ encryption\n' >input.txt
dd if=/dev/urandom of=large.bin bs=65536 count=4 2>/dev/null
if "$zupt" c --pq-sdk key.priv.pub small.zupt input.txt >/dev/null 2>&1; then
pass 'SDK encrypts a small file'
else
fail 'SDK encrypts a small file'
fi
mkdir extract1
if (cd extract1 && "$zupt" x --pq-sdk ../key.priv ../small.zupt >/dev/null 2>&1); then
pass 'SDK decrypts a small file'
else
fail 'SDK decrypts a small file'
fi
if cmp -s input.txt extract1/input.txt; then
pass 'SDK small roundtrip is byte-exact'
else
fail 'SDK small roundtrip is byte-exact'
fi
if "$zupt" c --pq-sdk key.priv.pub large.zupt large.bin >/dev/null 2>&1; then
pass 'SDK encrypts a 256 KiB file'
else
fail 'SDK encrypts a 256 KiB file'
fi
mkdir extract2
if (cd extract2 && "$zupt" x --pq-sdk ../key.priv ../large.zupt >/dev/null 2>&1); then
pass 'SDK decrypts a 256 KiB file'
else
fail 'SDK decrypts a 256 KiB file'
fi
if cmp -s large.bin extract2/large.bin; then
pass 'SDK large roundtrip is byte-exact'
else
fail 'SDK large roundtrip is byte-exact'
fi
"$zupt" keygen --sdk -o other.priv >/dev/null 2>&1
mkdir wrong-key
if (cd wrong-key && "$zupt" x --pq-sdk ../other.priv ../small.zupt >/dev/null 2>&1); then
fail 'SDK rejects the wrong private key'
else
pass 'SDK rejects the wrong private key'
fi
# Tamper detected.
# F-02 (Zupt 2.2.4): use a deterministic body-region offset, not
# len-50 which occasionally landed in the unauthenticated index
# region. See docs/FINDINGS-2.x.md F-02 for the full analysis.
cp small.zupt tampered.zupt
python3 -c "
b = bytearray(open('tampered.zupt','rb').read())
b[200] ^= 1
open('tampered.zupt','wb').write(bytes(b))
"
"$ZUPT_BIN" x --pq-sdk key.priv tampered.zupt > /dev/null 2>&1
chk_neg "SDK tampered ciphertext rejected"
python3 - <<'PY'
from pathlib import Path
# Legacy v1 compat: legacy --pq still works
"$ZUPT_BIN" keygen -o legacy.key > /dev/null 2>&1
"$ZUPT_BIN" c --pq legacy.key legacy.zupt input.txt > /dev/null 2>&1
chk "Legacy --pq still encrypts"
mkdir -p extract3 && cd extract3
"$ZUPT_BIN" x --pq ../legacy.key ../legacy.zupt > /dev/null 2>&1
chk "Legacy --pq still decrypts"
cd ..
path = Path("tampered.zupt")
data = bytearray(path.read_bytes())
if len(data) <= 200:
raise SystemExit("archive too small for deterministic body tamper")
data[200] ^= 1
path.write_bytes(data)
PY
mkdir tampered
if (cd tampered && "$zupt" x --pq-sdk ../key.priv ../tampered.zupt >/dev/null 2>&1); then
fail 'SDK rejects tampered ciphertext'
else
pass 'SDK rejects tampered ciphertext'
fi
echo
echo " Results: $PASS passed, $FAIL failed ($((PASS+FAIL)) tests)"
[ $FAIL -eq 0 ]
"$zupt" keygen -o native.key >/dev/null 2>&1
if "$zupt" c --pq native.key native.zupt input.txt >/dev/null 2>&1; then
pass 'native --pq encryption remains available'
else
fail 'native --pq encryption remains available'
fi
mkdir native-out
if (cd native-out && "$zupt" x --pq ../native.key ../native.zupt >/dev/null 2>&1) &&
cmp -s input.txt native-out/input.txt; then
pass 'native --pq roundtrip remains byte-exact'
else
fail 'native --pq roundtrip remains byte-exact'
fi
printf '\n Results: %d passed, %d failed (%d tests)\n' \
"$passed" "$failed" "$((passed + failed))"
((failed == 0))

425
tests/test_source_only.sh Executable file
View file

@ -0,0 +1,425 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -Eeuo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)
SCANNER=$ROOT/scripts/check-source-only.sh
TEST_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-source-only-tests.XXXXXXXX")
PASSED=0
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
rm -rf -- "$TEST_TMP"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
pass() {
PASSED=$((PASSED + 1))
printf 'ok %d - %s\n' "$PASSED" "$1"
}
skip() {
PASSED=$((PASSED + 1))
printf 'ok %d - %s # SKIP\n' "$PASSED" "$1"
}
expect_pass_tree() {
local name=$1 tree=$2 output=$TEST_TMP/output
if "$SCANNER" --tree "$tree" >"$output" 2>&1 && grep -q '^PASS source-only:' "$output"; then
pass "$name"
else
printf 'not ok - %s\n' "$name"
sed -n '1,120p' "$output"
exit 1
fi
}
expect_fail_tree() {
local name=$1 tree=$2 expected=${3:-} output=$TEST_TMP/output
if "$SCANNER" --tree "$tree" >"$output" 2>&1; then
printf 'not ok - %s (scanner unexpectedly passed)\n' "$name"
exit 1
fi
grep -q '^FAIL ' "$output" || {
printf 'not ok - %s (missing FAIL finding)\n' "$name"
exit 1
}
grep -q '^FAIL source-only:' "$output" || {
printf 'not ok - %s (missing FAIL summary)\n' "$name"
exit 1
}
if [[ -n $expected ]] && ! grep -Fq -- "$expected" "$output"; then
printf 'not ok - %s (missing expected path)\n' "$name"
exit 1
fi
pass "$name"
}
expect_fail_archive_with_limits() {
local name=$1 archive=$2 expected=$3
shift 3
local output=$TEST_TMP/output
if env "$@" "$SCANNER" --archive "$archive" >"$output" 2>&1; then
printf 'not ok - %s (scanner unexpectedly passed)\n' "$name"
exit 1
fi
if ! grep -Fq -- "$expected" "$output"; then
printf 'not ok - %s (missing expected bounded-archive finding)\n' "$name"
sed -n '1,120p' "$output"
exit 1
fi
pass "$name"
}
fresh_tree() {
local name=$1
mkdir -p "$TEST_TMP/$name"
printf '%s' "$TEST_TMP/$name"
}
safe=$(fresh_tree safe)
mkdir -p "$safe/src" "$safe/assets"
printf '#include <stdio.h>\nint main(void) { return 0; }\n' >"$safe/src/main.c"
printf '.text\n.globl portable_symbol\nportable_symbol:\n ret\n' >"$safe/src/portable.S"
printf '\211PNG\r\n\032\n' >"$safe/assets/icon.png"
printf '\000\000\001\000' >"$safe/assets/icon.ico"
if ln -s src/main.c "$safe/main-link.c" 2>/dev/null &&
[[ -L $safe/main-link.c ]]; then
SYMLINKS_SUPPORTED=1
safe_label='text source, assembly, PNG, ICO, and internal symlink pass'
else
SYMLINKS_SUPPORTED=0
safe_label='text source, assembly, PNG, and ICO pass (symlink unavailable)'
fi
expect_pass_tree "$safe_label" "$safe"
tree=$(fresh_tree undeclared-bin)
printf '\001\002\003fixture data\n' >"$tree/vector.bin"
expect_fail_tree 'undeclared .bin data is rejected' "$tree" vector.bin
tree=$(fresh_tree declared-bin)
mkdir -p "$tree/tests/data"
printf '\001\002\003fixture data\n' >"$tree/tests/data/vector.bin"
manifest=$TEST_TMP/source-data.tsv
printf 'tests/data/vector.bin\ttest vector\tgenerated by test_source_only.sh\tAGPL-3.0-or-later\n' >"$manifest"
if "$SCANNER" --data-manifest "$manifest" --tree "$tree" >"$TEST_TMP/output" 2>&1 &&
grep -q '^PASS source-only:' "$TEST_TMP/output"; then
pass 'declared non-executable .bin fixture passes with complete metadata'
else
printf 'not ok - declared non-executable .bin fixture passes\n'
sed -n '1,120p' "$TEST_TMP/output"
exit 1
fi
printf '\177ELF\002\001\001\000compiled' >"$tree/tests/data/vector.bin"
if "$SCANNER" --data-manifest "$manifest" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - manifest cannot allow executable magic\n'
exit 1
elif grep -Fq 'tests/data/vector.bin' "$TEST_TMP/output"; then
pass 'data manifest cannot exempt executable magic'
else
printf 'not ok - executable magic path missing from manifest test\n'
exit 1
fi
tree=$(fresh_tree elf)
printf '\177ELF\002\001\001\000compiled' >"$tree/renamed.txt"
expect_fail_tree 'ELF renamed as text is rejected' "$tree" renamed.txt
case "$(uname -s)" in
MINGW*|MSYS*|CYGWIN*)
skip 'control-byte filenames are forbidden by the Windows filesystem'
skip 'raw C1 filenames are forbidden by the Windows filesystem'
skip 'UTF-8 C1 filenames are forbidden by the Windows filesystem'
skip 'bidirectional-control filenames are forbidden by the Windows filesystem'
skip 'printable UTF-8 filename preservation is not exercised on Windows'
;;
*)
tree=$(fresh_tree control-path)
control_name=$'escape\033[31m.txt'
printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"
if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - control-byte path was not rejected\n'
exit 1
elif grep -q $'\033' "$TEST_TMP/output" ||
! grep -Fq 'escape\x1b[31m.txt' "$TEST_TMP/output"; then
printf 'not ok - control-byte path was not rendered safely\n'
exit 1
else
pass 'scanner escapes terminal control bytes in reported paths'
fi
tree=$(fresh_tree raw-c1-path)
control_name=$'raw-\200.txt'
printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"
if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - raw C1 path was not rejected\n'
exit 1
elif ! grep -Fq 'raw-\x80.txt' "$TEST_TMP/output" ||
LC_ALL=C grep -q $'\200' "$TEST_TMP/output"; then
printf 'not ok - raw C1 path was not rendered safely\n'
exit 1
else
pass 'scanner escapes invalid raw C1 bytes in reported paths'
fi
tree=$(fresh_tree utf8-c1-path)
control_name=$'utf8-\302\233.txt'
printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"
if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - UTF-8 C1 path was not rejected\n'
exit 1
elif ! grep -Fq 'utf8-\u009b.txt' "$TEST_TMP/output" ||
LC_ALL=C grep -q $'\302\233' "$TEST_TMP/output"; then
printf 'not ok - UTF-8 C1 path was not rendered safely\n'
exit 1
else
pass 'scanner escapes UTF-8-encoded C1 controls in reported paths'
fi
tree=$(fresh_tree bidi-path)
control_name=$'report-\342\200\256txt.exe'
printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"
if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - bidirectional-control path was not rejected\n'
exit 1
elif ! grep -Fq 'report-\u202etxt.exe' "$TEST_TMP/output" ||
LC_ALL=C grep -q $'\342\200\256' "$TEST_TMP/output"; then
printf 'not ok - bidirectional-control path was not rendered safely\n'
exit 1
else
pass 'scanner escapes UTF-8 bidirectional controls in reported paths'
fi
tree=$(fresh_tree printable-utf8-path)
control_name=$'caf\303\251.txt'
printf '\177ELF\002\001\001\000compiled' >"$tree/$control_name"
if "$SCANNER" --tree "$tree" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - printable UTF-8 path was not rejected\n'
exit 1
elif ! LC_ALL=C grep -Fq -- "$control_name" "$TEST_TMP/output"; then
printf 'not ok - printable UTF-8 path was not preserved\n'
exit 1
else
pass 'scanner preserves printable UTF-8 in reported paths'
fi
;;
esac
tree=$(fresh_tree ar)
printf '!<arch>\n' >"$tree/renamed.data"
expect_fail_tree 'ar library renamed as data is rejected' "$tree" renamed.data
tree=$(fresh_tree thin-ar)
printf '!<thin>\n' >"$tree/renamed.data"
expect_fail_tree 'GNU thin archive renamed as data is rejected' "$tree" renamed.data
tree=$(fresh_tree mz)
printf 'MZnot-source' >"$tree/renamed.data"
expect_fail_tree 'PE/MZ renamed as data is rejected' "$tree" renamed.data
tree=$(fresh_tree macho)
printf '\376\355\372\317compiled' >"$tree/renamed.data"
expect_fail_tree 'Mach-O renamed as data is rejected' "$tree" renamed.data
tree=$(fresh_tree coff)
printf '\144\206\001\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' >"$tree/renamed.data"
expect_fail_tree 'COFF object renamed as data is rejected' "$tree" renamed.data
tree=$(fresh_tree lfs)
printf 'version https://git-lfs.github.com/spec/v1\noid sha256:0000\nsize 4\n' >"$tree/pointer.c"
expect_fail_tree 'unresolved Git LFS pointer is rejected' "$tree" pointer.c
tree=$(fresh_tree symlink)
if ((SYMLINKS_SUPPORTED)) && ln -s ../../outside "$tree/escape" 2>/dev/null &&
[[ -L $tree/escape ]]; then
expect_fail_tree 'escaping symlink is rejected' "$tree" escape
else
skip 'escaping symlink test is unsupported by this runner'
fi
tree=$(fresh_tree so-version)
printf 'not actually compiled\n' >"$tree/libexample.so.1"
expect_fail_tree 'versioned shared-library extension is rejected' "$tree" libexample.so.1
tree=$(fresh_tree rpm)
printf '\355\253\356\333package' >"$tree/renamed.data"
expect_fail_tree 'RPM magic is rejected without relying on extension' "$tree" renamed.data
tree=$(fresh_tree deb)
printf '!<arch>\ndebian-binary 000000000000000000000000000000000000000000000000000000\n' >"$tree/renamed.data"
expect_fail_tree 'DEB magic is rejected without relying on extension' "$tree" renamed.data
tree=$(fresh_tree appimage)
printf '\177ELF\002\001\001\000AI\002payload' >"$tree/renamed.data"
expect_fail_tree 'AppImage magic is rejected without relying on extension' "$tree" renamed.data
tree=$(fresh_tree wasm)
printf '\000asm\001\000\000\000' >"$tree/module.data"
expect_fail_tree 'WebAssembly magic is rejected' "$tree" module.data
tree=$(fresh_tree class)
printf '\312\376\272\276\000\000\000\075' >"$tree/class.data"
expect_fail_tree 'Java class magic is rejected' "$tree" class.data
tree=$(fresh_tree pyc)
printf '\247\015\015\012\000\000\000\000\000\000\000\000\000\000\000\000' >"$tree/python.data"
expect_fail_tree 'Python bytecode magic is rejected' "$tree" python.data
tree=$(fresh_tree nested)
mkdir -p "$tree/input"
printf '\177ELF\002\001\001\000nested' >"$tree/input/payload.txt"
tar -C "$tree/input" -cf "$tree/outer.tar" payload.txt
rm -rf -- "$tree/input"
expect_fail_tree 'compiled content inside an archive is rejected' "$tree" 'outer.tar!payload.txt'
tree=$(fresh_tree renamed-7z)
printf '\067\172\274\257\047\034malformed' >"$tree/renamed.data"
expect_fail_tree '7z magic is recognized and cannot bypass archive inspection' \
"$tree" renamed.data
tree=$(fresh_tree renamed-rar)
printf 'Rar!\032\007\001\000malformed' >"$tree/renamed.data"
expect_fail_tree 'RAR magic is recognized and cannot bypass archive inspection' \
"$tree" renamed.data
tree=$(fresh_tree empty-archive)
tar -cf "$tree/empty.tar" --files-from /dev/null
expect_fail_tree 'empty archives are rejected as having no inspectable source' \
"$tree" empty.tar
tree=$(fresh_tree member-limit)
mkdir -p "$tree/input"
for member_number in 1 2 3 4; do
printf 'source %s\n' "$member_number" >"$tree/input/$member_number.c"
done
tar -C "$tree/input" -cf "$tree/members.tar" .
expect_fail_archive_with_limits \
'archive member count is bounded during preflight listing' \
"$tree/members.tar" 'archive member limit exceeded' \
SOURCE_AUDIT_MAX_MEMBERS=3
expect_fail_archive_with_limits \
'archive member-name output is byte-bounded during preflight listing' \
"$tree/members.tar" 'archive member-name budget exceeded' \
SOURCE_AUDIT_MAX_LIST_KIB=0
tree=$(fresh_tree expanded-limit)
mkdir -p "$tree/input"
dd if=/dev/zero of="$tree/input/zeros.c" bs=1024 count=2048 2>/dev/null
tar -C "$tree/input" -czf "$tree/compressed-size-bomb.tar.gz" zeros.c
expect_fail_archive_with_limits \
'compressed archive declared size is rejected before extraction' \
"$tree/compressed-size-bomb.tar.gz" \
'archive declared-size limit exceeded before extraction' \
SOURCE_AUDIT_MAX_KIB=1024
tree=$(fresh_tree global-expanded-limit)
mkdir -p "$tree/one" "$tree/two"
dd if=/dev/zero of="$tree/one/one.c" bs=700 count=1 2>/dev/null
dd if=/dev/zero of="$tree/two/two.c" bs=700 count=1 2>/dev/null
tar -C "$tree/one" -cf "$tree/one.tar" one.c
tar -C "$tree/two" -cf "$tree/two.tar" two.c
if env SOURCE_AUDIT_MAX_KIB=2 SOURCE_AUDIT_MAX_TOTAL_KIB=1 \
"$SCANNER" --archive "$tree/one.tar" --archive "$tree/two.tar" \
>"$TEST_TMP/output" 2>&1; then
printf 'not ok - global archive size budget unexpectedly passed\n'
exit 1
elif grep -Fq 'global archive declared-size budget exceeded' "$TEST_TMP/output"; then
pass 'global declared-size budget covers multiple archives'
else
printf 'not ok - global archive size budget finding missing\n'
sed -n '1,120p' "$TEST_TMP/output"
exit 1
fi
tree=$(fresh_tree archive-symlink)
mkdir -p "$tree/input"
if ((SYMLINKS_SUPPORTED)) &&
ln -s ../../outside "$tree/input/escape" 2>/dev/null &&
[[ -L $tree/input/escape ]]; then
tar -C "$tree/input" -cf "$tree/escape.tar" escape
rm -rf -- "$tree/input"
expect_fail_tree 'escaping symlink inside an archive is rejected before extraction' "$tree" 'escape.tar!escape'
else
skip 'archive symlink test is unsupported by this runner'
fi
tree=$(fresh_tree bad-ref)
printf 'SDK_LIB = vendor/vuptsdk/libvuptsdk.so.2\n' >"$tree/Makefile"
expect_fail_tree 'removed vendored library references are rejected' "$tree" Makefile
archive_src=$(fresh_tree standalone-archive)
printf 'source text\n' >"$archive_src/source.c"
tar -C "$archive_src" -cf "$TEST_TMP/source.tar" source.c
if "$SCANNER" --archive "$TEST_TMP/source.tar" >"$TEST_TMP/output" 2>&1 &&
grep -q '^PASS source-only:' "$TEST_TMP/output"; then
pass 'standalone source archive passes'
else
printf 'not ok - standalone source archive passes\n'
sed -n '1,120p' "$TEST_TMP/output"
exit 1
fi
if SOURCE_AUDIT_FORCE_WATCHDOG=1 \
"$SCANNER" --archive "$TEST_TMP/source.tar" >"$TEST_TMP/output" 2>&1 &&
grep -q '^PASS source-only:' "$TEST_TMP/output"; then
pass 'portable archive watchdog fallback completes a normal scan'
else
printf 'not ok - portable archive watchdog fallback\n'
sed -n '1,120p' "$TEST_TMP/output"
exit 1
fi
repo=$TEST_TMP/repository
mkdir -p "$repo"
git -C "$repo" init -q
git -C "$repo" config user.name 'Source Audit Test'
git -C "$repo" config user.email 'source-audit@example.invalid'
printf 'safe source\n' >"$repo/source.c"
printf '*.o\n' >"$repo/.gitignore"
mkdir -p "$repo/tests" "$repo/scripts" "$repo/packaging/opensuse"
printf 'fixture mentions vendor/vuptsdk/libvuptsdk.so.2\n' >"$repo/tests/test_source_only.sh"
printf '# scanner implementation fixture\n' >"$repo/scripts/check-source-only.sh"
printf '# scanner wrapper fixture\n' >"$repo/packaging/opensuse/source-audit.sh"
git -C "$repo" add source.c .gitignore tests scripts packaging
git -C "$repo" commit -qm 'safe source'
git -C "$repo" tag v1.0.0
if "$SCANNER" --root "$repo" --tag v1.0.0 >"$TEST_TMP/output" 2>&1 &&
grep -q '^PASS source-only:' "$TEST_TMP/output"; then
pass 'tracked, working-tree, HEAD archive, and tag archive pass'
else
printf 'not ok - repository and tag audit pass\n'
sed -n '1,120p' "$TEST_TMP/output"
exit 1
fi
printf '\177ELF\002\001\001\000ignored' >"$repo/ignored.o"
if "$SCANNER" --root "$repo" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - ignored working-tree object is rejected\n'
exit 1
elif grep -Fq ignored.o "$TEST_TMP/output"; then
pass 'ignored working-tree object is rejected'
else
printf 'not ok - ignored object path missing\n'
exit 1
fi
rm -f -- "$repo/ignored.o"
printf '\177ELF\002\001\001\000indexed' >"$repo/indexed.txt"
git -C "$repo" add indexed.txt
printf 'safe worktree replacement\n' >"$repo/indexed.txt"
if "$SCANNER" --root "$repo" >"$TEST_TMP/output" 2>&1; then
printf 'not ok - compiled indexed blob is rejected\n'
exit 1
elif grep -Fq indexed.txt "$TEST_TMP/output"; then
pass 'Git index content is audited independently of the worktree'
else
printf 'not ok - indexed path missing\n'
exit 1
fi
printf '1..%d\n' "$PASSED"

View file

@ -4,10 +4,10 @@
#
# Static-analysis regression for v3.0.3.
#
# Asserts that our (non-vendored) C source compiles cleanly under:
# Asserts that every first-party src/zupt_*.c translation unit compiles under:
# - GCC strict warnings + -Werror
# - GCC -Wconversion + -Wsign-conversion (silenced/false-positive-prone
# warnings; we enable for OUR code only, not vendored vv_*.c)
# - GCC -Wconversion + -Wsign-conversion on the security/I/O subset where
# that warning policy is already clean
# - cppcheck warning + performance level
#
# History:
@ -22,75 +22,90 @@ PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
# Our (non-vendored) C source files. Vendored: vv_*.c, fips202.c,
# zupt_mlkem.c — these have their own upstream style and we don't
# enforce our warning set on them.
OUR_FILES=(
src/zupt_main.c
src/zupt_format.c
src/zupt_dedup.c
src/zupt_disk.c
src/zupt_crypto.c
src/zupt_aes256.c
src/zupt_sha256.c
src/zupt_xxh.c
src/zupt_parallel.c
STATIC_TMP=$(mktemp -d "${TMPDIR:-/tmp}/zupt-static-analysis.XXXXXXXX") || exit 1
cleanup() {
local status=$?
trap - EXIT HUP INT TERM
rm -rf -- "$STATIC_TMP"
exit "$status"
}
trap cleanup EXIT HUP INT TERM
# Derive the list so a newly added first-party translation unit cannot silently
# escape this gate. Bundled GPL codec sources use their own warning policy.
OUR_FILES=()
while IFS= read -r -d '' source_file; do
OUR_FILES[${#OUR_FILES[@]}]=$source_file
done < <(find src -maxdepth 1 -type f -name 'zupt_*.c' \
! -name 'zupt_sha256_shani.c' -print0 | sort -z)
# Conversion warnings are intentionally a second, narrower policy. The LZ,
# LZH, Keccak, and ML-KEM implementations use signed loop indices per their
# reviewed algorithms; the strict-Werror and cppcheck passes still cover them.
CONVERSION_FILES=(
src/zupt_main.c src/zupt_format.c src/zupt_dedup.c src/zupt_disk.c
src/zupt_crypto.c src/zupt_crypto_sdk.c src/zupt_crypto_pqbox.c
src/zupt_aes256.c src/zupt_sha256.c src/zupt_xxh.c src/zupt_parallel.c
src/zupt_cpuid.c src/zupt_filetype.c src/zupt_mlock.c src/zupt_predict.c
src/zupt_x25519.c
)
# zupt_sha256_shani.c needs -msha -mssse3 -msse4.1 to compile its
# intrinsics; checked separately below so the main loop stays flag-clean.
SHANI_FILE=src/zupt_sha256_shani.c
# Filter to files that actually exist (architecture-conditional ones)
EXIST=()
for f in "${OUR_FILES[@]}"; do
[ -f "$f" ] && EXIST+=("$f")
done
EXIST=("${OUR_FILES[@]}")
echo "Static analysis"
# ─── Strict GCC + -Werror ───
STRICT_CFLAGS="-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes \
-Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op -Wjump-misses-init \
-Wdouble-promotion -Woverlength-strings -Werror -O2 -std=c11 -Iinclude -Isrc"
STRICT_CFLAGS=(
-Wall -Wextra -Wpedantic -Wshadow -Wcast-align -Wstrict-prototypes
-Wmissing-prototypes -Wnull-dereference -Wformat=2 -Wlogical-op
-Wjump-misses-init -Wdouble-promotion -Woverlength-strings -Werror
-O2 -std=c11 -Iinclude -Isrc
)
STRICT_FAILS=0
for f in "${EXIST[@]}"; do
if ! gcc $STRICT_CFLAGS -c "$f" -o /dev/null 2>/tmp/sa-strict.log; then
if ! gcc "${STRICT_CFLAGS[@]}" -c "$f" -o /dev/null 2>"$STATIC_TMP/strict.log"; then
STRICT_FAILS=$((STRICT_FAILS+1))
F "strict GCC -Werror failed on $f"
head -3 /tmp/sa-strict.log | sed 's/^/ /'
head -3 "$STATIC_TMP/strict.log" | sed 's/^/ /'
fi
done
[ "$STRICT_FAILS" = 0 ] && P "strict GCC -Werror clean on ${#EXIST[@]} files"
# ─── -Wconversion + -Wsign-conversion ───
CONV_CFLAGS="-Wall -Wextra -Wconversion -Wsign-conversion -O2 -std=c11 -Iinclude -Isrc"
CONV_CFLAGS=(
-Wall -Wextra -Wconversion -Wsign-conversion
-O2 -std=c11 -Iinclude -Isrc
)
CONV_FAILS=0
for f in "${EXIST[@]}"; do
n=$(gcc $CONV_CFLAGS -c "$f" -o /dev/null 2>&1 | grep -c "warning:")
for f in "${CONVERSION_FILES[@]}"; do
n=$(gcc "${CONV_CFLAGS[@]}" -c "$f" -o /dev/null 2>&1 | grep -c "warning:")
if [ "$n" -gt 0 ]; then
CONV_FAILS=$((CONV_FAILS+1))
F "$f: $n -Wconversion warnings"
gcc $CONV_CFLAGS -c "$f" -o /dev/null 2>&1 | grep "warning:" | head -3 | sed 's/^/ /'
gcc "${CONV_CFLAGS[@]}" -c "$f" -o /dev/null 2>&1 | grep "warning:" | head -3 | sed 's/^/ /'
fi
done
[ "$CONV_FAILS" = 0 ] && P "-Wconversion -Wsign-conversion clean on ${#EXIST[@]} files"
[ "$CONV_FAILS" = 0 ] && P "-Wconversion -Wsign-conversion clean on ${#CONVERSION_FILES[@]} security/I/O files"
# ── SHA-NI file (needs -msha -mssse3 -msse4.1 on x86_64) ──
if [ -f "$SHANI_FILE" ]; then
ARCH_SA=$(uname -m)
if [ "$ARCH_SA" = "x86_64" ] || [ "$ARCH_SA" = "i686" ]; then
SA_SHANI="-msha -mssse3 -msse4.1"
SA_SHANI=(-msha -mssse3 -msse4.1)
else
SA_SHANI=""
SA_SHANI=()
fi
if gcc $STRICT_CFLAGS $SA_SHANI -c "$SHANI_FILE" -o /dev/null 2>/tmp/sa-shani.log; then
if gcc "${STRICT_CFLAGS[@]}" "${SA_SHANI[@]}" -c "$SHANI_FILE" -o /dev/null 2>"$STATIC_TMP/shani.log"; then
P "SHA-NI file strict GCC -Werror clean"
else
F "SHA-NI file fails strict -Werror"
head -5 /tmp/sa-shani.log | sed 's/^/ /'
head -5 "$STATIC_TMP/shani.log" | sed 's/^/ /'
fi
if [ "$(gcc $CONV_CFLAGS $SA_SHANI -c "$SHANI_FILE" -o /dev/null 2>&1 | grep -c 'warning:')" = 0 ]; then
if [ "$(gcc "${CONV_CFLAGS[@]}" "${SA_SHANI[@]}" -c "$SHANI_FILE" -o /dev/null 2>&1 | grep -c 'warning:')" = 0 ]; then
P "SHA-NI file -Wconversion -Wsign-conversion clean"
else
F "SHA-NI file has -Wconversion warnings"
@ -99,7 +114,7 @@ fi
# ─── cppcheck warning + performance ───
if command -v cppcheck >/dev/null 2>&1; then
SUPP=/tmp/cppcheck-supp-sa.txt
SUPP=$STATIC_TMP/cppcheck-suppressions.txt
cat > "$SUPP" <<EOF
*:src/vv_ans.c
*:src/vv_decoder.c
@ -108,7 +123,6 @@ if command -v cppcheck >/dev/null 2>&1; then
*:src/vv_simd.c
*:src/vv_xxh64.c
*:src/fips202.c
*:src/zupt_mlkem.c
missingIncludeSystem
EOF
n=$(timeout 60 cppcheck --quiet --enable=warning,performance \

View file

@ -2,7 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (c) 2025-2026 Cristian Cezar Moisés
set +e
ZUPT="./zupt"
ZUPT="${1:-./zupt}"
T="/tmp/zupt_mt_$$"
PASS=0; FAIL=0; TOTAL=0
mkdir -p "$T"
@ -207,7 +207,8 @@ T4_MS=$(( (T4_END - T4_START) / 1000000 ))
echo " N=1: ${T1_MS}ms N=4: ${T4_MS}ms"
if [ "$T4_MS" -gt 0 ] && [ "$T1_MS" -gt 0 ]; then
SPEEDUP=$(echo "scale=1; $T1_MS / $T4_MS" | bc 2>/dev/null || echo "?")
SPEEDUP=$(awk -v one="$T1_MS" -v four="$T4_MS" \
'BEGIN { if (four > 0) printf "%.1f", one / four; else print "?" }')
echo " Speedup: ${SPEEDUP}x"
pass "Throughput comparison (N=1: ${T1_MS}ms, N=4: ${T4_MS}ms, ${SPEEDUP}x)"
else

View file

@ -2,7 +2,7 @@
* ZUPT v2.0.0 VaptVupt Codec Unit Tests
*
* Tests VaptVupt roundtrip in all 3 modes, incompressible fallback,
* and validates integration with Zupt's XXH64 alias.
* and validates integration with ZUPT's XXH64 alias.
*
* VAPTVUPT: Integration test suite
* Copyright (c) 2026 Cristian Cezar Moisés

View file

@ -1,7 +1,7 @@
/*
* SPDX-License-Identifier: AGPL-3.0-or-later
* Copyright (c) 2025-2026 Cristian Cezar Moisés
* Zupt NIST/RFC Cryptographic Test Vectors
* ZUPT NIST/RFC Cryptographic Test Vectors
* Copyright (c) 2026 Cristian Cezar Moisés AGPL-3.0-or-later
*
* Tests: SHA-256 (FIPS 180-4), HMAC-SHA256 (RFC 4231),
@ -44,7 +44,7 @@ static void hex2bin(const char *hex, uint8_t *bin, int len) {
}
int main(void) {
printf("Zupt Cryptographic Test Vectors\n");
printf("ZUPT Cryptographic Test Vectors\n");
printf("================================\n\n");
/* ═══ SHA-256 (FIPS 180-4) ═══ */
@ -206,7 +206,7 @@ int main(void) {
else { printf(" FAIL: XXH64('') = %016llx\n", (unsigned long long)h); fail++; }
}
/* ═══ ML-KEM-768 internal self-test (F-04, Zupt 2.2.4) ═══ */
/* ═══ ML-KEM-768 internal self-test (F-04, ZUPT 2.2.4) ═══ */
printf("\n-- ML-KEM-768 internal self-test --\n");
{
/* zupt_mlkem768_selftest() returns 0 on success, -1 on failure. */

View file

@ -26,8 +26,7 @@ PASS=0; FAIL=0
P() { echo "$1"; PASS=$((PASS+1)); }
F() { echo "$1"; FAIL=$((FAIL+1)); }
BIN=./vaptvupt
[ -x ./vaptvupt ] || BIN=./zupt
BIN=${1:-${ZUPT_BIN:-./zupt}}
[ -x "$BIN" ] || { echo "ERROR: no built binary"; exit 2; }
echo "VaptVupt decode over-copy guard"