From 98c5b150d2e4b31e1c708720f6d0f194829b8cfd Mon Sep 17 00:00:00 2001 From: Soim Kim Date: Fri, 2 Oct 2026 11:31:18 +0900 Subject: [PATCH 1/4] fix(notice): decompress gz entries in hub zip Partition NOTICE.xml.gz files share one basename, so storing the gzip as-is made every zip member NOTICE.xml.gz. Hub uploads need the uncompressed text and a path-based name when those names collide. Signed-off-by: Soim Kim --- .../android_binary_analysis.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/fosslight_android/android_binary_analysis.py b/src/fosslight_android/android_binary_analysis.py index 2417233..5dc5d69 100755 --- a/src/fosslight_android/android_binary_analysis.py +++ b/src/fosslight_android/android_binary_analysis.py @@ -12,6 +12,7 @@ import xml.etree.ElementTree as ET import logging import zipfile +import gzip import shutil # Parsing NOTICE from bs4 import BeautifulSoup @@ -914,6 +915,19 @@ def find_meta_lic_files(): meta_lic_files[key] = lic +def _notice_zip_basename(file_path): + name_path = file_path[:-3] if file_path.endswith('.gz') else file_path + return os.path.basename(name_path) + + +def _notice_zip_arcname(file_path, use_path): + # .gz is stored uncompressed. A colliding basename uses the path with / -> _. + name_path = file_path[:-3] if file_path.endswith('.gz') else file_path + if use_path: + return name_path.replace('/', '_') + return os.path.basename(name_path) + + def create_and_copy_notice_zip(notice_files_list, zip_file_path): final_destination_file_name = "" @@ -924,9 +938,16 @@ def create_and_copy_notice_zip(notice_files_list, zip_file_path): final_destination_file_name = destination_path logger.debug(f"Notice file is copied to '{destination_path}'.") else: + basenames = [_notice_zip_basename(path) for path in notice_files_list] with zipfile.ZipFile(zip_file_path, 'w') as zipf: for single_file_path in notice_files_list: - zipf.write(single_file_path, arcname=os.path.basename(single_file_path)) + use_path = basenames.count(_notice_zip_basename(single_file_path)) > 1 + arcname = _notice_zip_arcname(single_file_path, use_path) + if single_file_path.endswith('.gz'): + with gzip.open(single_file_path, 'rb') as gz_file: + zipf.writestr(arcname, gz_file.read()) + else: + zipf.write(single_file_path, arcname=arcname) final_destination_file_name = zip_file_path return final_destination_file_name From 42e7c9da14041db8f5e0d3320bbc7b9203f65bd4 Mon Sep 17 00:00:00 2001 From: Soim Kim Date: Tue, 6 Oct 2026 07:37:49 +0900 Subject: [PATCH 2/4] fix(notice): drop android source path from zip entry names Colliding NOTICE basenames use the full path, so the absolute android source root leaked into the zip entry name. Strip that prefix and keep only the build relative part. Signed-off-by: Soim Kim --- src/fosslight_android/android_binary_analysis.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/fosslight_android/android_binary_analysis.py b/src/fosslight_android/android_binary_analysis.py index 5dc5d69..9f8c1a3 100755 --- a/src/fosslight_android/android_binary_analysis.py +++ b/src/fosslight_android/android_binary_analysis.py @@ -920,11 +920,20 @@ def _notice_zip_basename(file_path): return os.path.basename(name_path) +def _strip_android_src_path(file_path): + # Notice paths are absolute. The android source root is the cwd, so drop it + # to keep only the build relative part in the zip entry name. + android_src_path = os.getcwd().rstrip('/') + '/' + if file_path.startswith(android_src_path): + return file_path[len(android_src_path):] + return file_path.lstrip('/') + + def _notice_zip_arcname(file_path, use_path): # .gz is stored uncompressed. A colliding basename uses the path with / -> _. name_path = file_path[:-3] if file_path.endswith('.gz') else file_path if use_path: - return name_path.replace('/', '_') + return _strip_android_src_path(name_path).replace('/', '_') return os.path.basename(name_path) From 17fd74c751402782ca1133f068612823cba8bbe8 Mon Sep 17 00:00:00 2001 From: Soim Kim Date: Tue, 6 Oct 2026 08:18:28 +0900 Subject: [PATCH 3/4] fix(notice): prevent duplicate zip member names Track final NOTICE archive member names and append a numeric suffix when flattened paths or gzip normalization produce a collision. Add regression coverage for both collision forms. Signed-off-by: Soim Kim --- .../android_binary_analysis.py | 17 +++++++ test/test_notice_zip.py | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 test/test_notice_zip.py diff --git a/src/fosslight_android/android_binary_analysis.py b/src/fosslight_android/android_binary_analysis.py index 9f8c1a3..4a30daa 100755 --- a/src/fosslight_android/android_binary_analysis.py +++ b/src/fosslight_android/android_binary_analysis.py @@ -937,6 +937,21 @@ def _notice_zip_arcname(file_path, use_path): return os.path.basename(name_path) +def _deduplicate_notice_zip_arcname(arcname, used_arcnames): + if arcname not in used_arcnames: + used_arcnames.add(arcname) + return arcname + + name, extension = os.path.splitext(arcname) + suffix = 2 + unique_arcname = f"{name}_{suffix}{extension}" + while unique_arcname in used_arcnames: + suffix += 1 + unique_arcname = f"{name}_{suffix}{extension}" + used_arcnames.add(unique_arcname) + return unique_arcname + + def create_and_copy_notice_zip(notice_files_list, zip_file_path): final_destination_file_name = "" @@ -948,10 +963,12 @@ def create_and_copy_notice_zip(notice_files_list, zip_file_path): logger.debug(f"Notice file is copied to '{destination_path}'.") else: basenames = [_notice_zip_basename(path) for path in notice_files_list] + used_arcnames = set() with zipfile.ZipFile(zip_file_path, 'w') as zipf: for single_file_path in notice_files_list: use_path = basenames.count(_notice_zip_basename(single_file_path)) > 1 arcname = _notice_zip_arcname(single_file_path, use_path) + arcname = _deduplicate_notice_zip_arcname(arcname, used_arcnames) if single_file_path.endswith('.gz'): with gzip.open(single_file_path, 'rb') as gz_file: zipf.writestr(arcname, gz_file.read()) diff --git a/test/test_notice_zip.py b/test/test_notice_zip.py new file mode 100644 index 0000000..8148cd6 --- /dev/null +++ b/test/test_notice_zip.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# SPDX-FileCopyrightText: Copyright 2026 LG Electronics Inc. +# SPDX-License-Identifier: Apache-2.0 + +import gzip +import zipfile + +import pytest + +from fosslight_android.android_binary_analysis import create_and_copy_notice_zip + + +@pytest.mark.run +@pytest.mark.release +def test_notice_zip_member_names_are_unique_after_path_flattening(tmp_path, monkeypatch): + notice_files = [ + (tmp_path / "a_b" / "NOTICE.xml", b"plain flattened path"), + (tmp_path / "a" / "b" / "NOTICE.xml", b"nested flattened path"), + (tmp_path / "c_d" / "NOTICE.xml", b"plain notice"), + (tmp_path / "c" / "d" / "NOTICE.xml.gz", b"compressed notice"), + ] + for notice_file, content in notice_files: + notice_file.parent.mkdir(parents=True, exist_ok=True) + if notice_file.suffix == ".gz": + with gzip.open(notice_file, "wb") as output_file: + output_file.write(content) + else: + notice_file.write_bytes(content) + + monkeypatch.chdir(tmp_path) + zip_file = tmp_path / "notices.zip" + create_and_copy_notice_zip([str(path) for path, _ in notice_files], str(zip_file)) + + with zipfile.ZipFile(zip_file) as notice_zip: + member_names = notice_zip.namelist() + assert member_names == [ + "a_b_NOTICE.xml", + "a_b_NOTICE_2.xml", + "c_d_NOTICE.xml", + "c_d_NOTICE_2.xml", + ] + assert len(member_names) == len(set(member_names)) + assert set(notice_zip.read(name) for name in member_names) == { + content for _, content in notice_files + } From 74dd9ab444ce3426feb699418ca872fad8b5bc19 Mon Sep 17 00:00:00 2001 From: Soim Kim Date: Tue, 6 Oct 2026 08:23:16 +0900 Subject: [PATCH 4/4] fix(notice): remove incomplete zip on write failure Handle file and gzip read errors while creating the Hub NOTICE archive. Remove any partially written archive and return an empty result so it is not reported as uploadable. Signed-off-by: Soim Kim --- .../android_binary_analysis.py | 30 ++++++++++++------- test/test_notice_zip.py | 19 ++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/fosslight_android/android_binary_analysis.py b/src/fosslight_android/android_binary_analysis.py index 4a30daa..5a9fe9e 100755 --- a/src/fosslight_android/android_binary_analysis.py +++ b/src/fosslight_android/android_binary_analysis.py @@ -964,16 +964,26 @@ def create_and_copy_notice_zip(notice_files_list, zip_file_path): else: basenames = [_notice_zip_basename(path) for path in notice_files_list] used_arcnames = set() - with zipfile.ZipFile(zip_file_path, 'w') as zipf: - for single_file_path in notice_files_list: - use_path = basenames.count(_notice_zip_basename(single_file_path)) > 1 - arcname = _notice_zip_arcname(single_file_path, use_path) - arcname = _deduplicate_notice_zip_arcname(arcname, used_arcnames) - if single_file_path.endswith('.gz'): - with gzip.open(single_file_path, 'rb') as gz_file: - zipf.writestr(arcname, gz_file.read()) - else: - zipf.write(single_file_path, arcname=arcname) + try: + with zipfile.ZipFile(zip_file_path, 'w') as zipf: + for single_file_path in notice_files_list: + use_path = basenames.count(_notice_zip_basename(single_file_path)) > 1 + arcname = _notice_zip_arcname(single_file_path, use_path) + arcname = _deduplicate_notice_zip_arcname(arcname, used_arcnames) + if single_file_path.endswith('.gz'): + with gzip.open(single_file_path, 'rb') as gz_file: + zipf.writestr(arcname, gz_file.read()) + else: + zipf.write(single_file_path, arcname=arcname) + except (OSError, EOFError) as error: + logger.debug(f"Failed to compress Notice file: {error}") + try: + os.remove(zip_file_path) + except FileNotFoundError: + pass + except OSError as cleanup_error: + logger.debug(f"Failed to remove incomplete Notice zip: {cleanup_error}") + return "" final_destination_file_name = zip_file_path return final_destination_file_name diff --git a/test/test_notice_zip.py b/test/test_notice_zip.py index 8148cd6..de422b6 100644 --- a/test/test_notice_zip.py +++ b/test/test_notice_zip.py @@ -44,3 +44,22 @@ def test_notice_zip_member_names_are_unique_after_path_flattening(tmp_path, monk assert set(notice_zip.read(name) for name in member_names) == { content for _, content in notice_files } + + +@pytest.mark.run +@pytest.mark.release +def test_notice_zip_removes_partial_archive_when_gzip_is_invalid(tmp_path): + plain_notice = tmp_path / "plain" / "NOTICE.xml" + invalid_gzip_notice = tmp_path / "compressed" / "NOTICE.xml.gz" + plain_notice.parent.mkdir() + invalid_gzip_notice.parent.mkdir() + plain_notice.write_bytes(b"plain notice") + invalid_gzip_notice.write_bytes(b"not a gzip stream") + zip_file = tmp_path / "notices.zip" + + result = create_and_copy_notice_zip( + [str(plain_notice), str(invalid_gzip_notice)], str(zip_file) + ) + + assert result == "" + assert not zip_file.exists()