diff --git a/src/openjarvis/security/file_utils.py b/src/openjarvis/security/file_utils.py new file mode 100644 index 00000000..eb960fdf --- /dev/null +++ b/src/openjarvis/security/file_utils.py @@ -0,0 +1,34 @@ +"""Secure file and directory creation helpers. + +All OpenJarvis data files under ``~/.openjarvis/`` should be created +through these helpers to ensure consistent, restrictive permissions. +""" + +from __future__ import annotations + +import os +from pathlib import Path + + +def secure_mkdir(path: Path, mode: int = 0o700) -> Path: + """Create a directory with restrictive permissions. + + Creates parent directories as needed, then sets *mode* on the + target directory (even if it already exists). + """ + path.mkdir(parents=True, exist_ok=True) + os.chmod(path, mode) + return path + + +def secure_create(path: Path, mode: int = 0o600) -> Path: + """Ensure a file exists with restrictive permissions. + + Creates the parent directory with ``0o700`` if needed, touches the + file if it doesn't exist, and sets *mode* on it. + """ + secure_mkdir(path.parent, mode=0o700) + if not path.exists(): + path.touch() + os.chmod(path, mode) + return path diff --git a/tests/security/test_file_permissions.py b/tests/security/test_file_permissions.py new file mode 100644 index 00000000..758d020c --- /dev/null +++ b/tests/security/test_file_permissions.py @@ -0,0 +1,74 @@ +"""Tests for secure file creation helpers (Section 4).""" + +from __future__ import annotations + +import os +import stat +import tempfile +from pathlib import Path + + +class TestSecureMkdir: + """secure_mkdir should create directories with 0o700.""" + + def test_creates_directory_with_700(self) -> None: + from openjarvis.security.file_utils import secure_mkdir + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "secure_dir" + result = secure_mkdir(target) + assert result.is_dir() + mode = stat.S_IMODE(os.stat(target).st_mode) + assert mode == 0o700 + + def test_creates_parent_directories(self) -> None: + from openjarvis.security.file_utils import secure_mkdir + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "a" / "b" / "c" + result = secure_mkdir(target) + assert result.is_dir() + + def test_existing_directory_gets_permission_fix(self) -> None: + from openjarvis.security.file_utils import secure_mkdir + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "existing" + target.mkdir(mode=0o755) + secure_mkdir(target) + mode = stat.S_IMODE(os.stat(target).st_mode) + assert mode == 0o700 + + +class TestSecureCreate: + """secure_create should create files with 0o600.""" + + def test_creates_file_with_600(self) -> None: + from openjarvis.security.file_utils import secure_create + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "secure_file.db" + result = secure_create(target) + assert result.exists() + mode = stat.S_IMODE(os.stat(target).st_mode) + assert mode == 0o600 + + def test_existing_file_gets_permission_fix(self) -> None: + from openjarvis.security.file_utils import secure_create + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "existing.db" + target.write_text("data") + os.chmod(target, 0o644) + secure_create(target) + mode = stat.S_IMODE(os.stat(target).st_mode) + assert mode == 0o600 + + def test_creates_parent_directory_with_700(self) -> None: + from openjarvis.security.file_utils import secure_create + + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "sub" / "file.db" + secure_create(target) + parent_mode = stat.S_IMODE(os.stat(target.parent).st_mode) + assert parent_mode == 0o700