unzip-http 0.7__tar.gz → 0.9__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unzip-http
3
- Version: 0.7
3
+ Version: 0.9
4
4
  Summary: extract files from .zip files over http without downloading entire archive
5
5
  Author: Saul Pwanson
6
6
  Project-URL: Homepage, https://github.com/saulpw/unzip-http
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unzip-http"
7
- version = "0.7"
7
+ version = "0.9"
8
8
  description = "extract files from .zip files over http without downloading entire archive"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,37 @@
1
+ import io
2
+ import pathlib
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+
7
+ import unzip_http
8
+
9
+
10
+ def fake_rzf(contents):
11
+ rzf = unzip_http.RemoteZipFile.__new__(unzip_http.RemoteZipFile)
12
+ rzf.open = lambda fn: io.BytesIO(contents)
13
+ return rzf
14
+
15
+
16
+ @pytest.mark.parametrize('member', ['../escaped.txt', '/tmp/absolute.txt', 'sub/../../escaped.txt'])
17
+ def test_extract_refuses_escaping_member(tmp_path, member):
18
+ target = tmp_path/'target'
19
+ target.mkdir()
20
+ with pytest.raises(Exception, match='unsafe path'):
21
+ fake_rzf(b'x').extract(member, path=target)
22
+ assert list(tmp_path.rglob('*')) == [target]
23
+
24
+
25
+ def test_extract_writes_inside_target(tmp_path):
26
+ fake_rzf(b'hello').extract('sub/ok.txt', path=tmp_path)
27
+ assert (tmp_path/'sub'/'ok.txt').read_bytes() == b'hello'
28
+
29
+
30
+ def test_cli_full_filepaths_refuses_escaping_member(tmp_path, monkeypatch):
31
+ monkeypatch.chdir(tmp_path)
32
+ monkeypatch.setattr(unzip_http, 'extract_one', lambda outfile, rzf, f, ofname: outfile.write(b'x'))
33
+ f = SimpleNamespace(filename='../escaped.txt', compress_size=1)
34
+ args = SimpleNamespace(files=['*'], stdout=False, full_filepaths=True)
35
+ with pytest.raises(Exception, match='unsafe path'):
36
+ unzip_http.download_file(f, None, args)
37
+ assert not (tmp_path.parent/'escaped.txt').exists()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unzip-http
3
- Version: 0.7
3
+ Version: 0.9
4
4
  Summary: extract files from .zip files over http without downloading entire archive
5
5
  Author: Saul Pwanson
6
6
  Project-URL: Homepage, https://github.com/saulpw/unzip-http
@@ -3,6 +3,7 @@ MANIFEST.in
3
3
  README.md
4
4
  pyproject.toml
5
5
  unzip_http.py
6
+ tests/test_unzip_http.py
6
7
  unzip_http.egg-info/PKG-INFO
7
8
  unzip_http.egg-info/SOURCES.txt
8
9
  unzip_http.egg-info/dependency_links.txt
@@ -56,7 +56,7 @@ import pathlib
56
56
  import urllib.parse
57
57
 
58
58
 
59
- __version__ = '0.6'
59
+ __version__ = '0.9'
60
60
 
61
61
 
62
62
  def error(s):
@@ -117,6 +117,14 @@ class RemoteZipInfo:
117
117
  i += fieldsz
118
118
 
119
119
 
120
+ def safe_extract_path(base, member):
121
+ base = pathlib.Path(base).resolve()
122
+ target = (base / member).resolve()
123
+ if base != target and base not in target.parents:
124
+ raise Exception(f'refusing unsafe path in zip member: {member!r}')
125
+ return target
126
+
127
+
120
128
  class RemoteZipFile:
121
129
  fmt_eocd = '<IHHHHIIH' # end of central directory
122
130
  fmt_eocd64 = '<IQHHIIQQQQ' # end of central directory ZIP64
@@ -129,6 +137,11 @@ class RemoteZipFile:
129
137
  import urllib3
130
138
  self.url = url
131
139
  self.http = urllib3.PoolManager()
140
+ self.default_headers = {
141
+ 'User-Agent': f'unzip-http/{__version__}',
142
+ 'Accept': '*/*',
143
+ 'Connection': 'keep-alive',
144
+ }
132
145
  self.zip_size = 0
133
146
 
134
147
  def __enter__(self):
@@ -150,7 +163,7 @@ class RemoteZipFile:
150
163
  return list(r.filename for r in self.infoiter())
151
164
 
152
165
  def infoiter(self):
153
- resp = self.http.request('HEAD', self.url)
166
+ resp = self._request('HEAD')
154
167
  if not (200 <= resp.status <= 299):
155
168
  error(f'cannot open URL: HTTP status {resp.status}')
156
169
 
@@ -223,10 +236,10 @@ class RemoteZipFile:
223
236
 
224
237
  path = path or pathlib.Path('.')
225
238
 
226
- outpath = path/member
239
+ outpath = safe_extract_path(path, member)
227
240
  os.makedirs(outpath.parent, exist_ok=True)
228
241
  with self.open(member) as fpin:
229
- with open(path/member, mode='wb') as fpout:
242
+ with open(outpath, mode='wb') as fpout:
230
243
  while True:
231
244
  r = fpin.read(65536)
232
245
  if not r:
@@ -237,8 +250,14 @@ class RemoteZipFile:
237
250
  for fn in members or self.namelist():
238
251
  self.extract(fn, path, pwd=pwd)
239
252
 
253
+ def _request(self, method, headers=None, preload_content=True):
254
+ h = dict(self.default_headers)
255
+ if headers:
256
+ h.update(headers)
257
+ return self.http.request(method, self.url, headers=h, preload_content=preload_content)
258
+
240
259
  def get_range(self, start, n):
241
- return self.http.request('GET', self.url, headers={'Range': f'bytes={start}-{start+n-1}'}, preload_content=False)
260
+ return self._request('GET', headers={'Range': f'bytes={start}-{start+n-1}'}, preload_content=False)
242
261
 
243
262
  def matching_files(self, *globs):
244
263
  for f in self.files.values():
@@ -354,6 +373,7 @@ def download_file(f, rzf, args):
354
373
  else:
355
374
  path = pathlib.Path(f.filename)
356
375
  if args.full_filepaths:
376
+ path = safe_extract_path('.', path)
357
377
  path.parent.mkdir(parents=True, exist_ok=True)
358
378
  else:
359
379
  path = path.name
File without changes
File without changes
File without changes
File without changes