Skip to content

Commit 402f3b2

Browse files
committed
Add shared safe extract utils, tests, env scripts, and CI workflow
- utils_safe_extract.py for safe tar/zip extraction - Refactor GUIs to use shared utils - tests/: pytest for safe extract and command builder - requirements: add pytest - .github/workflows/ci.yml: run pytest and enforce 98MB file size cap - .envrc.example and .python-version for direnv/pyenv
1 parent 26a4dc7 commit 402f3b2

9 files changed

Lines changed: 179 additions & 61 deletions

File tree

.envrc.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# direnv example for ffmpeg-gui-pyqt6
2+
3+
# Use pyenv local version if present
4+
use python
5+
6+
# Create venv if missing and activate
7+
layout python
8+
9+
# Optional: set Qt environment tweaks
10+
# export QT_LOGGING_RULES="*.debug=false;qt.qpa.*=false"

.github/workflows/ci.yml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [ master, fix/**, feature/** ]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- name: Set up Python
14+
uses: actions/setup-python@v5
15+
with:
16+
python-version: '3.11'
17+
- name: Install dependencies
18+
run: |
19+
python -m pip install --upgrade pip
20+
python -m pip install -r requirements.txt
21+
python -m pip install pytest
22+
- name: Enforce max file size
23+
run: |
24+
# Fail if any repo file exceeds 98MB (102,760,448 bytes)
25+
max=102760448
26+
while IFS= read -r -d '' f; do
27+
sz=$(stat -c%s "$f")
28+
if [ "$sz" -ge "$max" ]; then
29+
echo "File too large: $f ($sz bytes)"
30+
exit 1
31+
fi
32+
done < <(git ls-files -z)
33+
- name: Run tests
34+
env:
35+
QT_QPA_PLATFORM: offscreen
36+
run: |
37+
pytest -q

.python-version

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
3.11.9

GUI_pyqt6_WINFF.py

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import tarfile
1515
from io import BytesIO
1616
from functools import partial
17+
from utils_safe_extract import safe_tar_extract, safe_zip_extract
1718

1819
from PyQt6 import QtWidgets, QtCore
1920
from PyQt6.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, QTextEdit, QComboBox, QFileDialog, QCheckBox, QHBoxLayout, QVBoxLayout, QProgressDialog
@@ -454,7 +455,7 @@ def _extract_ffmpeg_zip(self, zip_bytes):
454455
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
455456
os.makedirs(base_dir, exist_ok=True)
456457
with zipfile.ZipFile(BytesIO(zip_bytes)) as zf:
457-
self._safe_zip_extract(zf, base_dir)
458+
safe_zip_extract(zf, base_dir)
458459
# try to find ffmpeg(.exe)
459460
ffmpeg_path = None
460461
for root, dirs, files in os.walk(base_dir):
@@ -498,7 +499,7 @@ def worker():
498499
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
499500
os.makedirs(base_dir, exist_ok=True)
500501
with tarfile.open(fileobj=BytesIO(content), mode='r:xz') as tf:
501-
self._safe_tar_extract(tf, base_dir)
502+
safe_tar_extract(tf, base_dir)
502503
# procurar binário
503504
ffmpeg_path = None
504505
for root, dirs, files in os.walk(base_dir):
@@ -561,36 +562,7 @@ def show_info_msg(self, msg):
561562
def show_error_msg(self, msg):
562563
QtWidgets.QMessageBox.critical(self, 'Erro', msg)
563564

564-
# ---- security helpers ----
565-
def _safe_tar_extract(self, tf: tarfile.TarFile, base_dir: str):
566-
base = os.path.realpath(base_dir)
567-
for member in tf.getmembers():
568-
member_path = os.path.realpath(os.path.join(base, member.name))
569-
if not member_path.startswith(base + os.sep) and member_path != base:
570-
raise RuntimeError(f"Entrada insegura no tar: {member.name}")
571-
tf.extractall(base)
572-
573-
def _safe_zip_extract(self, zf: zipfile.ZipFile, base_dir: str):
574-
base = os.path.realpath(base_dir)
575-
for zi in zf.infolist():
576-
name = zi.filename
577-
# reject absolute paths or drive letters
578-
if os.path.isabs(name) or (
579-
len(name) > 1 and name[1] == ':'
580-
):
581-
raise RuntimeError(f"Entrada insegura no zip (absoluta): {name}")
582-
dest = os.path.realpath(os.path.join(base, name))
583-
if not dest.startswith(base + os.sep) and dest != base:
584-
raise RuntimeError(f"Entrada insegura no zip: {name}")
585-
# after validation, extract members
586-
for zi in zf.infolist():
587-
dest = os.path.realpath(os.path.join(base, zi.filename))
588-
if zi.is_dir() or zi.filename.endswith('/'):
589-
os.makedirs(dest, exist_ok=True)
590-
continue
591-
os.makedirs(os.path.dirname(dest), exist_ok=True)
592-
with zf.open(zi, 'r') as src, open(dest, 'wb') as out:
593-
out.write(src.read())
565+
# ---- security helpers moved to utils_safe_extract ----
594566

595567
if __name__ == '__main__':
596568
app = QApplication(sys.argv)

GUI_tkinter_WINFF.py

Lines changed: 4 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import tarfile
1717
import tkinter as tk
1818
from tkinter import ttk, filedialog, messagebox
19+
from utils_safe_extract import safe_tar_extract, safe_zip_extract
1920

2021
import importlib
2122
try:
@@ -406,7 +407,7 @@ def _extract_ffmpeg_zip(self, zip_bytes: bytes) -> str | None:
406407
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
407408
os.makedirs(base_dir, exist_ok=True)
408409
with zipfile.ZipFile(BytesIO(zip_bytes)) as zf:
409-
self._safe_zip_extract(zf, base_dir)
410+
safe_zip_extract(zf, base_dir)
410411
for root, _dirs, files in os.walk(base_dir):
411412
for f in files:
412413
if f.lower() == ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg'):
@@ -447,7 +448,7 @@ def worker():
447448
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
448449
os.makedirs(base_dir, exist_ok=True)
449450
with tarfile.open(fileobj=BytesIO(content), mode='r:xz') as tf:
450-
self._safe_tar_extract(tf, base_dir)
451+
safe_tar_extract(tf, base_dir)
451452
ffpath = None
452453
for root, _d, files in os.walk(base_dir):
453454
for f in files:
@@ -484,33 +485,7 @@ def poll():
484485
threading.Thread(target=worker, daemon=True).start()
485486
self.after(200, poll)
486487

487-
def _safe_tar_extract(self, tf: tarfile.TarFile, base_dir: str):
488-
base = os.path.realpath(base_dir)
489-
for member in tf.getmembers():
490-
member_path = os.path.realpath(os.path.join(base, member.name))
491-
if not member_path.startswith(base + os.sep) and member_path != base:
492-
raise RuntimeError(f"Entrada insegura no tar: {member.name}")
493-
tf.extractall(base)
494-
495-
def _safe_zip_extract(self, zf: zipfile.ZipFile, base_dir: str):
496-
base = os.path.realpath(base_dir)
497-
for zi in zf.infolist():
498-
name = zi.filename
499-
# reject absolute paths or drive letters on Windows
500-
if os.path.isabs(name) or (len(name) > 1 and name[1] == ':'):
501-
raise RuntimeError(f"Entrada insegura no zip (absoluta): {name}")
502-
dest = os.path.realpath(os.path.join(base, name))
503-
if not dest.startswith(base + os.sep) and dest != base:
504-
raise RuntimeError(f"Entrada insegura no zip: {name}")
505-
# after validation, extract members
506-
for zi in zf.infolist():
507-
dest = os.path.realpath(os.path.join(base, zi.filename))
508-
if zi.is_dir() or zi.filename.endswith('/'):
509-
os.makedirs(dest, exist_ok=True)
510-
continue
511-
os.makedirs(os.path.dirname(dest), exist_ok=True)
512-
with zf.open(zi, 'r') as src, open(dest, 'wb') as out:
513-
out.write(src.read())
488+
# safe extract helpers moved to utils_safe_extract
514489

515490
def install_ffmpeg_via_winget(self):
516491
if platform.system() != 'Windows':

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
PyQt6>=6.4.0
22
ffmpeg-python>=0.2.0
3+
pytest>=8.2.0

tests/test_command_build.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import os
2+
import shlex
3+
import types
4+
5+
# We'll import the GUI modules and call their build_command_list through a minimal shim.
6+
7+
8+
def test_pyqt6_build_command_list_defaults(monkeypatch):
9+
import GUI_pyqt6_WINFF as m
10+
11+
w = m.FFmpegGuiPyQt6()
12+
# simulate basic defaults
13+
w.input_edit.setText('/tmp/in.mp4')
14+
w.same_dir_chk.setChecked(True)
15+
w.set_default_options()
16+
17+
args = w.build_command_list()
18+
assert args[0] == 'ffmpeg'
19+
assert '-i' in args and '/tmp/in.mp4' in args
20+
# ensure no -s when resolution is original
21+
assert '-s' not in args
22+
23+
24+
def test_tkinter_build_command_list_defaults(monkeypatch):
25+
import GUI_tkinter_WINFF as m
26+
27+
app = m.FFmpegGuiTk()
28+
app.input_var.set('/tmp/in.mp4')
29+
app.same_dir_var.set(True)
30+
app.set_default_options()
31+
32+
args = app.build_command_list()
33+
assert args[0] == 'ffmpeg'
34+
assert '-i' in args and '/tmp/in.mp4' in args
35+
assert '-s' not in args

tests/test_safe_extract.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import io
2+
import os
3+
import tarfile
4+
import zipfile
5+
import tempfile
6+
import pytest
7+
8+
from utils_safe_extract import safe_tar_extract, safe_zip_extract
9+
10+
11+
def test_safe_tar_extract_blocks_traversal(tmp_path):
12+
# create a tar with a member that tries to escape
13+
data = io.BytesIO()
14+
with tarfile.open(fileobj=data, mode='w:xz') as tf:
15+
ti = tarfile.TarInfo(name='ok/file.txt')
16+
payload = b'hello'
17+
ti.size = len(payload)
18+
tf.addfile(ti, io.BytesIO(payload))
19+
bad = tarfile.TarInfo(name='../../evil.txt')
20+
bad.size = len(payload)
21+
tf.addfile(bad, io.BytesIO(payload))
22+
data.seek(0)
23+
24+
with tarfile.open(fileobj=io.BytesIO(data.read()), mode='r:xz') as tf:
25+
with pytest.raises(RuntimeError):
26+
safe_tar_extract(tf, str(tmp_path))
27+
28+
29+
def test_safe_zip_extract_blocks_traversal(tmp_path):
30+
# create a zip with a traversal
31+
data = io.BytesIO()
32+
with zipfile.ZipFile(data, mode='w') as z:
33+
z.writestr('ok/file.txt', 'hello')
34+
z.writestr('../../evil.txt', 'hello')
35+
data.seek(0)
36+
37+
with zipfile.ZipFile(io.BytesIO(data.read()), mode='r') as z:
38+
with pytest.raises(RuntimeError):
39+
safe_zip_extract(z, str(tmp_path))
40+
41+
42+
def test_safe_zip_extract_ok(tmp_path):
43+
data = io.BytesIO()
44+
with zipfile.ZipFile(data, mode='w') as z:
45+
z.writestr('dir/a.txt', 'x')
46+
data.seek(0)
47+
with zipfile.ZipFile(io.BytesIO(data.read()), mode='r') as z:
48+
safe_zip_extract(z, str(tmp_path))
49+
assert (tmp_path / 'dir' / 'a.txt').exists()

utils_safe_extract.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""
2+
Safe extraction helpers for tar and zip archives to prevent path traversal.
3+
"""
4+
from __future__ import annotations
5+
6+
import os
7+
import tarfile
8+
import zipfile
9+
10+
11+
def safe_tar_extract(tf: tarfile.TarFile, base_dir: str) -> None:
12+
base = os.path.realpath(base_dir)
13+
for member in tf.getmembers():
14+
member_path = os.path.realpath(os.path.join(base, member.name))
15+
if not member_path.startswith(base + os.sep) and member_path != base:
16+
raise RuntimeError(f"Entrada insegura no tar: {member.name}")
17+
tf.extractall(base)
18+
19+
20+
def safe_zip_extract(zf: zipfile.ZipFile, base_dir: str) -> None:
21+
base = os.path.realpath(base_dir)
22+
for zi in zf.infolist():
23+
name = zi.filename
24+
# reject absolute paths or Windows drive letters
25+
if os.path.isabs(name) or (len(name) > 1 and name[1] == ':'):
26+
raise RuntimeError(f"Entrada insegura no zip (absoluta): {name}")
27+
dest = os.path.realpath(os.path.join(base, name))
28+
if not dest.startswith(base + os.sep) and dest != base:
29+
raise RuntimeError(f"Entrada insegura no zip: {name}")
30+
# after validation, extract members
31+
for zi in zf.infolist():
32+
dest = os.path.realpath(os.path.join(base, zi.filename))
33+
if zi.is_dir() or zi.filename.endswith('/'):
34+
os.makedirs(dest, exist_ok=True)
35+
continue
36+
os.makedirs(os.path.dirname(dest), exist_ok=True)
37+
with zf.open(zi, 'r') as src, open(dest, 'wb') as out:
38+
out.write(src.read())

0 commit comments

Comments
 (0)