release: restore ZUPT and harden source-only 5.2.2
This commit is contained in:
parent
74e393ba3e
commit
ff99770bd0
205 changed files with 19627 additions and 13215 deletions
157
tests/archive_path_fixture.c
Normal file
157
tests/archive_path_fixture.c
Normal 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
275
tests/archive_surgery.py
Normal 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())
|
||||
46
tests/fixture_hex_decode.c
Normal file
46
tests/fixture_hex_decode.c
Normal 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
29
tests/fixtures/README.md
vendored
Normal 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.
|
||||
1
tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex
vendored
Normal file
1
tests/fixtures/v5.2.1-encrypted-dedup-disk.zupt.hex
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
5a5550541a000106c1000000004c4bcb31d9ce1871636dd7ea914893a9ec6b3ac330947f40000000000000000000000000000000000000000000000000000000bb0103000000003535dfcd5687362b923e017cc0d3d4dbe979c8ffc1294ca8f9735f6a1dca2eada0761483cf98ad06c75a87bb737c1adfa566513e1114d1e6f8326dc0270900bb01001000010080800475cf99e954d44bb621fc91737f492f2cd390cdbeaadb23a6bc42250a8c15f8e41284be8b5ed8c00ca3c548b80e08dbd4edda4926c7e4150a53ee84f2ad94d74d6610bce4d72f9fa1a255a71a9b8bb3dc56d6feaa292e8ba02c82d6820872a5b37434c86dc59d0ddccd58c12b2968c8cd1bc3aaa53c37940b75820b5b7ff4bb01001000010080800475647dfd91a6b9e6c021d49988ac9bf13ed8106c6c7505353e7857f699687e25392fabd4e8bc6287a156474f0dbf608801c28e062505b42073e7c8d982f4beddc9e3bd70643e56f21b008e40be5a0924e1ce7d6499047d6e5eae782aa5f64a85231a382a5436f958880a2cf937272b34b9b9048df33a738f7b3a3c452d0bbb01040000000080800408647dfd91a6b9e6c00e01000000000000bb0100100001008080047561edd57b45ef972a0a654bc02830ee9b4db7bd1431f80e984e2e78cd3843dd129dbdfdfa36f37997f55edf6205afe1593b4c993a0a5db70cb3ea1af3f231e09c074233e2f9c1b1047d30d880e891d4f7f9a5d5b42b202d92839deca17ffa5cb6c53c5c1326298dd76006d2437bfe209092e93ba811198f3b4afcccbbd7bb010200000000444427de2204fe74e31a010000000f6c65676163792d616262632e696d67000004000000000067010000000000000016e60632d9ce180d922d4614e9c20186000000000000000400000000000000390200000000000004000000000000004435c96b5a53aaaf5a454e440100000072d5f64874d717f7784f84b625d43ac127352bd3a37b9f2804056d0d475b12c9
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
413
tests/test_atomic_archive_output.sh
Normal file
413
tests/test_atomic_archive_output.sh
Normal 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'
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
92
tests/test_authenticated_dedup_reorder.sh
Normal file
92
tests/test_authenticated_dedup_reorder.sh
Normal 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'
|
||||
49
tests/test_benchmark_temp_safety.sh
Executable file
49
tests/test_benchmark_temp_safety.sh
Executable 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'
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
38
tests/test_block_type_confusion.sh
Executable file
38
tests/test_block_type_confusion.sh
Executable 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'
|
||||
|
|
@ -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[] = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
80
tests/test_disk_device_capacity.sh
Executable file
80
tests/test_disk_device_capacity.sh
Executable 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'
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 " ───────────────────────────────────────"
|
||||
|
|
|
|||
|
|
@ -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 ]
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
163
tests/test_format_little_endian.sh
Normal file
163
tests/test_format_little_endian.sh
Normal 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'
|
||||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
371
tests/test_key_files.sh
Normal 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
47
tests/test_legacy_disk_5_2_1.sh
Executable 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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
85
tests/test_password_prompt_signal.sh
Executable file
85
tests/test_password_prompt_signal.sh
Executable 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
|
||||
135
tests/test_password_sources.sh
Normal file
135
tests/test_password_sources.sh
Normal 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'
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
425
tests/test_source_only.sh
Executable 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"
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue