From 3dcf5e1b7fff19fa49236812307381fcc71f79d7 Mon Sep 17 00:00:00 2001 From: Shashank Tomar Date: Sun, 8 Nov 2026 13:29:18 +0530 Subject: [PATCH] Decode archive paths as UTF-8 first #77 Archive entry paths are bytes. The charset detector used by commoncode.text.as_unicode can mistake plain ASCII path segments such as "+dfsg/LICENSES/Qt-" for UTF-7. This corrupts the path and extracts files in the wrong directory, as reported for the qt6-webengine archive. Decode paths as UTF-8 first and only fall back to the charset detector for bytes that are not valid UTF-8. Add a regression test using the path from the report. Signed-off-by: Shashank Tomar --- src/extractcode/libarchive2.py | 22 +++++++++++++++++- tests/test_libarchive2.py | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/extractcode/libarchive2.py b/src/extractcode/libarchive2.py index 024d116..25d0b6c 100644 --- a/src/extractcode/libarchive2.py +++ b/src/extractcode/libarchive2.py @@ -328,6 +328,26 @@ def __iter__(self): return self.iter() +def as_unicode_path(path): + """ + Return a unicode string decoded from a path bytes `path` of an archive + entry. + + Archive entry paths are bytes. Decode them as UTF-8 first: this is the + encoding used by most archives and decoding is deterministic. A charset + detector can mistake plain ASCII path segments such as ``+dfsg/`` for + UTF-7 and corrupt directory names, extracting files in the wrong + directory. Fall back to the historical charset detection for paths that + are not valid UTF-8. + """ + if not isinstance(path, bytes): + return path + try: + return path.decode("utf-8") + except UnicodeDecodeError: + return text.as_unicode(path) + + @attr.attrs class Entry(object): """ @@ -416,7 +436,7 @@ def get_path(self, func, func_w): if not path: path = func_w(self.entry_struct) if not isinstance(path, str): - path = text.as_unicode(path) + path = as_unicode_path(path) return path diff --git a/tests/test_libarchive2.py b/tests/test_libarchive2.py index 78315e9..056d64a 100644 --- a/tests/test_libarchive2.py +++ b/tests/test_libarchive2.py @@ -7,7 +7,9 @@ # See https://aboutcode.org for more information about nexB OSS projects. # +import io import os +import tarfile from commoncode import fileutils @@ -21,6 +23,46 @@ """ +def test_as_unicode_path_does_not_decode_plain_ascii_as_utf7(): + from extractcode.libarchive2 import as_unicode_path + + path = b"qt6-webengine-6.8.2+dfsg/LICENSES/Qt-GPL-exception-1.0.txt" + assert as_unicode_path(path) == ("qt6-webengine-6.8.2+dfsg/LICENSES/Qt-GPL-exception-1.0.txt") + + +def test_as_unicode_path_decodes_utf8(): + from extractcode.libarchive2 import as_unicode_path + + path = "some/path/äöü.txt".encode("utf-8") + assert as_unicode_path(path) == "some/path/äöü.txt" + + +def test_as_unicode_path_handles_missing_paths(): + from extractcode.libarchive2 import as_unicode_path + + assert as_unicode_path(None) is None + assert as_unicode_path(b"") == "" + + +def test_extract_tar_keeps_directories_with_a_plus_sign(tmp_path): + from extractcode.libarchive2 import extract + + archive = str(tmp_path / "test.tar") + name = "qt6-webengine-6.8.2+dfsg/LICENSES/Qt-GPL-exception-1.0.txt" + with tarfile.open(archive, "w") as tar: + info = tarfile.TarInfo(name) + info.size = 5 + tar.addfile(info, io.BytesIO(b"hello")) + + target_dir = tmp_path / "target" + target_dir.mkdir() + + result = list(extract(archive, str(target_dir))) + + assert result == [] + assert (target_dir / name).exists() + + class TestExtractorTest(BaseArchiveTestCase): def test_libarchive_extract_can_extract_to_relative_paths(self): # The setup is a tad complex because we want to have a relative dir