Skip to content

Commit 6c53b3b

Browse files
committed
Implement optional caching of remote files
1 parent da5f3ca commit 6c53b3b

File tree

4 files changed

+89
-18
lines changed

4 files changed

+89
-18
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ the file name.
9090
#### Extract
9191

9292
```
93-
usage: reolinkfw extract [-h] [-d DEST] [-f] file_or_url
93+
usage: reolinkfw extract [-h] [--no-cache] [-d DEST] [-f] file_or_url
9494
9595
Extract the file system from a Reolink firmware
9696
@@ -99,6 +99,7 @@ positional arguments:
9999
100100
optional arguments:
101101
-h, --help show this help message and exit
102+
--no-cache don't use cache for remote files (URLs)
102103
-d DEST, --dest DEST destination directory. Default: current directory
103104
-f, --force overwrite existing files. Does not apply to UBIFS. Default: False
104105
```

reolinkfw/__init__.py

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@
2121
from ubireader.ubifs.defines import UBIFS_NODE_MAGIC as UBIFS_MAGIC
2222
from ubireader.ubifs.output import _process_reg_file
2323

24-
from reolinkfw.util import DummyLEB, get_fs_from_ubi, sha256_pak
24+
from reolinkfw.util import (
25+
DummyLEB,
26+
get_cache_file,
27+
get_fs_from_ubi,
28+
has_cache,
29+
make_cache_file,
30+
sha256_pak
31+
)
2532

2633
__version__ = "1.1.0"
2734

@@ -170,7 +177,7 @@ async def direct_download_url(url):
170177
return url
171178

172179

173-
async def get_paks(file_or_url) -> list[tuple[Optional[str], PAK]]:
180+
async def get_paks(file_or_url, use_cache: bool = True) -> list[tuple[Optional[str], PAK]]:
174181
"""Return PAK files read from an on-disk file or a URL.
175182
176183
The file or resource may be a ZIP or a PAK. On success return a
@@ -180,12 +187,16 @@ async def get_paks(file_or_url) -> list[tuple[Optional[str], PAK]]:
180187
It is the caller's responsibility to close the PAK files.
181188
"""
182189
if is_url(file_or_url):
190+
if use_cache and has_cache(file_or_url):
191+
return await get_paks(get_cache_file(file_or_url))
183192
file_or_url = await direct_download_url(file_or_url)
184193
zip_or_pak_bytes = await download(file_or_url)
185194
if isinstance(zip_or_pak_bytes, int):
186195
raise Exception(f"HTTP error {zip_or_pak_bytes}")
187-
elif is_pak_file(zip_or_pak_bytes):
188-
pakname = dict(parse_qsl(urlparse(file_or_url).query)).get("name")
196+
pakname = dict(parse_qsl(urlparse(file_or_url).query)).get("name")
197+
if use_cache:
198+
make_cache_file(file_or_url, zip_or_pak_bytes, pakname)
199+
if is_pak_file(zip_or_pak_bytes):
189200
return [(pakname, PAK.from_bytes(zip_or_pak_bytes))]
190201
else:
191202
zipfile = io.BytesIO(zip_or_pak_bytes)
@@ -203,13 +214,13 @@ async def get_paks(file_or_url) -> list[tuple[Optional[str], PAK]]:
203214
raise Exception("Not a URL or file")
204215

205216

206-
async def get_info(file_or_url):
217+
async def get_info(file_or_url, use_cache: bool = True):
207218
"""Retrieve firmware info from an on-disk file or a URL.
208219
209220
The file or resource may be a ZIP or a PAK.
210221
"""
211222
try:
212-
paks = await get_paks(file_or_url)
223+
paks = await get_paks(file_or_url, use_cache)
213224
except Exception as e:
214225
return [{"file": file_or_url, "error": str(e)}]
215226
if not paks:

reolinkfw/__main__.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@
1111
from reolinkfw.util import sha256_pak
1212

1313

14-
def info(args: argparse.Namespace) -> None:
15-
info = asyncio.run(get_info(args.file_or_url))
14+
async def info(args: argparse.Namespace) -> None:
15+
info = await get_info(args.file_or_url, not args.no_cache)
1616
print(json.dumps(info, indent=args.indent, default=str))
1717

1818

1919
async def extract(args: argparse.Namespace) -> None:
20-
paks = await get_paks(args.file_or_url)
20+
paks = await get_paks(args.file_or_url, not args.no_cache)
2121
if not paks:
2222
raise Exception("No PAKs found in ZIP file")
2323
dest = Path.cwd() if args.dest is None else args.dest
@@ -32,24 +32,24 @@ def main():
3232
parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
3333
subparsers = parser.add_subparsers(required=True)
3434

35-
parser_i = subparsers.add_parser("info")
35+
pcache = argparse.ArgumentParser(add_help=False)
36+
pcache.add_argument("--no-cache", action="store_true", help="don't use cache for remote files (URLs)")
37+
38+
parser_i = subparsers.add_parser("info", parents=[pcache])
3639
parser_i.add_argument("file_or_url", help="URL or on-disk file")
3740
parser_i.add_argument("-i", "--indent", type=int, help="indent level for pretty print")
3841
parser_i.set_defaults(func=info)
3942

4043
descex = "Extract the file system from a Reolink firmware"
41-
parser_e = subparsers.add_parser("extract", help=descex.lower(), description=descex)
44+
parser_e = subparsers.add_parser("extract", parents=[pcache], help=descex.lower(), description=descex)
4245
parser_e.add_argument("file_or_url", help="URL or on-disk file")
4346
parser_e.add_argument("-d", "--dest", type=Path, help="destination directory. Default: current directory")
4447
parser_e.add_argument("-f", "--force", action="store_true", help="overwrite existing files. Does not apply to UBIFS. Default: %(default)s")
4548
parser_e.set_defaults(func=extract)
4649

4750
args = parser.parse_args()
4851
try:
49-
if asyncio.iscoroutinefunction(args.func):
50-
asyncio.run(args.func(args))
51-
else:
52-
args.func(args)
52+
asyncio.run(args.func(args))
5353
except Exception as e:
5454
sys.exit(f"error: {e}")
5555

reolinkfw/util.py

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,22 @@
22
import io
33
from contextlib import contextmanager
44
from functools import partial
5+
from os import scandir
6+
from pathlib import Path
7+
from shutil import disk_usage
8+
from tempfile import gettempdir as _gettempdir
9+
from zipfile import is_zipfile
510

6-
from pakler import PAK
11+
from pakler import PAK, is_pak_file
712
from ubireader.ubi import ubi
813
from ubireader.ubi_io import ubi_file, leb_virtual_file
914
from ubireader.utils import guess_peb_size
1015

1116
from reolinkfw.tmpfile import TempFile
1217

18+
ONEMIB = 1024**2
19+
ONEGIB = 1024**3
20+
1321

1422
class DummyLEB:
1523
"""A class that emulates ubireader's `leb_virtual_file`."""
@@ -69,6 +77,57 @@ def get_fs_from_ubi(fd, size, offset=0) -> bytes:
6977
def sha256_pak(pak: PAK) -> str:
7078
sha = hashlib.sha256()
7179
pak._fd.seek(0)
72-
for block in iter(partial(pak._fd.read, 1024**2), b''):
80+
for block in iter(partial(pak._fd.read, ONEMIB), b''):
7381
sha.update(block)
7482
return sha.hexdigest()
83+
84+
85+
def dir_size(path):
86+
size = 0
87+
try:
88+
with scandir(path) as it:
89+
for entry in it:
90+
if entry.is_dir(follow_symlinks=False):
91+
size += dir_size(entry.path)
92+
elif entry.is_file(follow_symlinks=False):
93+
size += entry.stat().st_size
94+
except OSError:
95+
pass
96+
return size
97+
98+
99+
def gettempdir() -> Path:
100+
return Path(_gettempdir()) / "reolinkfwcache"
101+
102+
103+
def get_cache_file(url: str) -> Path:
104+
file = gettempdir() / hashlib.sha256(url.encode("utf8")).hexdigest()
105+
if is_zipfile(file) or is_pak_file(file):
106+
return file
107+
try:
108+
with open(file, 'r', encoding="utf8") as f:
109+
return gettempdir() / f.read(256)
110+
except (OSError, UnicodeDecodeError):
111+
return file
112+
113+
114+
def has_cache(url: str) -> bool:
115+
return get_cache_file(url).is_file()
116+
117+
118+
def make_cache_file(url: str, filebytes, name=None) -> bool:
119+
tempdir = gettempdir()
120+
tempdir.mkdir(exist_ok=True)
121+
if disk_usage(tempdir).free < ONEGIB or dir_size(tempdir) > ONEGIB:
122+
return False
123+
sha = hashlib.sha256(url.encode("utf8")).hexdigest()
124+
name = sha if not isinstance(name, str) else name
125+
try:
126+
with open(tempdir / name, "wb") as f:
127+
f.write(filebytes)
128+
if name != sha:
129+
with open(tempdir / sha, 'w', encoding="utf8") as f:
130+
f.write(name)
131+
except OSError:
132+
return False
133+
return True

0 commit comments

Comments
 (0)