diff --git a/src/fosslight_android/android_binary_analysis.py b/src/fosslight_android/android_binary_analysis.py index 2417233..5a9fe9e 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,43 @@ 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 _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 _strip_android_src_path(name_path).replace('/', '_') + 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 = "" @@ -924,9 +962,28 @@ 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: - 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)) + basenames = [_notice_zip_basename(path) for path in notice_files_list] + used_arcnames = set() + 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 new file mode 100644 index 0000000..de422b6 --- /dev/null +++ b/test/test_notice_zip.py @@ -0,0 +1,65 @@ +#!/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 + } + + +@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()