Repository: conan-io/conan

Issue:
[bug] Uncompressing source package reaches 200%
### Describe the bug

Hello!

When trying to install and build from Qt project from source, the Conan client shows more than 100% when uncompressing its .tar.xz file:

```
qt/5.15.14: Calling source() in /Users/uilian/.conan2/p/qt996e95125f50b/s
qt/5.15.14: Downloading 661.1MB fdd3a4f197d2c800ee0085c721f4bef60951cbda9e9c46e525d1412f74264ed7
qt/5.15.14: Downloaded 172.3MB 26% fdd3a4f197d2c800ee0085c721f4bef60951cbda9e9c46e525d1412f74264ed7
qt/5.15.14: Downloaded 359.4MB 54% fdd3a4f197d2c800ee0085c721f4bef60951cbda9e9c46e525d1412f74264ed7
qt/5.15.14: Downloaded 553.8MB 83% fdd3a4f197d2c800ee0085c721f4bef60951cbda9e9c46e525d1412f74264ed7
qt/5.15.14: Unzipping qt-everywhere-opensource-src-5.15.14.tar.xz to qt5
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 12%
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 17%
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 20%
...
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 99%
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 101%
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 112%
...
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 199%
Uncompressing qt-everywhere-opensource-src-5.15.14.tar.xz: 199%
qt/5.15.14: Apply patch (file): patches/aa2a39dea5.diff
qt/5.15.14: Apply patch (file): patches/c72097e.diff
qt/5.15.14: Apply patch (file): patches/fix-macdeployqt.diff
qt/5.15.14: Apply patch (file): patches/0001-Find-fontconfig-using-pkg-config.patch
qt/5.15.14: Apply patch (file): Fix qtwebengine build issues for vs 2022 (backport QTBUG-117073)
qt/5.15.14: Apply patch (file): patches/android-openssl.diff
qt/5.15.14: Apply patch (portability): Fix qmake build with apple-clang>=15
qt/5.15.14: Apply patch (portability): Fix usage of memory_resource with apple-clang>=15 and deployment target of macOS < 14
qt/5.15.14: Apply patch (file): qtwebengine don't use rpath-link on macOS
qt/5.15.14: Apply patch (file): qtlocation missing utility header
qt/5.15.14: Apply patch (file): qtwebengine fix missing headers
qt/5.15.14: Apply patch (file): macos fix chromium build in newer macos SDKs
qt/5.15.14: Apply patch (portability): Fix build error with binutils >= 2.41

-------- Installing package qt/5.15.14 (24 of 25) --------
```

#### Environment

- OS: Mac OSX
- Arch: ARMv8
- Conan version: 2.12.1
- Conan profile:

```
[settings]
arch=armv8
build_type=Release
compiler=apple-clang
compiler.cppstd=17
compiler.libcxx=libc++
compiler.version=16
os=Macos
```

/cc @AbrilRBS 

### How to reproduce it

I was able to produce that error when building Qt6/5, it's +670MB in the source package, so it may take a while.

`conan install -r conancenter --requires=qt/6.7.3 --build=missing`

Here is my full build log: [qt-uncompress-bug.log](https://github.com/user-attachments/files/18667803/qt-uncompress-bug.log)


Discussion:
The main issue here seems to be TarFile seeking to the begining of the file after a first pass
Here is the full log with seek logs:
```
conan source recipes/boost/all --version 1.86.0                                                              linter 3.12.3  09:59:18
conanfile.py (boost/1.86.0): Calling source() in /Users/perseo/sources/conan-center-index/recipes/boost/all/src
conanfile.py (boost/1.86.0): Source ['https://archives.boost.io/release/1.86.0/source/boost_1_86_0.tar.bz2', 'https://sourceforge.net/projects/boost/files/boost/1.86.0/boost_1_86_0.tar.bz2'] retrieved from local download cache
conanfile.py (boost/1.86.0): Unzipping boost_1_86_0.tar.bz2 to /Users/perseo/sources/conan-center-index/recipes/boost/all/src
Seeking to 0
Uncompressing boost_1_86_0.tar.bz2: 5%
Uncompressing boost_1_86_0.tar.bz2: 13%
Uncompressing boost_1_86_0.tar.bz2: 25%
Uncompressing boost_1_86_0.tar.bz2: 37%
Uncompressing boost_1_86_0.tar.bz2: 50%
Uncompressing boost_1_86_0.tar.bz2: 61%
Uncompressing boost_1_86_0.tar.bz2: 71%
Uncompressing boost_1_86_0.tar.bz2: 80%
Uncompressing boost_1_86_0.tar.bz2: 91%
Uncompressing boost_1_86_0.tar.bz2: 96%
Seeking to 0
Uncompressing boost_1_86_0.tar.bz2: 100%
Uncompressing boost_1_86_0.tar.bz2: 103%
Uncompressing boost_1_86_0.tar.bz2: 105%
Uncompressing boost_1_86_0.tar.bz2: 107%
Uncompressing boost_1_86_0.tar.bz2: 109%
Uncompressing boost_1_86_0.tar.bz2: 115%
Uncompressing boost_1_86_0.tar.bz2: 121%
Uncompressing boost_1_86_0.tar.bz2: 124%
Uncompressing boost_1_86_0.tar.bz2: 129%
Uncompressing boost_1_86_0.tar.bz2: 133%
Uncompressing boost_1_86_0.tar.bz2: 144%
Uncompressing boost_1_86_0.tar.bz2: 151%
Uncompressing boost_1_86_0.tar.bz2: 156%
Uncompressing boost_1_86_0.tar.bz2: 161%
Uncompressing boost_1_86_0.tar.bz2: 165%
Uncompressing boost_1_86_0.tar.bz2: 170%
Uncompressing boost_1_86_0.tar.bz2: 172%
Uncompressing boost_1_86_0.tar.bz2: 176%
Uncompressing boost_1_86_0.tar.bz2: 179%
Uncompressing boost_1_86_0.tar.bz2: 185%
Uncompressing boost_1_86_0.tar.bz2: 191%
Uncompressing boost_1_86_0.tar.bz2: 194%
Uncompressing boost_1_86_0.tar.bz2: 195%
Uncompressing boost_1_86_0.tar.bz2: 198%
conanfile.py (boost/1.86.0): Apply patch (conan): Optional flag to specify iconv from either libc of libiconv
```

A quick workaround could be to interpret each block read as half!
But I've to see if this behaviour is only for tarfiles or if it also affects zips

**UPDATE**
Making some isolated tests of `unzip` method, neither zip nor tar should read twice the file.
I do not know why it is behaving conan like this only from conan source command



Patch:
diff --git a/conan/tools/files/files.py b/conan/tools/files/files.py
index 433c30e2de9..66a2a43a999 100644
--- a/conan/tools/files/files.py
+++ b/conan/tools/files/files.py
@@ -360,33 +360,37 @@ def untargz(filename, destination=".", pattern=None, strip_root=False, extract_f
         f = getattr(tarfile, f"{extract_filter}_filter", None) if extract_filter else None
         tarredgzippedFile.extraction_filter = f or (lambda member_, _: member_)
         if not pattern and not strip_root:
+            # Simple case: extract everything
             tarredgzippedFile.extractall(destination)
         else:
-            members = tarredgzippedFile.getmembers()
-
-            if pattern:
-                members = list(filter(lambda m: fnmatch(m.name, pattern),
-                                      tarredgzippedFile.getmembers()))
-            if strip_root:
-                names = [member.name.replace("\\", "/") for member in members]
-                common_folder = os.path.commonprefix(names).split("/", 1)[0]
-                if not common_folder and len(names) > 1:
-                    raise ConanException("The tgz file contains more than 1 folder in the root")
-                if len(names) == 1 and len(names[0].split("/", 1)) == 1:
-                    raise ConanException("The tgz file contains a file in the root")
-                # Remove the directory entry if present
-                members = [m for m in members if m.name != common_folder]
-                for member in members:
+            # Read members one by one and process them
+            common_folder = None
+            for member in tarredgzippedFile:
+                if pattern and not fnmatch(member.name, pattern):
+                    continue  # Skip files that don’t match the pattern
+
+                if strip_root:
                     name = member.name.replace("\\", "/")
-                    member.name = name.split("/", 1)[1]
+                    if not common_folder:
+                        splits = name.split("/", 1)
+                        # First case for a plain folder in the root
+                        if member.isdir() or len(splits) > 1:
+                            common_folder = splits[0]  # Find the root folder
+                        else:
+                            raise ConanException("Can't untar a tgz containing files in the root with strip_root enabled")
+                    if not name.startswith(common_folder):
+                        raise ConanException("The tgz file contains more than 1 folder in the root")
+
+                    # Adjust the member's name for extraction
+                    member.name = name[len(common_folder) + 1:]
                     member.path = member.name
-                    if member.linkpath.startswith(common_folder):
+                    if member.linkpath and member.linkpath.startswith(common_folder):
                         # https://github.com/conan-io/conan/issues/11065
-                        linkpath = member.linkpath.replace("\\", "/")
-                        member.linkpath = linkpath.split("/", 1)[1]
+                        member.linkpath = member.linkpath[len(common_folder) + 1:].replace("\\", "/")
                         member.linkname = member.linkpath
-            tarredgzippedFile.extractall(destination, members=members)
-
+                # Extract file one by one, avoiding `extractall()` with members parameter:
+                # This will avoid a first whole file read resulting in a performant improvement around 25%
+                tarredgzippedFile.extract(member, path=destination)
 
 def check_sha1(conanfile, file_path, signature):
     """
diff --git a/conans/client/rest/file_uploader.py b/conans/client/rest/file_uploader.py
index 0b831d2da51..5005a5c14c3 100644
--- a/conans/client/rest/file_uploader.py
+++ b/conans/client/rest/file_uploader.py
@@ -97,7 +97,7 @@ def _upload_file(self, url, abs_path, headers, auth, ref):
 
 
 class FileProgress(io.FileIO):
-    def __init__(self, path: str, msg: str = "Uploading", interval: float = 1, *args, **kwargs):
+    def __init__(self, path: str, msg: str = "Uploading", interval: float = 10, *args, **kwargs):
         super().__init__(path, *args, **kwargs)
         self._size = os.path.getsize(path)
         self._filename = os.path.basename(path)


Test patch:
diff --git a/test/unittests/tools/files/test_zipping.py b/test/unittests/tools/files/test_zipping.py
index 0c41a494d33..655271ad5af 100644
--- a/test/unittests/tools/files/test_zipping.py
+++ b/test/unittests/tools/files/test_zipping.py
@@ -137,7 +137,7 @@ def test_untargz_with_strip_root_fails():
     dest_dir = temp_folder()
     with pytest.raises(ConanException) as error:
         unzip(conanfile, archive, dest_dir, strip_root=True)
-    assert "The tgz file contains more than 1 folder in the root" in str(error.value)
+    assert "Can't untar a tgz containing files in the root with strip_root enabled" in str(error.value)
 
 
 def test_untargz_with_strip_root_and_pattern():
diff --git a/test/unittests/util/files/strip_root_extract_test.py b/test/unittests/util/files/strip_root_extract_test.py
index 3f8746b9925..d65bcb36788 100644
--- a/test/unittests/util/files/strip_root_extract_test.py
+++ b/test/unittests/util/files/strip_root_extract_test.py
@@ -255,5 +255,20 @@ def test_invalid_flat_single_file(self):
 
         # Extract without the subfolder
         extract_folder = temp_folder()
-        with self.assertRaisesRegex(ConanException, "The tgz file contains a file in the root"):
+        with self.assertRaisesRegex(ConanException, "Can't untar a tgz containing files in the root with strip_root enabled"):
+            unzip(ConanFileMock(), tgz_file, destination=extract_folder, strip_root=True)
+
+    def test_invalid_flat_multiple_file(self):
+        tmp_folder = temp_folder()
+        with chdir(tmp_folder):
+            save("file1", "contentsfile1")
+            save("file2", "contentsfile2")
+
+        zip_folder = temp_folder()
+        tgz_file = os.path.join(zip_folder, "file.tar.gz")
+        self._compress_folder(tmp_folder, tgz_file)
+
+        # Extract without the subfolder
+        extract_folder = temp_folder()
+        with self.assertRaisesRegex(ConanException, "Can't untar a tgz containing files in the root with strip_root enabled"):
             unzip(ConanFileMock(), tgz_file, destination=extract_folder, strip_root=True)