Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions src/fosslight_android/android_binary_analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import xml.etree.ElementTree as ET
import logging
import zipfile
import gzip
import shutil
# Parsing NOTICE
from bs4 import BeautifulSoup
Expand Down Expand Up @@ -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 = ""

Expand All @@ -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
Expand Down
65 changes: 65 additions & 0 deletions test/test_notice_zip.py
Original file line number Diff line number Diff line change
@@ -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()
Loading