Skip to content

Commit 4050cb0

Browse files
committed
Refactor how we extract tarballs #44
* Introduce a new ExtractEvent that extract_tar() returns and contains error and warning messages. * Extract symlinks correctly. * do not try to strip leading slash in paths * do not try to treat broken links specially when extracting links Replacing: #42 Referenced-by: #44 Reported-by: Jono Yang <jyang@nexb.com> Signed-off-by: Philippe Ombredanne <pombredanne@nexb.com>
1 parent bbc1525 commit 4050cb0

15 files changed

Lines changed: 364 additions & 69 deletions

CHANGELOG.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
Changelog
22
=========
33

4+
v32.0.0
5+
--------
6+
7+
This is a minor release with bug fixes and an output change.
8+
9+
- "utils.extract_tar" function now behaves correctly with links and return
10+
ExtractEvent to track extraction errors and warnings.
11+
This replaces the simpler list of error messages.
12+
- in all places where extract is callable (Image, Layer) there is a new
13+
skip_symlinks argument defaulting to True. If True, we skip symlinks and links.
14+
15+
416
v31.1.0
517
--------
618

src/container_inspector/image.py

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -356,14 +356,22 @@ def bottom_layer(self):
356356
"""
357357
return self.layers[0]
358358

359-
def extract_layers(self, extracted_location):
359+
def extract_layers(self, extracted_location, skip_symlinks=True):
360360
"""
361361
Extract all layer archives to the `extracted_location` directory.
362362
Each layer is extracted to its own directory named after its `layer_id`.
363+
Skip symlinks and links if ``skip_symlinks`` is True.
364+
Return a list of ExtractEvent.
363365
"""
366+
all_events = []
364367
for layer in self.layers:
365368
exloc = os.path.join(extracted_location, layer.layer_id)
366-
layer.extract(extracted_location=exloc)
369+
events = layer.extract(
370+
extracted_location=exloc,
371+
skip_symlinks=skip_symlinks,
372+
)
373+
all_events.extend(events)
374+
return events
367375

368376
def get_layers_resources(self, with_dir=False):
369377
"""
@@ -453,9 +461,11 @@ def get_installed_packages(self, packages_getter):
453461
def extract(archive_location, extracted_location, skip_symlinks=False):
454462
"""
455463
Extract the image archive tarball at ``archive_location`` to
456-
``extracted_location``. Skip symlinks and links if ``skip_symlinks`` is True.
464+
``extracted_location``.
465+
Skip symlinks and links if ``skip_symlinks`` is True.
466+
Return a list of ExtractEvent.
457467
"""
458-
utils.extract_tar(
468+
return utils.extract_tar(
459469
location=archive_location,
460470
target_dir=extracted_location,
461471
skip_symlinks=skip_symlinks,
@@ -466,24 +476,29 @@ def get_images_from_tarball(
466476
archive_location,
467477
extracted_location,
468478
verify=True,
479+
skip_symlinks=False,
469480
):
470481
"""
471-
Return a list of Images found in the tarball at `archive_location` that
472-
will be extracted to `extracted_location`. The tarball must be in the
482+
Return a list of Images found in the tarball at ``archive_location`` that
483+
will be extracted to ``extracted_location``. The tarball must be in the
473484
format of a "docker save" command tarball.
474485
475-
If `verify` is True, perform extra checks on the config data and layers
486+
If ``verify`` is True, perform extra checks on the config data and layers
476487
checksums.
488+
Skip symlinks and links if ``skip_symlinks`` is True.
489+
Ignore the extract events from extraction.
477490
"""
478491
if TRACE:
479492
logger.debug(
480-
f'get_images_from_tarball: {archive_location} , '
493+
f'get_images_from_tarball: {archive_location} '
481494
f'extracting to: {extracted_location}'
482495
)
483496

484-
Image.extract(
497+
# TODO: do not ignore extract events
498+
_events = Image.extract(
485499
archive_location=archive_location,
486500
extracted_location=extracted_location,
501+
skip_symlinks=skip_symlinks,
487502
)
488503

489504
return Image.get_images_from_dir(
@@ -1071,13 +1086,15 @@ def __attrs_post_init__(self, *args, **kwargs):
10711086
if not self.size:
10721087
self.size = os.path.getsize(self.archive_location)
10731088

1074-
def extract(self, extracted_location, skip_symlinks=True):
1089+
def extract(self, extracted_location, skip_symlinks=False):
10751090
"""
10761091
Extract this layer archive in the `extracted_location` directory and set
10771092
this Layer ``extracted_location`` attribute to ``extracted_location``.
1093+
Skip symlinks and links if ``skip_symlinks`` is True.
1094+
Return a list of ExtractEvent.
10781095
"""
10791096
self.extracted_location = extracted_location
1080-
utils.extract_tar(
1097+
return utils.extract_tar(
10811098
location=self.archive_location,
10821099
target_dir=extracted_location,
10831100
skip_symlinks=skip_symlinks,

src/container_inspector/rootfs.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ class InconsistentLayersError(Exception):
3131
pass
3232

3333

34-
def rebuild_rootfs(img, target_dir):
34+
def rebuild_rootfs(img, target_dir, skip_symlinks=True):
3535
"""
3636
Extract and merge or "squash" all layers of the `image` Image in a single
3737
rootfs in `target_dir`. Extraction is done in sequence from the bottom (root
3838
or initial) layer to the top (or latest) layer and the "whiteouts"
3939
unionfs/overlayfs procedure is applied at each step as per the OCI spec:
4040
https://github.com/opencontainers/image-spec/blob/master/layer.md#whiteouts
4141
42+
Skip symlinks and links if ``skip_symlinks`` is True.
43+
4244
Return a list of deleted "whiteout" files.
4345
Raise an Exception on errrors.
4446
@@ -73,8 +75,15 @@ def rebuild_rootfs(img, target_dir):
7375
# 1. extract a layer to temp.
7476
# Note that we are not preserving any special file and any file permission
7577
extracted_loc = tempfile.mkdtemp('container_inspector-docker')
76-
layer.extract(extracted_location=extracted_loc)
77-
if TRACE: logger.debug(f' Extracted layer to: {extracted_loc}')
78+
# TODO: do not ignore extract events
79+
_extract_events = layer.extract(
80+
extracted_location=extracted_loc,
81+
skip_symlinks=skip_symlinks,
82+
)
83+
if TRACE:
84+
logger.debug(f' Extracted layer to: {extracted_loc} with skip_symlinks: {skip_symlinks}')
85+
for ev in _extract_events:
86+
logger.debug(f' {ev}')
7887

7988
# 2. find whiteouts in that layer.
8089
whiteouts = list(find_whiteouts(extracted_loc))

src/container_inspector/utils.py

Lines changed: 64 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,13 @@
1010
import logging
1111
import hashlib
1212
import os
13+
import traceback
14+
from typing import NamedTuple
1315

1416
from commoncode import fileutils
1517

1618
TRACE = False
19+
1720
logger = logging.getLogger(__name__)
1821
if TRACE:
1922
import sys
@@ -80,67 +83,88 @@ def get_labels(config, container_config):
8083
return dict(sorted(labels.items()))
8184

8285

83-
def extract_tar(location, target_dir, skip_symlinks=True):
86+
class ExtractEvent(NamedTuple):
87+
"""
88+
Represent an extraction event of interest. These are returned when running
89+
extract_tar
90+
"""
91+
92+
INFO = "info"
93+
WARNING = "warning"
94+
ERROR = "error"
95+
# type of event: one of error, warning or info
96+
type: str
97+
# source path in the archive
98+
source: str
99+
# even message
100+
message: str
101+
102+
103+
def extract_tar(location, target_dir, skip_symlinks=True, trace=TRACE):
84104
"""
85-
Extract a tar archive at `location` in the `target_dir` directory.
86-
Ignore special device files. Skip symlinks and hardlinks if skip_symlinks is True.
105+
Extract a tar archive at ``location`` in the ``target_dir`` directory and
106+
return a list of ExtractEvent possibly empty.
107+
Skip symlinks and hardlinks if skip_symlinks is True.
108+
109+
Ignore special device files.
87110
Do not preserve the permissions and owners.
88-
Raise exceptions on possible problematic relative paths.
89-
Issue a warning if skip_symlinks is True and links target are missing.
90111
"""
91112
import tarfile
92-
tarfile.TarInfo
93-
if TRACE: logger.debug(f'_extract_tar: {location} to {target_dir} skip_symlinks: {skip_symlinks}')
113+
if trace:
114+
logger.debug(f'_extract_tar: {location} to {target_dir} skip_symlinks: {skip_symlinks}')
94115

95116
fileutils.create_dir(target_dir)
96117

118+
events = []
97119
with tarfile.open(location) as tarball:
98-
# never extract character device, block and fifo files:
99-
# we extract dirs, files and links only
100-
error_messages = []
120+
101121
for tarinfo in tarball:
102-
if TRACE: logger.debug(f'_extract_tar: {tarinfo}')
122+
if trace:
123+
logger.debug(f'extract_tar: {location!r}: {tarinfo}')
103124

104125
if tarinfo.isdev() or tarinfo.ischr() or tarinfo.isblk() or tarinfo.isfifo() or tarinfo.sparse:
105-
msg = f'_extract_tar: skipping unsupported {tarinfo} file type: block, chr, dev or sparse file'
106-
error_messages.append(msg)
107-
if TRACE:
108-
logger.debug(msg)
126+
msg = f'skipping unsupported {tarinfo.name} file type: block, chr, dev or sparse file'
127+
events.append(ExtractEvent(type=ExtractEvent.INFO, source=tarinfo.name, message=msg))
128+
if trace:
129+
logger.debug(f'extract_tar: {msg}')
109130
continue
110131

111132
if '..' in tarinfo.name:
112-
msg = f'_extract_tar: skipping unsupported {tarinfo} with relative path'
113-
error_messages.append(msg)
114-
if TRACE:
115-
logger.debug(msg)
133+
msg = f'{location}: skipping unsupported {tarinfo.name} with relative path.'
134+
events.append(ExtractEvent(type=ExtractEvent.WARNING, source=tarinfo.name, message=msg))
135+
if trace:
136+
logger.debug(f'extract_tar: {msg}')
137+
continue
138+
139+
if skip_symlinks and (tarinfo.islnk() or tarinfo.issym()):
140+
msg = f'{location}: skipping link with skip_symlinks: {skip_symlinks}: {tarinfo.name} -> {tarinfo.linkname}'
141+
if trace:
142+
logger.debug(f'extract_tar: {msg}')
116143
continue
117144

118-
if tarinfo.islnk() or tarinfo.issym():
119-
try:
120-
target = tarball._find_link_target(tarinfo)
121-
if not target:
122-
msg = f'_extract_tar: skipping link with missing target: {tarinfo}'
123-
error_messages.append(msg)
124-
if TRACE:
125-
logger.debug(msg)
126-
continue
127-
128-
except Exception:
129-
import traceback
130-
msg = f'_extract_tar: skipping link with missing target: {tarinfo}: {traceback.format_exc()}'
131-
error_messages.append(msg)
132-
if TRACE:
133-
logger.debug(msg)
134-
continue
145+
if tarinfo.name.startswith('/'):
146+
msg = f'{location}: absolute path name: {tarinfo.name} transformed in relative path.'
147+
events.append(ExtractEvent(type=ExtractEvent.WARNING, source=tarinfo.name, message=msg))
148+
tarinfo.name = tarinfo.name.lstrip('/')
149+
if trace:
150+
logger.debug(f'extract_tar: {msg}')
135151

152+
# finally extract proper
136153
tarinfo.mode = 0o755
137-
tarinfo.name = tarinfo.name.lstrip('/')
138-
tarball.extract(member=tarinfo, path=target_dir, set_attrs=False,)
139-
return error_messages
154+
155+
try:
156+
tarball.extract(member=tarinfo, path=target_dir, set_attrs=False,)
157+
except Exception:
158+
msg = f'{location}: failed to extract: {tarinfo.name}: {traceback.format_exc()}'
159+
events.append(ExtractEvent(type=ExtractEvent.ERROR, source=tarinfo.name, message=msg))
160+
if trace:
161+
logger.debug(f'extract_tar: {msg}')
162+
163+
return events
140164

141165

142166
def extract_tar_with_symlinks(location, target_dir):
143-
return extract_tar(location, target_dir, skip_symlinks=False)
167+
return extract_tar(location=location, target_dir=target_dir, skip_symlinks=False)
144168

145169

146170
def lower_keys(mapping):
11.5 KB
Binary file not shown.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
[]
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[
2+
"/lib",
3+
"/lib/libcrypto.so.1.0.0",
4+
"/lib/libssl.so.1.0.0",
5+
"/usr",
6+
"/usr/lib",
7+
"/usr/lib/icu",
8+
"/usr/lib/icu/60.2",
9+
"/usr/lib/icu/60.2/Makefile.inc",
10+
"/usr/lib/icu/60.2/pkgdata.inc",
11+
"/usr/lib/libffi.so.6.0.4"
12+
]
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
"/lib",
3+
"/lib/libcrypto.so.1.0.0",
4+
"/lib/libssl.so.1.0.0",
5+
"/usr",
6+
"/usr/lib",
7+
"/usr/lib/icu",
8+
"/usr/lib/icu/60.2",
9+
"/usr/lib/icu/60.2/Makefile.inc",
10+
"/usr/lib/icu/60.2/pkgdata.inc",
11+
"/usr/lib/icu/Makefile.inc",
12+
"/usr/lib/icu/current",
13+
"/usr/lib/icu/current/Makefile.inc",
14+
"/usr/lib/icu/current/pkgdata.inc",
15+
"/usr/lib/icu/pkgdata.inc",
16+
"/usr/lib/libcrypto.so.1.0.0",
17+
"/usr/lib/libffi.so.6",
18+
"/usr/lib/libffi.so.6.0.4",
19+
"/usr/lib/libssl.so.1.0.0"
20+
]
9.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)