diff --git a/docs/latest.md b/docs/latest.md index d6f5dbb5..b7b4cd26 100755 --- a/docs/latest.md +++ b/docs/latest.md @@ -137,7 +137,7 @@ include both. If you need downstream rule to specifically depend on only the .de | distribution | "distribution: See http://www.debian.org/doc/debian-policy. | String | optional | `"unstable"` | | enhances | See http://www.debian.org/doc/debian-policy/ch-relationships.html#s-binarydeps. | List of strings | optional | `[]` | | homepage | The homepage of the project. | String | optional | `""` | -| installed_size | The approximate total size (in KiB) of the package's installed files. Corresponds to the Installed-Size field in the control file. See https://www.debian.org/doc/debian-policy/ch-controlfields.html#installed-size. | String | optional | `""` | +| installed_size | The approximate total size (in KiB) of the package's installed files. Corresponds to the Installed-Size field in the control file. See https://www.debian.org/doc/debian-policy/ch-controlfields.html#installed-size. If unset, it is computed automatically from the uncompressed size of `data`. | String | optional | `""` | | license | The license of the project. | String | optional | `""` | | maintainer | The maintainer of the package. | String | required | | | md5sums | A file listing md5 checksums of files in the data archive. This file is optional. See https://manpages.debian.org/bookworm/dpkg-dev/deb-md5sums.5.en.html. | Label | optional | `None` | diff --git a/pkg/private/deb/deb.bzl b/pkg/private/deb/deb.bzl index 169b007a..c6168b8c 100644 --- a/pkg/private/deb/deb.bzl +++ b/pkg/private/deb/deb.bzl @@ -346,7 +346,8 @@ See https://www.debian.org/doc/debian-policy/ch-files.html#s-config-files.""", "installed_size": attr.string( doc = """The approximate total size (in KiB) of the package's installed files. Corresponds to the Installed-Size field in the control file. - See https://www.debian.org/doc/debian-policy/ch-controlfields.html#installed-size.""", + See https://www.debian.org/doc/debian-policy/ch-controlfields.html#installed-size. + If unset, it is computed automatically from the uncompressed size of `data`.""", ), "license": attr.string(doc = """The license of the project."""), "breaks": attr.string_list( diff --git a/pkg/private/deb/make_deb.py b/pkg/private/deb/make_deb.py index 967b75b5..abdb6f43 100644 --- a/pkg/private/deb/make_deb.py +++ b/pkg/private/deb/make_deb.py @@ -192,6 +192,41 @@ def CreateDebControl(extrafiles=None, **kwargs): return control +def _SumTarMemberSizes(data): + """Sum member sizes of a tar, decompressing zstd via compression.zstd if needed. + + Raises tarfile.ReadError, OSError, or ImportError if data can't be read as + a tar. The last case covers zstd-compressed input on a Python without the + stdlib compression.zstd module (added in 3.14). + """ + try: + with tarfile.open(data, mode='r:*') as f: + return sum(member.size for member in f) + except tarfile.ReadError: + # compression.zstd is stdlib only from Python 3.14 (PEP 784); imported + # here, rather than at module scope, since it's only needed on this + # fallback path. + from compression import zstd + with zstd.open(data, mode='rb') as zf: + with tarfile.open(fileobj=zf, mode='r|') as f: + return sum(member.size for member in f) + + +def ComputeInstalledSizeKib(data): + """Approximate the Installed-Size (KiB) from the uncompressed data tar. + + Returns None if the tar can't be read (e.g. zstd-compressed input on a + Python without the stdlib compression.zstd module, added in 3.14), so + callers can fall back to omitting the field. An empty archive is a valid + result and returns '0', not None. + """ + try: + total_bytes = _SumTarMemberSizes(data) + except (tarfile.ReadError, OSError, ImportError): + return None + return str((total_bytes + 1023) // 1024) + + def CreateDeb(output, data, preinst=None, @@ -227,6 +262,10 @@ def CreateDeb(output, extrafiles['conffiles'] = ('\n'.join(conffiles) + '\n', 0o644) if changelog: extrafiles['changelog'] = (changelog, 0o644) + if not kwargs.get('installedSize'): + computed_size = ComputeInstalledSizeKib(data) + if computed_size: + kwargs['installedSize'] = computed_size control = CreateDebControl(extrafiles=extrafiles, **kwargs) # Write the final AR archive (the deb package) diff --git a/tests/deb/BUILD b/tests/deb/BUILD index f7e947f4..19f9635e 100644 --- a/tests/deb/BUILD +++ b/tests/deb/BUILD @@ -125,6 +125,35 @@ package_naming_test( target_under_test = ":test_deb", ) +# Test case for automatic Installed-Size computation when the attribute is +# left unset. +pkg_deb( + name = "deb_without_installed_size", + config = "config", + data = ":tar_input", + description = "test auto installed size", + maintainer = "test@example.com", + package = "fizzbuzz-noinstsize", + version = "1.0", +) + +py_test( + name = "pkg_deb_installed_size_test", + size = "medium", + srcs = [ + "pkg_deb_installed_size_test.py", + ], + data = [ + ":deb_without_installed_size", + ], + imports = ["../.."], + python_version = "PY3", + deps = [ + ":pkg_deb_test", + "@rules_python//python/runfiles", + ], +) + py_test( name = "control_field_test", size = "small", diff --git a/tests/deb/control_field_test.py b/tests/deb/control_field_test.py index 5f4f5e94..8c0bb116 100644 --- a/tests/deb/control_field_test.py +++ b/tests/deb/control_field_test.py @@ -20,7 +20,10 @@ import os import sys import tarfile +import tempfile +import types import unittest +from unittest import mock from pkg.private.deb import make_deb @@ -79,5 +82,109 @@ def test_multiline_add_trailing_newline(self): 'Description', 'fizz\n buzz\n baz', multiline=make_deb.Multiline.YES)) +def _write_tar_gz(path, contents): + """Write a tar.gz at path with the given {name: bytes} contents.""" + with tarfile.open(path, mode='w:gz') as tar: + for name, data in contents.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, fileobj=BytesIO(data)) + + +def _uncompressed_tar_bytes(contents): + """Return the bytes of an uncompressed tar with the given {name: bytes}.""" + buf = BytesIO() + with tarfile.open(fileobj=buf, mode='w') as tar: + for name, data in contents.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + tar.addfile(info, fileobj=BytesIO(data)) + return buf.getvalue() + + +_FAKE_ZSTD_MAGIC = b'FAKEZSTD:' + + +def _fake_zstd_open(filename, mode='rb'): + """Stand-in for compression.zstd.open (stdlib only from Python 3.14). + + Lets ComputeInstalledSizeKib's zstd fallback path be exercised on older + Pythons without a real zstd dependency: "compression" here is just a + magic-byte prefix, stripped back off on open(). + """ + with open(filename, 'rb') as f: + data = f.read() + assert data.startswith(_FAKE_ZSTD_MAGIC) + return BytesIO(data[len(_FAKE_ZSTD_MAGIC):]) + + +def _block_compression_zstd_module(): + """A sys.modules patch dict that makes `from compression import zstd` fail.""" + return {'compression': None, 'compression.zstd': None} + + +def _fake_compression_zstd_module(): + """A sys.modules patch dict providing a fake compression.zstd module.""" + fake_zstd = types.ModuleType('compression.zstd') + fake_zstd.open = _fake_zstd_open + fake_compression = types.ModuleType('compression') + fake_compression.zstd = fake_zstd + return {'compression': fake_compression, 'compression.zstd': fake_zstd} + + +class ComputeInstalledSizeKibTest(unittest.TestCase): + """Tests for ComputeInstalledSizeKib.""" + + def setUp(self): + super(ComputeInstalledSizeKibTest, self).setUp() + self.tmpdir = tempfile.TemporaryDirectory() + + def tearDown(self): + self.tmpdir.cleanup() + super(ComputeInstalledSizeKibTest, self).tearDown() + + def _tar_path(self, name='data.tar.gz'): + return os.path.join(self.tmpdir.name, name) + + def test_sums_member_sizes_and_rounds_up(self): + path = self._tar_path() + # 1000 + 100 = 1100 bytes -> ceil(1100 / 1024) = 2 KiB. + _write_tar_gz(path, {'a': b'x' * 1000, 'b': b'y' * 100}) + self.assertEqual(make_deb.ComputeInstalledSizeKib(path), '2') + + def test_exact_multiple_of_1024_is_not_rounded_up(self): + path = self._tar_path() + _write_tar_gz(path, {'a': b'x' * 1024}) + self.assertEqual(make_deb.ComputeInstalledSizeKib(path), '1') + + def test_empty_tar_returns_zero(self): + path = self._tar_path() + _write_tar_gz(path, {}) + self.assertEqual(make_deb.ComputeInstalledSizeKib(path), '0') + + def test_unreadable_file_returns_none(self): + path = self._tar_path('data.tar.zst') + with open(path, 'wb') as f: + f.write(b'not actually a tar') + with mock.patch.dict(sys.modules, _block_compression_zstd_module()): + self.assertIsNone(make_deb.ComputeInstalledSizeKib(path)) + + def test_zstd_without_module_returns_none(self): + # On a Python without compression.zstd (stdlib only from 3.14), a real + # zstd-compressed tar can't be read, same as any other unreadable input. + path = self._tar_path('data.tar.zst') + with open(path, 'wb') as f: + f.write(_FAKE_ZSTD_MAGIC + _uncompressed_tar_bytes({'a': b'x' * 1024})) + with mock.patch.dict(sys.modules, _block_compression_zstd_module()): + self.assertIsNone(make_deb.ComputeInstalledSizeKib(path)) + + def test_zstd_with_module_is_decompressed(self): + path = self._tar_path('data.tar.zst') + with open(path, 'wb') as f: + f.write(_FAKE_ZSTD_MAGIC + _uncompressed_tar_bytes({'a': b'x' * 1024})) + with mock.patch.dict(sys.modules, _fake_compression_zstd_module()): + self.assertEqual(make_deb.ComputeInstalledSizeKib(path), '1') + + if __name__ == '__main__': unittest.main() diff --git a/tests/deb/pkg_deb_installed_size_test.py b/tests/deb/pkg_deb_installed_size_test.py new file mode 100644 index 00000000..2793b44f --- /dev/null +++ b/tests/deb/pkg_deb_installed_size_test.py @@ -0,0 +1,42 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# -*- coding: utf-8 -*- +"""Tests that pkg_deb computes Installed-Size automatically when unset.""" + +import unittest + +from python.runfiles import runfiles +from tests.deb.pkg_deb_test import DebInspect + + +class PkgDebInstalledSizeTest(unittest.TestCase): + """Testing automatic Installed-Size computation for pkg_deb.""" + + def setUp(self): + super(PkgDebInstalledSizeTest, self).setUp() + self.runfiles = runfiles.Create() + self.deb_path = self.runfiles.Rlocation( + 'rules_pkg/tests/deb/fizzbuzz-noinstsize_1.0_all.deb') + self.deb_file = DebInspect(self.deb_path) + + def test_installed_size_is_computed(self): + control = self.deb_file.get_deb_ctl_file('control') + # :tar_input contains "etc/nsswitch.conf\n" (19 bytes) and + # "usr/fizzbuzz\n" (13 bytes), for 32 bytes total, rounded up to 1 KiB. + self.assertIn('Installed-Size: 1\n', control) + + +if __name__ == '__main__': + unittest.main()