diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 88bce3a..bbd2ad9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -37,6 +37,7 @@ Run the following commands to verify your changes: - **Dependencies**: if you add a new Python library to `requirements.txt`, you MUST run `voxvera vendorize` to include it in the portable distribution. - **Layout Integrity**: VoxVera uses visual width validation. If you add new UI elements, ensure they respect the physical boundaries of an 8.5"x11" flyer. - **Tests**: Ensure all tests pass: `pytest tests/`. Run the integrity suite specifically when modifying locales: `pytest tests/test_integrity.py`. +- **Recommended local check**: Run `bash scripts/check.sh` before opening a PR. It mirrors the main lint/test/build checks in one command. - **Style**: Follow the existing coding style (PEP 8 for Python). - **Documentation**: If adding a new feature, update the master templates in `docs/templates/` and run `voxvera build-docs`. diff --git a/README.md b/README.md index 9e9c002..8496020 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,13 @@ pip install . voxvera vendorize # Optional: pull dependencies locally for offline use ``` +### 5. Local Validation +Run the full local validation sweep with: +```bash +bash scripts/check.sh +``` +This runs linting, Python tests, Electron smoke tests, dependency checks, and the docs/site rebuild steps used during development. + --- ## Key Features diff --git a/ci/test-e2e.sh b/ci/test-e2e.sh index 4e04639..f67fdc6 100755 --- a/ci/test-e2e.sh +++ b/ci/test-e2e.sh @@ -15,9 +15,12 @@ echo "Starting E2E verification at $(date)" if [ -n "${VOXVERA_BIN:-}" ] && [ -f "$VOXVERA_BIN" ]; then voxvera_cmd="$VOXVERA_BIN" echo "Using provided VoxVera binary: $voxvera_cmd" >&2 +elif command -v voxvera >/dev/null 2>&1; then + voxvera_cmd="$(command -v voxvera)" + echo "Using existing VoxVera on PATH: $voxvera_cmd" >&2 else echo "Installing VoxVera in editable mode..." - python3 -m pip install --break-system-packages -e ".[e2e]" + python3 -m pip install --break-system-packages --no-build-isolation -e ".[e2e]" voxvera_cmd="$(command -v voxvera || echo "$HOME/.local/bin/voxvera")" fi @@ -30,16 +33,22 @@ DemoUser voxvera EOI +config_path="$HOME/host/voxvera/config.json" +if [ ! -f "$config_path" ]; then + echo "CRITICAL: Expected config file not found after init: $config_path" >&2 + exit 1 +fi + echo "Building demo flyer..." -"$voxvera_cmd" --lang en build +"$voxvera_cmd" --lang en --config "$config_path" build # Verify build output exists in host/ folder_name="voxvera" -host_dir="voxvera/host/$folder_name" +host_dir="$HOME/host/$folder_name" if [ ! -d "$host_dir" ]; then echo "CRITICAL: Host directory $host_dir not found!" >&2 - ls -R voxvera/host/ || true + ls -R "$HOME/host/" || true exit 1 fi diff --git a/gui/electron/main.js b/gui/electron/main.js index 9f9d238..57c03ac 100644 --- a/gui/electron/main.js +++ b/gui/electron/main.js @@ -4,6 +4,7 @@ const path = require('path'); const which = require('which'); const fs = require('fs'); const { launchTor } = require('./tor.js'); +const { extractOnionUrl } = require('./runtime-utils'); let mainWindow; let onionProc; @@ -74,8 +75,12 @@ async function runServe (retry = false) { onionProc.stdout.on('data', buf => { const line = buf.toString(); - const m = line.match(/OnionShare is hosting at (http.*\.onion)/); - if (m) { gotURL = true; clearTimeout(softTimeout); } + const onionUrl = extractOnionUrl(line); + if (onionUrl) { + gotURL = true; + clearTimeout(softTimeout); + mainWindow.webContents.send('onion-url', onionUrl); + } mainWindow.webContents.send('log', { text: line }); }); @@ -134,9 +139,9 @@ ipcMain.handle('run-quickstart', async (_, config) => { if (mainWindow) { mainWindow.webContents.send('log', { text: line, isError: false }); } - const m = line.match(/Onion URL:\s*(https?:\/\/[a-z0-9.-]+\.onion)/i); - if (m && mainWindow) { - mainWindow.webContents.send('onion-url', m[1]); + const onionUrl = extractOnionUrl(line); + if (onionUrl && mainWindow) { + mainWindow.webContents.send('onion-url', onionUrl); } }); proc.stderr.on('data', data => { diff --git a/gui/electron/package.json b/gui/electron/package.json index d204167..e0aa096 100644 --- a/gui/electron/package.json +++ b/gui/electron/package.json @@ -4,7 +4,8 @@ "main": "main.js", "scripts": { "start": "electron .", - "lint": "echo 'lint pass'" + "lint": "echo 'lint pass'", + "test": "node --test runtime-utils.test.js" }, "devDependencies": { "electron": "^29.0.0", diff --git a/gui/electron/runtime-utils.js b/gui/electron/runtime-utils.js new file mode 100644 index 0000000..0b481dd --- /dev/null +++ b/gui/electron/runtime-utils.js @@ -0,0 +1,16 @@ +function extractOnionUrl(line) { + const match = line.match(/Onion URL:\s*(https?:\/\/[a-z0-9.-]+\.onion)/i) + || line.match(/OnionShare is hosting at\s*(https?:\/\/[a-z0-9.-]+\.onion)/i); + return match ? match[1] : null; +} + +function platformDirName(platform = process.platform) { + if (platform === 'win32') return 'win'; + if (platform === 'darwin') return 'mac'; + return 'linux'; +} + +module.exports = { + extractOnionUrl, + platformDirName, +}; diff --git a/gui/electron/runtime-utils.test.js b/gui/electron/runtime-utils.test.js new file mode 100644 index 0000000..bd35491 --- /dev/null +++ b/gui/electron/runtime-utils.test.js @@ -0,0 +1,28 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { extractOnionUrl, platformDirName } = require('./runtime-utils'); + +test('extractOnionUrl handles current CLI output', () => { + assert.equal( + extractOnionUrl('[voxvera] Onion URL: http://test123.onion'), + 'http://test123.onion' + ); +}); + +test('extractOnionUrl handles legacy OnionShare output', () => { + assert.equal( + extractOnionUrl('OnionShare is hosting at http://legacy456.onion'), + 'http://legacy456.onion' + ); +}); + +test('extractOnionUrl returns null when no onion URL is present', () => { + assert.equal(extractOnionUrl('still bootstrapping'), null); +}); + +test('platformDirName maps bundled resource folders correctly', () => { + assert.equal(platformDirName('linux'), 'linux'); + assert.equal(platformDirName('darwin'), 'mac'); + assert.equal(platformDirName('win32'), 'win'); +}); diff --git a/gui/electron/tor.js b/gui/electron/tor.js index e863819..23893de 100644 --- a/gui/electron/tor.js +++ b/gui/electron/tor.js @@ -3,14 +3,16 @@ const path = require('path'); const fs = require('fs'); const which = require('which'); const getPort = (...args) => import('get-port').then(m => m.default(...args)); +const { platformDirName } = require('./runtime-utils'); async function launchTor() { const socks = await getPort(); const control = await getPort(); const torBase = path.join(__dirname, '..', '..', 'voxvera', 'resources', 'tor'); - let exe = path.join(torBase, process.platform, + const platformDir = platformDirName(process.platform); + let exe = path.join(torBase, platformDir, process.platform === 'win32' ? 'tor.exe' : 'tor'); - let obfs4 = path.join(torBase, process.platform, + let obfs4 = path.join(torBase, platformDir, process.platform === 'win32' ? 'obfs4proxy.exe' : 'obfs4proxy'); const missing = p => { diff --git a/pyproject.toml b/pyproject.toml index cb05411..05ea181 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ voxvera = ["templates/**", "src/**", "resources/**", "locales/**", "resources/bi [project] name = "voxvera" -version = "0.3.3" +version = "0.3.4" description = "VoxVera CLI utilities" readme = "README.md" authors = [{name = "VoxVera"}] diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..46d3281 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +echo "==> flake8" +python3 -m flake8 voxvera tests \ + --jobs=1 \ + --exclude=voxvera/vendor \ + --ignore=E501,W503,W293,E402,E306,E704,E261,F841,E302,F401,E401,F541 + +echo "==> pytest" +python3 -m pytest tests/ -v + +echo "==> electron smoke tests" +(cd gui/electron && npm test) + +echo "==> dependency check" +python3 -m voxvera.cli --lang en check + +echo "==> build docs" +python3 -m voxvera.cli build-docs + +echo "==> build site" +python3 -m voxvera.cli build-site + +echo "All local checks passed." diff --git a/site/download/VoxVera-x86_64.AppImage b/site/download/VoxVera-x86_64.AppImage deleted file mode 100755 index f9782ce..0000000 Binary files a/site/download/VoxVera-x86_64.AppImage and /dev/null differ diff --git a/site/download/voxvera-source.zip b/site/download/voxvera-source.zip index 624d529..ca90056 100644 Binary files a/site/download/voxvera-source.zip and b/site/download/voxvera-source.zip differ diff --git a/tests/test_cli.py b/tests/test_cli.py index e3a0f68..a227a23 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -126,6 +126,21 @@ def test_import_preserves_session(tmp_path, monkeypatch): assert (host_dir / "index.html").exists() +def test_import_rejects_zip_path_traversal(tmp_path, monkeypatch, capsys): + _setup_tmp(monkeypatch, tmp_path) + malicious_zip = tmp_path / "malicious.zip" + with zipfile.ZipFile(malicious_zip, "w") as zf: + zf.writestr("../escape.txt", "owned") + zf.writestr("config.json", json.dumps({"folder_name": "evil"})) + + cli.import_site(str(malicious_zip)) + + captured = capsys.readouterr() + assert "Refusing to extract path outside destination" in captured.out + assert not (tmp_path / "escape.txt").exists() + assert not ((tmp_path / "data" / "host" / "evil").exists()) + + def test_check_all_present(capsys, monkeypatch): monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/" + cmd) cli.main(["check"]) diff --git a/voxvera/__init__.py b/voxvera/__init__.py index a5fbca3..a61915f 100644 --- a/voxvera/__init__.py +++ b/voxvera/__init__.py @@ -1,7 +1,7 @@ import sys from pathlib import Path -__version__ = "0.3.3" +__version__ = "0.3.4" # Add the vendor directory to sys.path ONLY for PyInstaller (frozen) builds. # For pip/pipx installs the system/venv packages are correct and the vendored diff --git a/voxvera/cli.py b/voxvera/cli.py index 51aec64..771bd0a 100644 --- a/voxvera/cli.py +++ b/voxvera/cli.py @@ -28,6 +28,8 @@ from InquirerPy import prompt, inquirer from rich.console import Console from voxvera import __version__ +GITHUB_RECOMMENDED_BINARY_LIMIT = 95 * 1024 * 1024 + # package root (contains bundled templates and src/) if getattr(sys, 'frozen', False): ROOT = Path(sys._MEIPASS).joinpath("voxvera") @@ -858,6 +860,19 @@ import zipfile import tempfile +def _safe_extract_zip(zipf: zipfile.ZipFile, dest_dir: Path) -> None: + """Extract a ZIP archive without allowing path traversal.""" + dest_dir = dest_dir.resolve() + for member in zipf.infolist(): + member_path = Path(member.filename) + if member_path.is_absolute(): + raise ValueError(f"Refusing to extract absolute path from archive: {member.filename}") + target_path = (dest_dir / member_path).resolve() + if not str(target_path).startswith(str(dest_dir) + os.sep) and target_path != dest_dir: + raise ValueError(f"Refusing to extract path outside destination: {member.filename}") + zipf.extractall(dest_dir) + + def get_export_dir() -> Path: """Get the default export directory (~/voxvera-exports).""" export_dir = Path.home() / "voxvera-exports" @@ -911,7 +926,11 @@ def import_site(zip_path: str): with tempfile.TemporaryDirectory() as tmpdir: with zipfile.ZipFile(zip_path, 'r') as zipf: - zipf.extractall(tmpdir) + try: + _safe_extract_zip(zipf, Path(tmpdir)) + except ValueError as e: + print(f"Error: Invalid export file. {e}") + return tmp_path = Path(tmpdir) config_file = tmp_path / "config.json" @@ -1185,11 +1204,15 @@ def build_site(): # Bundle source version bundle_source(download_dir / "voxvera-source.zip") - # Copy standalone binaries if they exist + # Copy standalone binaries if they exist, but avoid writing oversized + # artifacts into the tracked site tree where GitHub rejects >100 MB files. bin_dir = ROOT / "resources" / "bin" if bin_dir.exists(): for bin_file in bin_dir.iterdir(): if bin_file.is_file(): + if bin_file.stat().st_size > GITHUB_RECOMMENDED_BINARY_LIMIT: + print(f"Skipping oversized site download artifact: {bin_file.name}") + continue shutil.copy(bin_file, download_dir / bin_file.name) print("site/ directory successfully synchronized.")