From c355a4ba8eff2b8fc4c12aa272ea9aec41fe3604 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20Beauz=C3=A9e-Luyssen?= Date: Wed, 16 Sep 2026 10:04:59 +0200 Subject: [PATCH] pkg_deb: compute Installed-Size automatically when unset The `Installed-Size` control field can be set through the `installed_size` attribute, but it requires knowing the package size ahead of time and hardcoding it, which is unpractical. This is likely to cause the attribute to be omitted and the installed size metadata to be missing. This commit now automatically computes the installed size when the attribute is not explicitly provided. --- docs/latest.md | 2 +- pkg/private/deb/deb.bzl | 3 +- pkg/private/deb/make_deb.py | 39 +++++++++ tests/deb/BUILD | 29 ++++++ tests/deb/control_field_test.py | 107 +++++++++++++++++++++++ tests/deb/pkg_deb_installed_size_test.py | 42 +++++++++ 6 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 tests/deb/pkg_deb_installed_size_test.py 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()