Skip to content
Closed
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
2 changes: 1 addition & 1 deletion docs/latest.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ include both. If you need downstream rule to specifically depend on only the .de
| <a id="pkg_deb-distribution"></a>distribution | "distribution: See http://www.debian.org/doc/debian-policy. | String | optional | `"unstable"` |
| <a id="pkg_deb-enhances"></a>enhances | See http://www.debian.org/doc/debian-policy/ch-relationships.html#s-binarydeps. | List of strings | optional | `[]` |
| <a id="pkg_deb-homepage"></a>homepage | The homepage of the project. | String | optional | `""` |
| <a id="pkg_deb-installed_size"></a>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 | `""` |
| <a id="pkg_deb-installed_size"></a>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 | `""` |
| <a id="pkg_deb-license"></a>license | The license of the project. | String | optional | `""` |
| <a id="pkg_deb-maintainer"></a>maintainer | The maintainer of the package. | String | required | |
| <a id="pkg_deb-md5sums"></a>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. | <a href="https://bazel.build/concepts/labels">Label</a> | optional | `None` |
Expand Down
3 changes: 2 additions & 1 deletion pkg/private/deb/deb.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions pkg/private/deb/make_deb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions tests/deb/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
107 changes: 107 additions & 0 deletions tests/deb/control_field_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()
42 changes: 42 additions & 0 deletions tests/deb/pkg_deb_installed_size_test.py
Original file line number Diff line number Diff line change
@@ -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()