7 Commits
Author SHA1 Message Date
LeoVasanko c9ae37a03d Validate LF line endings in release builds
release / build-wheels (linux, bash) (push) Successful in 1m8s
release / build-wheels (windows, cmd) (push) Successful in 1m19s
release / build-wheels (macos, bash) (push) Successful in 1m20s
release / publish-pypi (push) Successful in 15s
Replaces the removed normalize_line_endings rewriter: release.py now
fails listing any text file containing CRLF instead of silently
rewriting it. .gitattributes (eol=lf) remains the preventive half.
2026-10-04 18:30:28 +00:00
LeoVasanko e29035f768 Release builds must not write tracked files; validate instead
release.py previously ran the code generator, 'ruff check --fix',
'ruff format', and line-ending normalization before building. Any drift
rewrote tracked files, dirtying the checkout and turning the release
into a dev version (the v0.10.6 PyPI 400). Generation and linting are
now validation-only:

- tools/generate.py gains a --check mode that reports stale generated
  files without writing (comparing Python content in ruff-formatted
  form, matching what write + format would produce)
- release.py runs generate --check, ruff check (no --fix), and
  ruff format --check, failing with remediation hints
- normalize_line_endings() is removed; .gitattributes keeps checkouts LF
2026-10-04 18:26:49 +00:00
LeoVasanko 3ff145a0f0 Fix release versioning: keep tree ruff-clean, fail fast on dirty tag builds
The tagged v0.10.6 CI build produced wheels versioned
0.10.7.dev0+g503ce8e49.d20261004: setup.py was not ruff-format-clean, so
release.py's own 'ruff format .' step rewrote it, dirtying the checkout
before setuptools_scm ran. PyPI then rejected the local version segment
with HTTP 400.

- ruff-format setup.py
- release.py: on a v*-tagged HEAD, fail before building if the version
  is not a clean release, printing the dirty files
- add .gitattributes (eol=lf) so Windows checkouts can't dirty the tree
  via normalize_line_endings either
2026-10-04 18:15:07 +00:00
LeoVasanko 503ce8e491 Fix FileStorage on Windows: open fd with O_BINARY, UTF-8 release logs
release / build-wheels (linux, bash) (push) Successful in 1m13s
release / build-wheels (windows, cmd) (push) Successful in 1m20s
release / build-wheels (macos, bash) (push) Successful in 1m26s
release / publish-pypi (push) Failing after 2s
os.open() on Windows defaults to O_TEXT, so the CRT translated \r\n in
binary ciphertext: writes grew, offsets shifted, reads came up short and
the RAF callbacks failed with EIO (probe/read/write depending on where
a 0x0A byte landed). Pass O_BINARY where it exists.

Also reconfigure stdout/stderr to UTF-8 in tools/release.py so the
✓/✗/⚠ summary lines don't crash on cp1252 Windows consoles.
2026-10-04 17:48:34 +00:00
LeoVasanko 59dd4d922f README 2026-10-04 17:35:09 +00:00
LeoVasanko 9583a1f9a8 Fix cdef size_t size on Win64 (LLP64), require 64-bit builds
release / build-wheels (linux, bash) (push) Successful in 1m12s
release / build-wheels (windows, cmd) (push) Failing after 1m16s
release / build-wheels (macos, bash) (push) Successful in 1m21s
release / publish-pypi (push) Skipped
The generated cdef typedef'd size_t as unsigned long: 8 bytes on LP64
but only 4 on 64-bit Windows, where C's size_t is 8. cffi's import-time
struct layout verification then failed (aegis_raf_scratch.len: cdef
says 4, C compiler says 8). cffi pre-defines size_t/uint8_t with the
correct platform sizes, so don't typedef them.

Also hard-fail 32-bit build attempts in setup.py; 32-bit platforms are
no longer supported.
2026-10-04 17:32:52 +00:00
LeoVasanko c3c4d66ea8 README 2026-10-04 17:31:11 +00:00
8 changed files with 166 additions and 71 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+1
View File
@@ -8,5 +8,6 @@
/src/aeg/_aegis*.pyd
__pycache__
!.gitignore
!.gitattributes
!.gitea/
!.gitmodules
+4 -4
View File
@@ -123,12 +123,12 @@ Constants (per module): NAME, KEYBYTES, NONCEBYTES, MACBYTES, MACBYTES_LONG, RAT
- nonce_increment(nonce)
- wipe(buffer)
### CSPRNG (aeg.random)
### Random Generator (CSPRNG)
Seeded once from the OS, then a deterministic AEGIS keystream: each call increments the nonce, so no output block ever repeats. random_key() and random_nonce() draw from it.
Seeded once from the OS, then a cryptographically secure AEGIS keystream with thread local key/nonce making it thread safe. `random_key()` and `random_nonce()` draw from it. The into variant can directly fill Numpy integer arrays and other structures that support Buffer API, making it far faster than any alternatives (e.g. Numpy's own random module).
- ciph.random() -> per-thread aeg.random.Random singleton for the cipher module
- rng.bytes(n) -> bytearray, rng.into(buf), rng(n)
- random().bytes(n) -> bytearray
- random().into(buffer) -> None
### Random-access encrypted files (aeg.raf)
+9 -2
View File
@@ -9,8 +9,15 @@ from setuptools import setup
ROOT = Path(__file__).parent.resolve()
libaegis_static = ROOT / "libaegis/zig-out/lib" / (
"aegis.lib" if sys.platform == "win32" else "libaegis.a"
if sys.maxsize <= 2**32:
raise RuntimeError(
"aeg does not support 32-bit platforms; build with a 64-bit Python"
)
libaegis_static = (
ROOT
/ "libaegis/zig-out/lib"
/ ("aegis.lib" if sys.platform == "win32" else "libaegis.a")
)
ffibuilder = FFI()
-3
View File
@@ -1,8 +1,5 @@
/* This file is generated with tools/generate.py. Do not edit. */
typedef unsigned char uint8_t;
typedef unsigned long size_t;
/* aegis.h */
int aegis_init(void);
int aegis_verify_16(const uint8_t *x, const uint8_t *y);
+3
View File
@@ -104,6 +104,9 @@ class FileStorage:
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
else:
raise ValueError(f"Unsupported mode: {mode}")
# O_BINARY (Windows only): keep the CRT from translating \r\n in
# binary data, which corrupts ciphertext and shifts offsets
flags |= getattr(os, "O_BINARY", 0)
self._fd = os.open(path, flags, 0o644)
self._closed = False
self._lock = threading.Lock()
+72 -29
View File
@@ -73,13 +73,49 @@ def format_declaration(decl: str, max_width: int = 100) -> str:
return decl
def _ruff_format_source(content: str, filename: str) -> str:
"""Format Python source in memory (ruff format via stdin, no file writes)."""
result = subprocess.run(
["uv", "run", "ruff", "format", "--stdin-filename", filename, "-"],
input=content,
capture_output=True,
text=True,
check=True,
)
return result.stdout
def _is_up_to_date(path: pathlib.Path, content: str, check: bool) -> bool:
"""True if writing content to path would leave it unchanged. In check mode,
Python content is compared in its ruff-formatted form, matching what a
write + format pass would produce."""
if not path.exists():
return False
if check and path.suffix == ".py":
content = _ruff_format_source(content, path.name)
return path.read_text(encoding="utf-8") == content
def _sync_file(
path: pathlib.Path, content: str, check: bool, stale: list[pathlib.Path]
) -> None:
"""Write content to path, or in check mode record it as stale instead."""
if _is_up_to_date(path, content, check):
print(f" - No changes to {path.name}", file=sys.stderr)
elif check:
stale.append(path)
else:
path.write_bytes(content.encode())
print(f" - Updated {path}", file=sys.stderr)
def generate_cdef(include_dir: pathlib.Path) -> str:
# cffi pre-defines uint8_t, size_t, etc. with the correct platform sizes.
# Do not typedef them here: "unsigned long" matches size_t on LP64 but is
# 4 bytes vs 8 on Win64 (LLP64), breaking struct layout verification.
lines = [
"/* This file is generated with tools/generate.py. Do not edit. */",
"",
"typedef unsigned char uint8_t;",
"typedef unsigned long size_t;",
"",
]
headers = [
@@ -240,6 +276,7 @@ def generate_python_modules(
template_path: pathlib.Path,
output_dir: pathlib.Path,
constants: dict[str, dict[str, int]],
check: bool = False,
) -> tuple[list[pathlib.Path], list[pathlib.Path]]:
if not template_path.exists():
raise FileNotFoundError(f"Template not found: {template_path}")
@@ -287,10 +324,11 @@ def generate_python_modules(
else:
new_content = generate_variant(template_src, variant, const_dict)
if dst.exists() and dst.read_text(encoding="utf-8") == new_content:
if _is_up_to_date(dst, new_content, check):
unchanged.append(dst)
else:
dst.write_bytes(new_content.encode())
if not check:
dst.write_bytes(new_content.encode())
updated.append(dst)
return updated, unchanged
@@ -313,7 +351,9 @@ def generate_ciphers_module(constants: dict[str, dict[str, int]]) -> str:
return "\n".join(lines) + "\n"
def main() -> int:
def main(check: bool = False) -> int:
"""Regenerate files from the C sources. With check=True, only verify the
committed files are up to date; nothing is written."""
root = pathlib.Path(__file__).parent.parent
libaegis_src_dir = root / "libaegis" / "src"
include_dir = libaegis_src_dir / "include"
@@ -333,36 +373,26 @@ def main() -> int:
print("Error: No constants extracted", file=sys.stderr)
return 1
print("Step 2: Generating CFFI cdef header...", file=sys.stderr)
pyaegis_dir.mkdir(exist_ok=True)
cdef_path = pyaegis_dir / "aegis_cdef.h"
cdef_content = generate_cdef(include_dir)
stale: list[pathlib.Path] = []
if cdef_path.exists() and cdef_path.read_text(encoding="utf-8") == cdef_content:
print(f" - No changes to {cdef_path}", file=sys.stderr)
else:
cdef_path.write_bytes(cdef_content.encode())
print(f" - Updated {cdef_path}", file=sys.stderr)
print("Step 2: Generating CFFI cdef header...", file=sys.stderr)
if not check:
pyaegis_dir.mkdir(exist_ok=True)
_sync_file(pyaegis_dir / "aegis_cdef.h", generate_cdef(include_dir), check, stale)
print("Step 3: Generating _ciphers.py...", file=sys.stderr)
ciphers_path = pyaegis_dir / "_ciphers.py"
ciphers_content = generate_ciphers_module(constants)
if (
ciphers_path.exists()
and ciphers_path.read_text(encoding="utf-8") == ciphers_content
):
print(f" - No changes to {ciphers_path.name}", file=sys.stderr)
else:
ciphers_path.write_bytes(ciphers_content.encode())
print(f" - Updated {ciphers_path.name}", file=sys.stderr)
_sync_file(
pyaegis_dir / "_ciphers.py", generate_ciphers_module(constants), check, stale
)
print("Step 4: Generating Python modules...", file=sys.stderr)
try:
updated, unchanged = generate_python_modules(
pyaegis_dir / "aegis256x4.py", pyaegis_dir, constants
pyaegis_dir / "aegis256x4.py", pyaegis_dir, constants, check=check
)
if updated:
if check:
stale.extend(updated)
else:
for p in updated:
print(f" - {p.relative_to(root)}", file=sys.stderr)
if unchanged:
@@ -377,6 +407,19 @@ def main() -> int:
print(f"Error generating Python modules: {e}", file=sys.stderr)
return 1
if check:
for p in stale:
print(f" - Out of date: {p.relative_to(root)}", file=sys.stderr)
if stale:
print(
"✗ Generated files are out of date; "
"run tools/generate.py and commit the result",
file=sys.stderr,
)
return 1
print("All generated files are up to date.", file=sys.stderr)
return 0
print("Step 5: Formatting generated files with ruff...", file=sys.stderr)
result = subprocess.run(
["uv", "run", "ruff", "format", str(pyaegis_dir)],
@@ -392,4 +435,4 @@ def main() -> int:
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main(check="--check" in sys.argv))
+76 -33
View File
@@ -129,27 +129,29 @@ def get_build_env():
return env
def normalize_line_endings(repo_root: Path):
"""Normalize all text files to LF line endings."""
# Patterns for files to normalize
patterns = [
"src/aeg/**/*.py",
"src/aeg/**/*.h",
"tests/**/*.py",
"tools/**/*.py",
"*.py",
"*.md",
"*.txt",
"*.toml",
"*.in",
]
for pattern in patterns:
# Text files that must have LF line endings (.gitattributes enforces this on
# checkout; release validation fails instead of rewriting)
LINE_ENDING_PATTERNS = [
"src/aeg/**/*.py",
"src/aeg/**/*.h",
"tests/**/*.py",
"tools/**/*.py",
"*.py",
"*.md",
"*.txt",
"*.toml",
"*.in",
]
def find_crlf_files(repo_root: Path) -> list[Path]:
"""Return text files containing CRLF line endings."""
bad = []
for pattern in LINE_ENDING_PATTERNS:
for file_path in repo_root.glob(pattern):
if file_path.is_file():
content = file_path.read_bytes()
if b"\r\n" in content:
content = content.replace(b"\r\n", b"\n")
file_path.write_bytes(content)
if file_path.is_file() and b"\r\n" in file_path.read_bytes():
bad.append(file_path)
return bad
def get_wheel_pattern(py_version: str, abi3: bool = False) -> str:
@@ -305,6 +307,11 @@ def run_benchmark(wheel: Path, py_version: str) -> bool:
def main():
"""Build wheels for all supported Python versions."""
# Windows CI consoles default to cp1252; emit ✓/✗/⚠ as UTF-8
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
# CI matrix jobs build per-platform wheels but never upload; a separate
# publish job uploads everything once all platforms have succeeded.
no_upload = "--no-upload" in sys.argv
@@ -312,29 +319,38 @@ def main():
repo_root = Path(__file__).parent.parent
dist_dir = repo_root / "dist"
# Generate CFFI definitions and Python modules
# The repository must already contain up-to-date generated code and
# lint-clean sources: a release build never writes to tracked files.
# Line endings must be LF (.gitattributes enforces this on checkout).
print(f"\n{'=' * 70}")
print("Code generation from C headers (tools/generate.py)")
print("Validating generated files (tools/generate.py --check)")
print(f"{'=' * 70}")
if generate.main() != 0:
print("✗ Code generation failed", file=sys.stderr)
if generate.main(check=True) != 0:
return 1
# Run ruff to check and fix any issues
print(f"\n{'=' * 70}")
print("Linting and formatting")
print("Lint and format validation")
print(f"{'=' * 70}")
if not run_command(["uv", "run", "ruff", "check", "--fix", "."]):
print("✗ Ruff check failed", file=sys.stderr)
if not run_command(["uv", "run", "ruff", "check", "."]):
print("✗ Ruff check failed; fix and commit before releasing", file=sys.stderr)
return 1
# Run ruff format
if not run_command(["uv", "run", "ruff", "format", "."]):
print("✗ Ruff format failed", file=sys.stderr)
if not run_command(["uv", "run", "ruff", "format", "--check", "."]):
print(
"✗ Ruff format check failed; run 'uv run ruff format .' and commit",
file=sys.stderr,
)
return 1
# Normalize all line endings to LF (important for consistent builds)
normalize_line_endings(repo_root)
if crlf_files := find_crlf_files(repo_root):
print("✗ CRLF line endings found in:", file=sys.stderr)
for p in crlf_files:
print(f" - {p.relative_to(repo_root)}", file=sys.stderr)
print(
"Convert to LF and commit (.gitattributes enforces eol=lf).",
file=sys.stderr,
)
return 1
# Get version from git repo
version = get_version_from_scm()
@@ -342,6 +358,33 @@ def main():
return 1
is_release = is_release_version(version)
# On a tagged commit the version must be a clean release (e.g. 0.10.6).
# A dev/dirty version means the checkout itself is dirty (PyPI rejects
# such versions), so fail before building.
head_tags = subprocess.run(
["git", "tag", "--points-at", "HEAD", "--list", "v*"],
capture_output=True,
text=True,
check=False,
).stdout.split()
if head_tags and not is_release:
print(
f"✗ HEAD is tagged ({', '.join(head_tags)}) but the version resolved "
f"to {version}, not a clean release.",
file=sys.stderr,
)
dirty = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
).stdout
if dirty.strip():
print("Working copy is dirty:", file=sys.stderr)
print(dirty, file=sys.stderr, end="")
print("Commit the changes, re-tag, and run again.", file=sys.stderr)
return 1
# Main header for the packaging process
print(f"\n{'=' * 70}")
print(