browserget 1.0.1__py3-none-any.whl

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.
browserget/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """browserget — Standalone CLI to install browsers and drivers without any framework."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _pkg_version
5
+
6
+ from browserget.config import Config, load_config
7
+ from browserget.exceptions import (
8
+ AlreadyInstalledError,
9
+ BrowsergetError,
10
+ ChecksumMismatchError,
11
+ DriverMatchError,
12
+ InsufficientDiskSpaceError,
13
+ NetworkError,
14
+ UnknownTargetError,
15
+ UnsupportedPlatformError,
16
+ VersionNotFoundError,
17
+ )
18
+ from browserget.models import InstalledArtifact, ResolvedVersion, SystemBrowser
19
+ from browserget.platform import OS, Arch, Platform, detect_platform
20
+
21
+ try:
22
+ __version__ = _pkg_version("browserget")
23
+ except PackageNotFoundError: # pragma: no cover
24
+ __version__ = "0.0.0"
25
+
26
+ __all__ = [
27
+ "AlreadyInstalledError",
28
+ "Arch",
29
+ "BrowsergetError",
30
+ "ChecksumMismatchError",
31
+ "Config",
32
+ "DriverMatchError",
33
+ "InsufficientDiskSpaceError",
34
+ "InstalledArtifact",
35
+ "NetworkError",
36
+ "OS",
37
+ "Platform",
38
+ "ResolvedVersion",
39
+ "SystemBrowser",
40
+ "UnknownTargetError",
41
+ "UnsupportedPlatformError",
42
+ "VersionNotFoundError",
43
+ "__version__",
44
+ "detect_platform",
45
+ "load_config",
46
+ ]
browserget/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for running browserget as a module."""
2
+
3
+ from browserget.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
browserget/archive.py ADDED
@@ -0,0 +1,114 @@
1
+ """Safe archive extraction utilities with path-traversal protection.
2
+
3
+ All extraction functions validate that every entry in the archive resolves
4
+ to a path inside the destination directory, preventing Zip Slip and similar
5
+ path-traversal attacks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import stat
12
+ import sys
13
+ import tarfile
14
+ import zipfile
15
+ from pathlib import Path
16
+
17
+
18
+ def _safe_extract_path(dest: Path, member_name: str) -> Path:
19
+ """Resolve *member_name* relative to *dest* and verify it stays inside.
20
+
21
+ Args:
22
+ dest: The destination directory.
23
+ member_name: The archive member's relative path.
24
+
25
+ Returns:
26
+ The resolved, validated path.
27
+
28
+ Raises:
29
+ ValueError: If the resolved path escapes *dest*.
30
+ """
31
+ dest_resolved = dest.resolve()
32
+ target = (dest / member_name).resolve()
33
+ try:
34
+ target.relative_to(dest_resolved)
35
+ except ValueError as exc:
36
+ raise ValueError(f"Archive entry '{member_name}' escapes destination directory") from exc
37
+ return target
38
+
39
+
40
+ def extract_zip(archive_path: Path, dest: Path) -> None:
41
+ """Extract a zip archive safely, preventing path traversal.
42
+
43
+ Validates both member paths and symlink targets to prevent
44
+ path-traversal attacks via malicious symlinks.
45
+
46
+ Args:
47
+ archive_path: Path to the zip file.
48
+ dest: Destination directory (must exist).
49
+
50
+ Raises:
51
+ ValueError: If any entry escapes the destination directory.
52
+ zipfile.BadZipFile: If the archive is corrupt or not a zip file.
53
+ """
54
+ dest.mkdir(parents=True, exist_ok=True)
55
+ with zipfile.ZipFile(archive_path, "r") as zf:
56
+ for member in zf.infolist():
57
+ _safe_extract_path(dest, member.filename)
58
+ unix_mode = member.external_attr >> 16
59
+ if stat.S_ISLNK(unix_mode):
60
+ link_target = zf.read(member).decode("utf-8", errors="replace")
61
+ _safe_extract_path(dest, link_target)
62
+ zf.extractall(dest)
63
+
64
+
65
+ def extract_tar(archive_path: Path, dest: Path) -> None:
66
+ """Extract a tar archive safely, preventing path traversal.
67
+
68
+ Automatically detects compression (gzip, bzip2, xz, or uncompressed).
69
+ Validates both member paths and symlink/hardlink targets to prevent
70
+ path-traversal attacks via malicious symlinks.
71
+
72
+ Args:
73
+ archive_path: Path to the tar file.
74
+ dest: Destination directory (must exist).
75
+
76
+ Raises:
77
+ ValueError: If any entry escapes the destination directory.
78
+ tarfile.TarError: If the archive is corrupt.
79
+ """
80
+ dest.mkdir(parents=True, exist_ok=True)
81
+ with tarfile.open(str(archive_path), "r:*") as tf:
82
+ for member in tf.getmembers():
83
+ _safe_extract_path(dest, member.name)
84
+ if member.issym() or member.islnk():
85
+ _safe_extract_path(dest, member.linkname)
86
+ if sys.version_info >= (3, 12):
87
+ tf.extractall(dest, filter="data")
88
+ else:
89
+ for member in tf.getmembers():
90
+ if not (member.isfile() or member.isdir() or member.issym() or member.islnk()):
91
+ raise ValueError(f"Refusing to extract special file: {member.name!r}")
92
+ tf.extractall(dest)
93
+
94
+
95
+ def find_file_by_name(root: Path, filename: str) -> Path | None:
96
+ """Find a file by name in a directory tree without following symlinks.
97
+
98
+ Uses ``os.walk(followlinks=False)`` to avoid infinite recursion from
99
+ circular symlinks that may exist in extracted archives.
100
+
101
+ Args:
102
+ root: The root directory to search.
103
+ filename: The filename to match (basename only).
104
+
105
+ Returns:
106
+ The first matching file path, or ``None`` if not found.
107
+ """
108
+ for dirpath, _dirnames, filenames in os.walk(root, followlinks=False):
109
+ for fname in filenames:
110
+ if fname == filename:
111
+ candidate = Path(dirpath) / fname
112
+ if not candidate.is_symlink() and candidate.is_file():
113
+ return candidate
114
+ return None
browserget/cache.py ADDED
@@ -0,0 +1,187 @@
1
+ """Cache directory management for browserget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ from browserget.config import load_config
11
+
12
+
13
+ def get_cache_dir() -> Path:
14
+ """Return the cache directory, creating it if it does not exist.
15
+
16
+ Returns:
17
+ The path to the cache directory.
18
+
19
+ Raises:
20
+ OSError: If the directory cannot be created (e.g. read-only filesystem).
21
+ """
22
+ cache_dir = load_config().cache_dir
23
+ cache_dir.mkdir(parents=True, exist_ok=True)
24
+ return cache_dir
25
+
26
+
27
+ def _validate_path_component(component: str) -> str:
28
+ """Validate that a string is safe to use as a single path component.
29
+
30
+ Rejects empty strings, strings containing path separators (``/`` or ``\\``),
31
+ and the ``..`` traversal sequence. This prevents path-traversal attacks
32
+ when version strings or names from upstream APIs are used to construct
33
+ filesystem paths.
34
+
35
+ Args:
36
+ component: The string to validate.
37
+
38
+ Returns:
39
+ The validated string.
40
+
41
+ Raises:
42
+ ValueError: If the component contains path separators or ``..``.
43
+ """
44
+ if not component or component == ".." or component == ".":
45
+ raise ValueError(f"Unsafe path component: {component!r}")
46
+ if "\x00" in component:
47
+ raise ValueError(f"Path component contains null byte: {component!r}")
48
+ if "/" in component or "\\" in component:
49
+ raise ValueError(f"Path component contains separator: {component!r}")
50
+ # Reject any component that resolves to a parent path
51
+ if Path(component).parts != (component,):
52
+ raise ValueError(f"Path component is not a single segment: {component!r}")
53
+ return component
54
+
55
+
56
+ def get_artifact_dir(name: str, version: str) -> Path:
57
+ """Return the directory path for a specific artifact version.
58
+
59
+ Args:
60
+ name: Artifact name (e.g. "chrome", "chromedriver").
61
+ version: Artifact version string.
62
+
63
+ Returns:
64
+ The path ``{cache_dir}/{name}/{version}/``.
65
+
66
+ Raises:
67
+ ValueError: If *name* or *version* contains path separators or
68
+ traversal sequences (``..``).
69
+ """
70
+ _validate_path_component(name)
71
+ _validate_path_component(version)
72
+ return get_cache_dir() / name / version
73
+
74
+
75
+ def get_download_dir() -> Path:
76
+ """Return the temporary download directory inside the cache.
77
+
78
+ Returns:
79
+ The path ``{cache_dir}/downloads/``.
80
+ """
81
+ return get_cache_dir() / "downloads"
82
+
83
+
84
+ def safe_download_path(download_dir: Path, filename: str) -> Path:
85
+ """Construct a safe download path, rejecting path traversal in *filename*.
86
+
87
+ Args:
88
+ download_dir: The base download directory.
89
+ filename: The filename to append (typically extracted from a URL).
90
+
91
+ Returns:
92
+ The resolved path inside *download_dir*.
93
+
94
+ Raises:
95
+ ValueError: If *filename* contains path separators or ``..``
96
+ sequences that would escape *download_dir*.
97
+ """
98
+ _validate_path_component(filename)
99
+ return download_dir / filename
100
+
101
+
102
+ def cleanup_downloads() -> None:
103
+ """Delete all contents of the download directory.
104
+
105
+ Individual deletion errors are suppressed so that one locked file
106
+ does not prevent cleanup of the remaining items.
107
+ """
108
+ download_dir = get_download_dir()
109
+ if download_dir.is_symlink():
110
+ download_dir.unlink()
111
+ return
112
+ if not download_dir.exists():
113
+ return
114
+ for item in download_dir.iterdir():
115
+ try:
116
+ if item.is_symlink():
117
+ item.unlink()
118
+ elif item.is_dir():
119
+ safe_rmtree(item)
120
+ else:
121
+ item.unlink()
122
+ except OSError:
123
+ pass
124
+
125
+
126
+ def safe_rmtree(path: Path) -> None:
127
+ """Remove a directory tree, handling symlinks safely.
128
+
129
+ If *path* is a symlink, the symlink itself is unlinked rather than
130
+ recursing into its target. This prevents accidental deletion of
131
+ files outside the cache when ``path`` points elsewhere via a symlink.
132
+
133
+ Args:
134
+ path: Directory path to remove.
135
+ """
136
+ if path.is_symlink():
137
+ path.unlink()
138
+ return
139
+ shutil.rmtree(path)
140
+
141
+
142
+ def check_disk_space(required_mb: int) -> bool:
143
+ """Check if there is enough disk space for an installation.
144
+
145
+ Args:
146
+ required_mb: Required space in megabytes.
147
+
148
+ Returns:
149
+ True if available space is sufficient, False otherwise.
150
+ """
151
+ cache_dir = get_cache_dir()
152
+ check_dir = cache_dir if cache_dir.exists() else Path.home()
153
+ usage = shutil.disk_usage(check_dir)
154
+ return usage.free >= required_mb * 1024 * 1024
155
+
156
+
157
+ def get_available_disk_mb() -> int:
158
+ """Return available disk space in megabytes.
159
+
160
+ Returns:
161
+ Available space in MB at the cache directory location.
162
+ """
163
+ cache_dir = get_cache_dir()
164
+ check_dir = cache_dir if cache_dir.exists() else Path.home()
165
+ usage = shutil.disk_usage(check_dir)
166
+ return usage.free // (1024 * 1024)
167
+
168
+
169
+ def get_cache_size() -> int:
170
+ """Return the total size of the cache directory in bytes.
171
+
172
+ Returns:
173
+ Total bytes in the cache directory (recursive), or 0 if the
174
+ directory does not exist.
175
+ """
176
+ cache_dir = load_config().cache_dir
177
+ if not cache_dir.exists():
178
+ return 0
179
+ total = 0
180
+ for dirpath, _dirnames, filenames in os.walk(cache_dir, followlinks=False):
181
+ for filename in filenames:
182
+ filepath = Path(dirpath) / filename
183
+ if filepath.is_symlink():
184
+ continue
185
+ with contextlib.suppress(OSError):
186
+ total += filepath.stat().st_size
187
+ return total
browserget/checksum.py ADDED
@@ -0,0 +1,76 @@
1
+ """Checksum computation and verification using hashlib."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ from pathlib import Path
8
+
9
+ from browserget.exceptions import ChecksumMismatchError
10
+
11
+ _CHUNK_SIZE = 8192
12
+
13
+
14
+ def compute_checksum(filepath: Path, algorithm: str) -> str:
15
+ """Compute the hex digest of a file using the specified algorithm.
16
+
17
+ Reads the file in 8192-byte chunks to handle files larger than memory.
18
+
19
+ Args:
20
+ filepath: Path to the file to hash.
21
+ algorithm: Hash algorithm name (e.g. "sha256", "sha512").
22
+
23
+ Returns:
24
+ The hexadecimal digest string.
25
+
26
+ Raises:
27
+ FileNotFoundError: If the file does not exist.
28
+ ValueError: If the algorithm is not supported by hashlib.
29
+ """
30
+ try:
31
+ hasher = hashlib.new(algorithm)
32
+ except ValueError as exc:
33
+ raise ValueError(f"Unsupported algorithm: {algorithm}") from exc
34
+
35
+ with open(filepath, "rb") as f:
36
+ while True:
37
+ chunk = f.read(_CHUNK_SIZE)
38
+ if not chunk:
39
+ break
40
+ hasher.update(chunk)
41
+ return hasher.hexdigest()
42
+
43
+
44
+ def verify_checksum(filepath: Path, expected: str, algorithm: str) -> bool:
45
+ """Verify that a file's checksum matches the expected value.
46
+
47
+ Args:
48
+ filepath: Path to the file to verify.
49
+ expected: Expected hexadecimal digest.
50
+ algorithm: Hash algorithm name (e.g. "sha256", "sha512").
51
+
52
+ Returns:
53
+ True if the computed checksum matches the expected value.
54
+ """
55
+ actual = compute_checksum(filepath, algorithm)
56
+ return hmac.compare_digest(actual, expected)
57
+
58
+
59
+ def verify_or_raise(filepath: Path, expected: str, algorithm: str) -> None:
60
+ """Verify a file's checksum, raising on mismatch.
61
+
62
+ Args:
63
+ filepath: Path to the file to verify.
64
+ expected: Expected hexadecimal digest.
65
+ algorithm: Hash algorithm name (e.g. "sha256", "sha512").
66
+
67
+ Raises:
68
+ ChecksumMismatchError: If the computed checksum does not match.
69
+ """
70
+ actual = compute_checksum(filepath, algorithm)
71
+ if not hmac.compare_digest(actual, expected):
72
+ raise ChecksumMismatchError(
73
+ filename=filepath.name,
74
+ expected=expected,
75
+ actual=actual,
76
+ )