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()