codexfer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: codexfer
3
+ Version: 0.1.0
4
+ Summary: End-to-end encrypted code sharing for developers.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.12
7
+ Requires-Dist: rich>=13.7
8
+ Requires-Dist: cryptography>=42.0
9
+ Requires-Dist: requests>=2.31
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,68 @@
1
+ # CodeXfer
2
+
3
+ End-to-end encrypted code sharing for developers. The server never sees
4
+ plaintext source code — compression and encryption both happen locally.
5
+
6
+ ## Install (pure Python, cross-platform)
7
+
8
+ ```bash
9
+ python -m venv .venv
10
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
11
+ pip install -e ".[dev]" # or: pip install -e .
12
+ ```
13
+
14
+ ## Usage
15
+
16
+ ```bash
17
+ cd your-project
18
+ codexfer pack
19
+ codexfer share my-project
20
+
21
+ # on the receiving machine
22
+ codexfer build CXF8A92K
23
+ ```
24
+
25
+ ## Configuring the backend
26
+
27
+ By default, with no backend configured, `share`/`build` fall back to a local
28
+ folder (`~/.codexfer/packages`, override with `CODEXFER_LOCAL_DIR`) so you can
29
+ test the full flow on one machine before deploying anything.
30
+
31
+ To use the real Cloudflare-backed server, deploy `worker/` (see
32
+ `worker/README.md`) and set:
33
+
34
+ ```bash
35
+ export CODEXFER_API_URL="https://your-worker.your-subdomain.workers.dev"
36
+ ```
37
+
38
+ ## Security model
39
+
40
+ - AES-256-GCM authenticated encryption, key derived from the Access Code via
41
+ PBKDF2-HMAC-SHA256 (390,000 iterations) with a random salt per package.
42
+ - The Access Code is generated locally and never transmitted to or stored on
43
+ the server — only the Package ID and ciphertext travel over the network.
44
+ - GCM's authentication tag detects any tampering or corruption at decrypt time.
45
+
46
+ ## Project layout
47
+
48
+ ```
49
+ codexfer/
50
+ __init__.py
51
+ cli.py Typer CLI: pack / share / build
52
+ config.py Environment-driven settings
53
+ compression.py Project -> zip (excludes .git, venv, node_modules, etc.)
54
+ encryption.py AES-256-GCM + PBKDF2 key derivation
55
+ package_format.py .cxf container read/write
56
+ api_client.py Upload/download, with local-folder fallback
57
+ utils.py ID/access-code generation, logging
58
+ tests/
59
+ test_core.py
60
+ worker/ Cloudflare Worker backend (R2 + D1)
61
+ ```
62
+
63
+ ## Running tests
64
+
65
+ ```bash
66
+ pip install pytest
67
+ pytest
68
+ ```
@@ -0,0 +1,2 @@
1
+ """CodeXfer: end-to-end encrypted code sharing."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,73 @@
1
+ """HTTP client for the CodeXfer backend (Cloudflare Worker).
2
+
3
+ If CODEXFER_API_URL is not configured, CodeXfer falls back to a local
4
+ "packages" directory so the whole pack/share/build flow still works
5
+ end-to-end on one machine without any cloud account -- useful for testing
6
+ before you deploy the Worker.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import requests
15
+
16
+ from .config import settings
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ApiClient:
22
+ def __init__(self, base_url: str | None = None):
23
+ self.base_url = (base_url or settings.api_base_url).rstrip("/")
24
+ self.local_dir = Path(settings.local_fallback_dir)
25
+ self.local_dir.mkdir(parents=True, exist_ok=True)
26
+
27
+ @property
28
+ def is_remote(self) -> bool:
29
+ return bool(self.base_url)
30
+
31
+ def upload(self, package_id: str, cxf_path: Path, expiry_hours: int, max_downloads: int) -> None:
32
+ if self.is_remote:
33
+ with open(cxf_path, "rb") as f:
34
+ resp = requests.post(
35
+ f"{self.base_url}/upload",
36
+ params={
37
+ "package_id": package_id,
38
+ "expiry_hours": expiry_hours,
39
+ "max_downloads": max_downloads,
40
+ },
41
+ data=f,
42
+ headers={"Content-Type": "application/octet-stream"},
43
+ timeout=120,
44
+ )
45
+ resp.raise_for_status()
46
+ else:
47
+ dest = self.local_dir / f"{package_id}.cxf"
48
+ shutil.copyfile(cxf_path, dest)
49
+ logger.warning(
50
+ "CODEXFER_API_URL not set -- saved package locally to %s "
51
+ "(no real upload happened; set CODEXFER_API_URL to use the "
52
+ "Cloudflare backend).",
53
+ dest,
54
+ )
55
+
56
+ def download(self, package_id: str, destination: Path) -> Path:
57
+ if self.is_remote:
58
+ resp = requests.get(f"{self.base_url}/download/{package_id}", timeout=120, stream=True)
59
+ resp.raise_for_status()
60
+ with open(destination, "wb") as f:
61
+ for chunk in resp.iter_content(chunk_size=settings.chunk_size_bytes):
62
+ f.write(chunk)
63
+ return destination
64
+ else:
65
+ src = self.local_dir / f"{package_id}.cxf"
66
+ if not src.exists():
67
+ raise FileNotFoundError(
68
+ f"No local package '{package_id}' found in {self.local_dir}. "
69
+ "If the sender used a real deployment, set CODEXFER_API_URL "
70
+ "to the same Worker URL before running 'codexfer build'."
71
+ )
72
+ shutil.copyfile(src, destination)
73
+ return destination
@@ -0,0 +1,134 @@
1
+ """CodeXfer CLI -- exactly three commands: pack, share, build."""
2
+ from __future__ import annotations
3
+
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+
11
+ from .api_client import ApiClient
12
+ from .compression import compress_project, extract_project
13
+ from .config import settings
14
+ from .encryption import EncryptedBlob, decrypt_bytes, encrypt_bytes
15
+ from .package_format import read_cxf, write_cxf
16
+ from .utils import generate_access_code, generate_package_id, setup_logging
17
+
18
+ app = typer.Typer(add_completion=False, help="CodeXfer: end-to-end encrypted code sharing.")
19
+ console = Console()
20
+
21
+ PACK_FILENAME = ".codexfer_pack.zip"
22
+
23
+
24
+ @app.command()
25
+ def pack(
26
+ project_dir: str = typer.Argument(".", help="Project directory to pack (default: current dir)."),
27
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
28
+ ):
29
+ """Scan and compress the current project locally. No upload happens here."""
30
+ setup_logging(verbose)
31
+ project_path = Path(project_dir).resolve()
32
+ out_path = project_path / PACK_FILENAME
33
+
34
+ with console.status("[bold cyan]Scanning and compressing project..."):
35
+ compress_project(project_path, out_path)
36
+
37
+ size_mb = out_path.stat().st_size / (1024 * 1024)
38
+ console.print(Panel.fit(
39
+ f"[green]Packed successfully[/green]\n"
40
+ f"Archive: {out_path}\nSize: {size_mb:.2f} MB",
41
+ title="codexfer pack",
42
+ ))
43
+
44
+
45
+ @app.command()
46
+ def share(
47
+ package_name: str = typer.Argument(..., help="Friendly name for this package."),
48
+ project_dir: str = typer.Option(".", "--dir", help="Project directory (must already be packed)."),
49
+ expiry_hours: int = typer.Option(settings.default_expiry_hours, "--expiry"),
50
+ max_downloads: int = typer.Option(settings.default_max_downloads, "--max-downloads"),
51
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
52
+ ):
53
+ """Encrypt the packed project locally and upload only the ciphertext."""
54
+ setup_logging(verbose)
55
+ project_path = Path(project_dir).resolve()
56
+ pack_path = project_path / PACK_FILENAME
57
+
58
+ if not pack_path.exists():
59
+ console.print("[yellow]No pack found -- packing now...[/yellow]")
60
+ compress_project(project_path, pack_path)
61
+
62
+ package_id = generate_package_id()
63
+ access_code = generate_access_code()
64
+
65
+ with console.status("[bold cyan]Encrypting locally..."):
66
+ plaintext = pack_path.read_bytes()
67
+ blob = encrypt_bytes(plaintext, access_code)
68
+
69
+ with tempfile.TemporaryDirectory() as tmp:
70
+ cxf_path = Path(tmp) / f"{package_id}.cxf"
71
+ write_cxf(cxf_path, package_name, blob)
72
+
73
+ with console.status("[bold cyan]Uploading encrypted package..."):
74
+ client = ApiClient()
75
+ client.upload(package_id, cxf_path, expiry_hours, max_downloads)
76
+
77
+ pack_path.unlink(missing_ok=True)
78
+
79
+ console.print(Panel.fit(
80
+ f"[bold]Package ID[/bold]\n{package_id}\n\n"
81
+ f"[bold]Access Code[/bold]\n{access_code}\n\n"
82
+ f"Expires: {expiry_hours} Hours\n"
83
+ f"Max Downloads: {max_downloads}",
84
+ title="[green]Share ready[/green]",
85
+ ))
86
+ console.print("[dim]Share ONLY the Package ID and Access Code with the receiver.[/dim]")
87
+
88
+
89
+ @app.command()
90
+ def build(
91
+ package_id: str = typer.Argument(..., help="Package ID given by the sender."),
92
+ destination: str = typer.Option(".", "--dest", help="Where to restore the project."),
93
+ verbose: bool = typer.Option(False, "--verbose", "-v"),
94
+ ):
95
+ """Download, verify, decrypt, and restore a shared project."""
96
+ setup_logging(verbose)
97
+ access_code = typer.prompt("Enter Access Code", hide_input=True)
98
+
99
+ with tempfile.TemporaryDirectory() as tmp:
100
+ cxf_path = Path(tmp) / f"{package_id}.cxf"
101
+
102
+ with console.status("[bold cyan]Downloading..."):
103
+ client = ApiClient()
104
+ try:
105
+ client.download(package_id, cxf_path)
106
+ except Exception as exc: # noqa: BLE001
107
+ console.print(f"[red]Download failed:[/red] {exc}")
108
+ raise typer.Exit(code=1)
109
+
110
+ with console.status("[bold cyan]Decrypting..."):
111
+ header, ciphertext = read_cxf(cxf_path)
112
+ blob = EncryptedBlob(
113
+ salt=bytes.fromhex(header.salt_hex),
114
+ nonce=bytes.fromhex(header.nonce_hex),
115
+ ciphertext=ciphertext,
116
+ )
117
+ try:
118
+ plaintext = decrypt_bytes(blob, access_code)
119
+ except Exception:
120
+ console.print("[red]Decryption failed -- wrong access code or corrupted package.[/red]")
121
+ raise typer.Exit(code=1)
122
+
123
+ zip_path = Path(tmp) / "restore.zip"
124
+ zip_path.write_bytes(plaintext)
125
+
126
+ with console.status("[bold cyan]Restoring project..."):
127
+ dest_dir = Path(destination).resolve() / header.package_name
128
+ extract_project(zip_path, dest_dir)
129
+
130
+ console.print(Panel.fit(f"[green]Done[/green]\nRestored to: {dest_dir}", title="codexfer build"))
131
+
132
+
133
+ if __name__ == "__main__":
134
+ app()
@@ -0,0 +1,73 @@
1
+ """Project compression: zip a project tree while excluding noise directories,
2
+ preserving hidden files, binary files, and folder structure exactly.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ import logging
8
+ import os
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ DEFAULT_EXCLUDES = [
15
+ ".git", ".git/*", "__pycache__", "__pycache__/*", "*.pyc",
16
+ "node_modules", "node_modules/*", ".venv", ".venv/*", "venv", "venv/*",
17
+ ".mypy_cache", ".mypy_cache/*", ".pytest_cache", ".pytest_cache/*",
18
+ "dist", "dist/*", "build", "build/*", "*.egg-info", "*.egg-info/*",
19
+ ".codexfer_pack.zip", "*.cxf",
20
+ ]
21
+
22
+
23
+ def _is_excluded(rel_path: str, patterns: list[str]) -> bool:
24
+ parts = rel_path.replace("\\", "/").split("/")
25
+ for pattern in patterns:
26
+ if fnmatch.fnmatch(rel_path.replace("\\", "/"), pattern):
27
+ return True
28
+ if any(fnmatch.fnmatch(part, pattern) for part in parts):
29
+ return True
30
+ return False
31
+
32
+
33
+ def compress_project(
34
+ project_dir: str | Path,
35
+ output_path: str | Path,
36
+ excludes: list[str] | None = None,
37
+ ) -> Path:
38
+ """Zip project_dir -> output_path, streaming file-by-file (no full read into RAM)."""
39
+ project_dir = Path(project_dir).resolve()
40
+ output_path = Path(output_path).resolve()
41
+ patterns = DEFAULT_EXCLUDES + (excludes or [])
42
+
43
+ file_count = 0
44
+ with zipfile.ZipFile(output_path, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
45
+ for root, dirnames, filenames in os.walk(project_dir):
46
+ rel_root = os.path.relpath(root, project_dir)
47
+ dirnames[:] = [
48
+ d for d in dirnames
49
+ if not _is_excluded(os.path.join(rel_root, d), patterns)
50
+ ]
51
+ for name in filenames:
52
+ rel_path = os.path.normpath(os.path.join(rel_root, name))
53
+ abs_path = Path(os.path.join(root, name)).resolve()
54
+ # Never zip the archive into itself (it may already exist on
55
+ # disk while we're actively writing to it) and skip any
56
+ # leftover .cxf packages from a previous share.
57
+ if abs_path == output_path or abs_path.suffix == ".cxf":
58
+ continue
59
+ if _is_excluded(rel_path, patterns):
60
+ continue
61
+ zf.write(abs_path, arcname=rel_path)
62
+ file_count += 1
63
+
64
+ logger.info("Packed %d files from %s into %s", file_count, project_dir, output_path)
65
+ return output_path
66
+
67
+
68
+ def extract_project(archive_path: str | Path, destination_dir: str | Path) -> Path:
69
+ destination_dir = Path(destination_dir)
70
+ destination_dir.mkdir(parents=True, exist_ok=True)
71
+ with zipfile.ZipFile(archive_path, mode="r") as zf:
72
+ zf.extractall(destination_dir)
73
+ return destination_dir
@@ -0,0 +1,24 @@
1
+ """Central configuration for CodeXfer.
2
+
3
+ All values can be overridden via environment variables so the CLI works
4
+ identically for every user without editing source.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Settings:
14
+ api_base_url: str = os.environ.get("CODEXFER_API_URL", "")
15
+ local_fallback_dir: str = os.environ.get(
16
+ "CODEXFER_LOCAL_DIR", os.path.expanduser("~/.codexfer/packages")
17
+ )
18
+ default_expiry_hours: int = int(os.environ.get("CODEXFER_EXPIRY_HOURS", "24"))
19
+ default_max_downloads: int = int(os.environ.get("CODEXFER_MAX_DOWNLOADS", "1"))
20
+ chunk_size_bytes: int = 4 * 1024 * 1024 # 4 MB streaming chunks
21
+ kdf_iterations: int = 390_000 # PBKDF2-HMAC-SHA256 iterations
22
+
23
+
24
+ settings = Settings()
@@ -0,0 +1,55 @@
1
+ """AES-256-GCM encryption with PBKDF2-HMAC-SHA256 key derivation.
2
+
3
+ Design:
4
+ - Access code is the only secret. It is generated locally, never sent
5
+ to the server, and never stored anywhere.
6
+ - A random salt (16 bytes) + random nonce (12 bytes) are stored alongside
7
+ the ciphertext (they are not secret; they make the key/ciphertext unique).
8
+ - GCM's authentication tag provides integrity verification: any tampering
9
+ with the ciphertext causes decryption to raise InvalidTag.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+
16
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
17
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
18
+ from cryptography.hazmat.primitives import hashes
19
+
20
+ from .config import settings
21
+
22
+ SALT_SIZE = 16
23
+ NONCE_SIZE = 12
24
+ KEY_SIZE = 32 # AES-256
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EncryptedBlob:
29
+ salt: bytes
30
+ nonce: bytes
31
+ ciphertext: bytes # includes GCM tag appended
32
+
33
+
34
+ def _derive_key(access_code: str, salt: bytes) -> bytes:
35
+ kdf = PBKDF2HMAC(
36
+ algorithm=hashes.SHA256(),
37
+ length=KEY_SIZE,
38
+ salt=salt,
39
+ iterations=settings.kdf_iterations,
40
+ )
41
+ return kdf.derive(access_code.encode("utf-8"))
42
+
43
+
44
+ def encrypt_bytes(plaintext: bytes, access_code: str) -> EncryptedBlob:
45
+ salt = os.urandom(SALT_SIZE)
46
+ nonce = os.urandom(NONCE_SIZE)
47
+ key = _derive_key(access_code, salt)
48
+ ciphertext = AESGCM(key).encrypt(nonce, plaintext, associated_data=None)
49
+ return EncryptedBlob(salt=salt, nonce=nonce, ciphertext=ciphertext)
50
+
51
+
52
+ def decrypt_bytes(blob: EncryptedBlob, access_code: str) -> bytes:
53
+ key = _derive_key(access_code, blob.salt)
54
+ # Raises cryptography.exceptions.InvalidTag on wrong code or tampering.
55
+ return AESGCM(key).decrypt(blob.nonce, blob.ciphertext, associated_data=None)
@@ -0,0 +1,57 @@
1
+ """The .cxf container format: a small JSON header + the encrypted archive.
2
+
3
+ Layout on disk:
4
+ [4 bytes] header length (big-endian uint32)
5
+ [N bytes] UTF-8 JSON header
6
+ [rest] raw ciphertext bytes (AES-GCM, tag appended)
7
+
8
+ The header never contains secrets. It is safe for the server to read it,
9
+ though CodeXfer's server never needs to.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import struct
15
+ from dataclasses import asdict, dataclass
16
+ from pathlib import Path
17
+
18
+ from .encryption import EncryptedBlob
19
+
20
+ FORMAT_VERSION = 1
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class CxfHeader:
25
+ version: int
26
+ package_name: str
27
+ compression: str # "zip"
28
+ salt_hex: str
29
+ nonce_hex: str
30
+
31
+
32
+ def write_cxf(path: str | Path, package_name: str, blob: EncryptedBlob) -> Path:
33
+ header = CxfHeader(
34
+ version=FORMAT_VERSION,
35
+ package_name=package_name,
36
+ compression="zip",
37
+ salt_hex=blob.salt.hex(),
38
+ nonce_hex=blob.nonce.hex(),
39
+ )
40
+ header_bytes = json.dumps(asdict(header)).encode("utf-8")
41
+ path = Path(path)
42
+ with open(path, "wb") as f:
43
+ f.write(struct.pack(">I", len(header_bytes)))
44
+ f.write(header_bytes)
45
+ f.write(blob.ciphertext)
46
+ return path
47
+
48
+
49
+ def read_cxf(path: str | Path) -> tuple[CxfHeader, bytes]:
50
+ path = Path(path)
51
+ with open(path, "rb") as f:
52
+ (header_len,) = struct.unpack(">I", f.read(4))
53
+ header_bytes = f.read(header_len)
54
+ ciphertext = f.read()
55
+ header_dict = json.loads(header_bytes.decode("utf-8"))
56
+ header = CxfHeader(**header_dict)
57
+ return header, ciphertext
@@ -0,0 +1,29 @@
1
+ """Small shared helpers: ids, access codes, logging setup."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ import secrets
6
+ import string
7
+
8
+
9
+ def setup_logging(verbose: bool = False) -> None:
10
+ level = logging.DEBUG if verbose else logging.WARNING
11
+ logging.basicConfig(
12
+ level=level,
13
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
14
+ )
15
+
16
+
17
+ def generate_package_id(length: int = 8) -> str:
18
+ alphabet = string.ascii_uppercase + string.digits
19
+ return "CXF" + "".join(secrets.choice(alphabet) for _ in range(length - 3))
20
+
21
+
22
+ def generate_access_code(groups: int = 3, group_len: int = 4) -> str:
23
+ """Human-friendly high-entropy code, e.g. A9KP-29QX-L81M."""
24
+ alphabet = string.ascii_uppercase + string.digits
25
+ parts = [
26
+ "".join(secrets.choice(alphabet) for _ in range(group_len))
27
+ for _ in range(groups)
28
+ ]
29
+ return "-".join(parts)
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: codexfer
3
+ Version: 0.1.0
4
+ Summary: End-to-end encrypted code sharing for developers.
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: typer>=0.12
7
+ Requires-Dist: rich>=13.7
8
+ Requires-Dist: cryptography>=42.0
9
+ Requires-Dist: requests>=2.31
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ codexfer/__init__.py
4
+ codexfer/api_client.py
5
+ codexfer/cli.py
6
+ codexfer/compression.py
7
+ codexfer/config.py
8
+ codexfer/encryption.py
9
+ codexfer/package_format.py
10
+ codexfer/utils.py
11
+ codexfer.egg-info/PKG-INFO
12
+ codexfer.egg-info/SOURCES.txt
13
+ codexfer.egg-info/dependency_links.txt
14
+ codexfer.egg-info/entry_points.txt
15
+ codexfer.egg-info/requires.txt
16
+ codexfer.egg-info/top_level.txt
17
+ tests/test_core.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codexfer = codexfer.cli:app
@@ -0,0 +1,7 @@
1
+ typer>=0.12
2
+ rich>=13.7
3
+ cryptography>=42.0
4
+ requests>=2.31
5
+
6
+ [dev]
7
+ pytest>=8.0
@@ -0,0 +1 @@
1
+ codexfer
@@ -0,0 +1,24 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "codexfer"
7
+ version = "0.1.0"
8
+ description = "End-to-end encrypted code sharing for developers."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "typer>=0.12",
12
+ "rich>=13.7",
13
+ "cryptography>=42.0",
14
+ "requests>=2.31",
15
+ ]
16
+
17
+ [project.optional-dependencies]
18
+ dev = ["pytest>=8.0"]
19
+
20
+ [project.scripts]
21
+ codexfer = "codexfer.cli:app"
22
+
23
+ [tool.setuptools.packages.find]
24
+ include = ["codexfer*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,45 @@
1
+ from codexfer.encryption import encrypt_bytes, decrypt_bytes
2
+ from codexfer.compression import compress_project, extract_project
3
+ from codexfer.package_format import write_cxf, read_cxf
4
+
5
+
6
+ def test_encrypt_decrypt_roundtrip():
7
+ plaintext = b"print('hello world')"
8
+ code = "A9KP-29QX-L81M"
9
+ blob = encrypt_bytes(plaintext, code)
10
+ assert decrypt_bytes(blob, code) == plaintext
11
+
12
+
13
+ def test_wrong_access_code_fails():
14
+ plaintext = b"secret source code"
15
+ blob = encrypt_bytes(plaintext, "RIGHT-CODE-0000")
16
+ try:
17
+ decrypt_bytes(blob, "WRONG-CODE-1111")
18
+ assert False, "should have raised"
19
+ except Exception:
20
+ pass
21
+
22
+
23
+ def test_pack_and_extract_roundtrip(tmp_path):
24
+ project = tmp_path / "proj"
25
+ (project / "src").mkdir(parents=True)
26
+ (project / "src" / "main.py").write_text("print(1)")
27
+ (project / ".hidden").write_text("secret")
28
+
29
+ archive = tmp_path / "out.zip"
30
+ compress_project(project, archive)
31
+
32
+ restored = tmp_path / "restored"
33
+ extract_project(archive, restored)
34
+
35
+ assert (restored / "src" / "main.py").read_text() == "print(1)"
36
+ assert (restored / ".hidden").read_text() == "secret"
37
+
38
+
39
+ def test_cxf_header_roundtrip(tmp_path):
40
+ blob = encrypt_bytes(b"data", "CODE-0000-0000")
41
+ cxf_path = tmp_path / "pkg.cxf"
42
+ write_cxf(cxf_path, "demo-project", blob)
43
+ header, ciphertext = read_cxf(cxf_path)
44
+ assert header.package_name == "demo-project"
45
+ assert ciphertext == blob.ciphertext