Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/windows-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
build:
if: github.repository_owner == 'wolfssl'
runs-on: windows-latest
timeout-minutes: 10
timeout-minutes: 20

env:
BUILD_PLATFORM: x64
Expand Down Expand Up @@ -54,5 +54,5 @@ jobs:

- name: Run tests
working-directory: wolfclu
run: python tests/run_tests.py
run: python tests/run_tests_parallel.py

5 changes: 3 additions & 2 deletions tests/dgst/dgst-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from wolfclu_test import CERTS_DIR, is_fips, run_wolfssl, test_main
from wolfclu_test import (CERTS_DIR, is_fips, run_wolfssl, test_main,
truncate_sparse)

DGST_DIR = os.path.dirname(os.path.abspath(__file__))

Expand Down Expand Up @@ -253,7 +254,7 @@ def setUpClass(cls):
try:
for p in (cls.original, cls.tampered):
with open(p, "wb") as f:
f.truncate(cls.LARGE_FILE_SIZE)
truncate_sparse(f, cls.LARGE_FILE_SIZE)
with open(cls.tampered, "r+b") as f:
f.seek(-1, os.SEEK_END)
f.write(b"X")
Expand Down
4 changes: 2 additions & 2 deletions tests/hash/hash-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from wolfclu_test import CERTS_DIR, run_wolfssl, test_main
from wolfclu_test import (CERTS_DIR, run_wolfssl, test_main, truncate_sparse)

HASH_DIR = os.path.dirname(os.path.abspath(__file__))
CERT_FILE = os.path.join(CERTS_DIR, "ca-cert.pem")
Expand Down Expand Up @@ -135,7 +135,7 @@ def setUpClass(cls):
try:
for p in (cls.original, cls.tampered):
with open(p, "wb") as f:
f.truncate(cls.LARGE_FILE_SIZE)
truncate_sparse(f, cls.LARGE_FILE_SIZE)
with open(cls.tampered, "r+b") as f:
f.seek(-1, os.SEEK_END)
f.write(b"X")
Expand Down
11 changes: 2 additions & 9 deletions tests/ocsp/ocsp-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,18 @@
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main
from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main, find_free_port

HAS_OPENSSL = shutil.which("openssl") is not None


def _find_free_port():
"""Bind to port 0 to let the OS assign a free ephemeral port."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]

INDEX_VALID = (
"V\t991231235959Z\t\t01\tunknown\t"
"/C=US/ST=Montana/L=Bozeman/O=wolfSSL/OU=Support"
Expand Down Expand Up @@ -135,7 +128,7 @@ def setUpClass(cls):
raise unittest.SkipTest(
f"OCSP not supported by {cls.RESPONDER_BIN}")

cls.PORT = _find_free_port()
cls.PORT = find_free_port()
cls._tmpdir = tempfile.mkdtemp()
cls._responder = None
Comment thread
aidankeefe2022 marked this conversation as resolved.

Expand Down
48 changes: 0 additions & 48 deletions tests/run_tests.py

This file was deleted.

96 changes: 96 additions & 0 deletions tests/run_tests_parallel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Parallel test runner for wolfCLU Python tests.

Runs each *-test.py file in its own process concurrently. The tests are
I/O-bound (each spawns the wolfssl binary), so file-level parallelism gives
near-linear speedup over the serial run_tests.py.

"""

import glob
import os
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed

# Per-file cap so a single hung test can't run until the CI job-level timeout.
PER_TEST_TIMEOUT = 600


def run_one(test_file, project_root):
"""Run a single test file in its own process."""

try:
proc = subprocess.run(
[sys.executable, test_file],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=project_root,
universal_newlines=True,
timeout=PER_TEST_TIMEOUT,
)
return test_file, proc.returncode, proc.stdout
except subprocess.TimeoutExpired as e:
out = e.output or ""
return test_file, 1, out + "\n[TIMEOUT] killed after {}s\n".format(
PER_TEST_TIMEOUT)


def report(script_dir, test_file, rc):
"""Print a one-line status; return True if the file failed."""
name = os.path.relpath(test_file, script_dir)
# 77 is the automake SKIP exit code emitted by test_main().
status = "PASS" if rc == 0 else ("SKIP" if rc == 77 else "FAIL")
print("[{}] {}".format(status, name), flush=True)
return rc not in (0, 77)


def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(script_dir)

pattern = os.path.join(script_dir, "**", "*-test.py")
all_files = sorted(glob.glob(pattern, recursive=True))

default_workers = os.cpu_count() or 4
workers_env = os.environ.get("WOLFCLU_TEST_JOBS")
if workers_env:
try:
workers = max(1, int(workers_env))
except ValueError:
workers = default_workers
else:
workers = default_workers

failed = [] # (name, output)
skipped = [] # (name)

with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(run_one, f, project_root) for f in all_files]
# Report as each file finishes so live status isn't gated on the
# slowest file (and a hang can't hide already-completed results).
for fut in as_completed(futures):
test_file, rc, out = fut.result()
if report(script_dir, test_file, rc):
failed.append((os.path.relpath(test_file, script_dir), out))
if rc == 77:
skipped.append(os.path.relpath(test_file, script_dir))


# Dump captured output for any failures so it isn't lost in the noise.
for name, out in failed:
print("\n===== FAILED: {} =====".format(name))
if out:
print(out, end="" if out.endswith("\n") else "\n")

total = len(all_files) - len(skipped)
print(
"\n{}/{} test files passed. {} skipped.".format(total - len(failed),
total, len(skipped)),
flush=True,
)
sys.exit(1 if failed else 0)


if __name__ == "__main__":
main()
9 changes: 6 additions & 3 deletions tests/server/server-test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import unittest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main
from wolfclu_test import WOLFSSL_BIN, CERTS_DIR, test_main, find_free_port


class ServerClientTest(unittest.TestCase):
Expand All @@ -30,9 +30,11 @@ def test_server_client(self):
if os.path.exists(readyfile):
os.remove(readyfile)

port = find_free_port()

# Start server in background
server = subprocess.Popen(
[WOLFSSL_BIN, "s_server", "-port", "11111",
[WOLFSSL_BIN, "s_server", "-port", str(port),
"-key", os.path.join(CERTS_DIR, "server-key.pem"),
"-cert", os.path.join(CERTS_DIR, "server-cert.pem"),
"-noVerify", "-readyFile", readyfile],
Expand All @@ -54,7 +56,8 @@ def test_server_client(self):

# Connect with client
client = subprocess.run(
[WOLFSSL_BIN, "s_client", "-connect", "127.0.0.1:11111",
[WOLFSSL_BIN, "s_client", "-connect",
"127.0.0.1:{}".format(port),
"-CAfile", os.path.join(CERTS_DIR, "ca-cert.pem"),
"-verify_return_error", "-disable_stdin_check"],
capture_output=True, stdin=subprocess.DEVNULL, timeout=30,
Expand Down
81 changes: 81 additions & 0 deletions tests/wolfclu_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,23 @@
import subprocess
import sys
import unittest
import socket

_TESTS_DIR = os.path.dirname(os.path.abspath(__file__))
_PROJECT_ROOT = os.path.dirname(_TESTS_DIR)
PROJECT_ROOT = _PROJECT_ROOT


def find_free_port():
"""Return an ephemeral TCP port number chosen by the OS.
This does *not* reserve the port after the socket is closed, so callers that
bind/listen should be prepared to retry if the port is claimed concurrently. """

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]


def _find_wolfssl_bin():
"""Locate the wolfssl binary, searching common build output paths.

Expand Down Expand Up @@ -83,6 +94,76 @@ def is_fips():
return "FIPS" in (r.stdout + r.stderr)


def make_sparse(fileobj):
"""Mark an open file as sparse on Windows before it is extended.

NTFS does not treat a file as sparse unless the sparse flag is set
explicitly, so a subsequent truncate() physically allocates and
zero-fills every byte. Setting FSCTL_SET_SPARSE first keeps the
extension sparse, matching the behaviour of ext4/APFS where truncate()
is already sparse. This must be called before extending the file.

No-op on non-Windows platforms.
"""
if sys.platform != "win32":
return
import ctypes
import msvcrt
from ctypes import wintypes

FSCTL_SET_SPARSE = 0x000900C4
handle = msvcrt.get_osfhandle(fileobj.fileno())
bytes_returned = wintypes.DWORD(0)
ok = ctypes.windll.kernel32.DeviceIoControl(
wintypes.HANDLE(handle),
FSCTL_SET_SPARSE,
None, 0, # no input buffer => set the sparse flag (TRUE)
None, 0, # no output buffer
ctypes.byref(bytes_returned),
None,
)
if not ok:
raise ctypes.WinError()


def truncate_sparse(fileobj, size):
"""Extend an open file to `size` bytes without physically allocating it.

On POSIX, truncate() already produces a sparse file. On Windows, Python's
truncate() routes through the CRT _chsize_s, which *writes zeros* over the
extended range and so allocates every cluster even when the sparse flag is
set. Instead we mark the file sparse and move the end-of-file pointer with
SetEndOfFile directly: no bytes are written, so the range stays sparse (and
it is instant rather than a multi-GB zero-fill).
"""
if sys.platform != "win32":
fileobj.truncate(size)
return

import ctypes
import msvcrt
from ctypes import wintypes

make_sparse(fileobj) # set FSCTL_SET_SPARSE first

k32 = ctypes.windll.kernel32
handle = wintypes.HANDLE(msvcrt.get_osfhandle(fileobj.fileno()))

set_ptr = k32.SetFilePointerEx
set_ptr.argtypes = [wintypes.HANDLE, ctypes.c_longlong,
ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD]
set_ptr.restype = wintypes.BOOL
FILE_BEGIN = 0
if not set_ptr(handle, ctypes.c_longlong(size), None, FILE_BEGIN):
raise ctypes.WinError()

set_eof = k32.SetEndOfFile
set_eof.argtypes = [wintypes.HANDLE]
set_eof.restype = wintypes.BOOL
if not set_eof(handle):
raise ctypes.WinError()


def test_main():
"""Run tests with automake-compatible exit codes.

Expand Down
Loading