summaryrefslogtreecommitdiffstats
path: root/debian/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-15 20:42:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-15 20:42:49 +0000
commit03815601f93e95e6f1e56dac32de10e546123726 (patch)
treeb1acc790faf13513e5beee2e7ac67a25c70670e4 /debian/tests
parentAdding upstream version 1.0.134. (diff)
downloaddebootstrap-debian/1.0.134.tar.xz
debootstrap-debian/1.0.134.zip
Adding debian version 1.0.134.debian/1.0.134
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'debian/tests')
-rw-r--r--debian/tests/arch-all-mitm.py120
-rwxr-xr-xdebian/tests/arch-all-support30
-rw-r--r--debian/tests/control27
-rwxr-xr-xdebian/tests/debian-testing394
-rwxr-xr-xdebian/tests/fake/pbuilder-0.228.4-137
-rwxr-xr-xdebian/tests/fake/pbuilder-0.23164
-rwxr-xr-xdebian/tests/fake/schroot-1.6.10-358
-rwxr-xr-xdebian/tests/fake/schroot-proposed87
-rw-r--r--debian/tests/out-of-order-mitm.py98
-rwxr-xr-xdebian/tests/unsorted-packages-files34
10 files changed, 949 insertions, 0 deletions
diff --git a/debian/tests/arch-all-mitm.py b/debian/tests/arch-all-mitm.py
new file mode 100644
index 0000000..9180cd9
--- /dev/null
+++ b/debian/tests/arch-all-mitm.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023 Debian Install System Team <debian-boot@lists.debian.org>
+# Copyright (C) 2023 Matthias Klumpp <matthias@tenstral.net>
+#
+# SPDX-License-Identifier: MIT
+
+"""
+Flask app which MITM's a legacy-compatibility archive to make it arch:all-only.
+"""
+import functools
+import gzip
+import hashlib
+import os
+
+import requests
+import tempfile
+from apt_pkg import TagFile, TagSection
+from flask import Flask, redirect
+
+app = Flask(__name__)
+
+ARCH = os.environ.get("FLASK_ARCH", "amd64")
+DIST = os.environ.get("FLASK_DIST", "trixie")
+DISTRO = os.environ.get("FLASK_DISTRO", "debian")
+MIRROR = os.environ.get("FLASK_MIRROR", "http://deb.debian.org")
+
+
+if DISTRO in ("debian", "pureos"):
+ hash_funcs = [hashlib.md5, hashlib.sha256]
+else:
+ # Ubuntu includes SHA1 still
+ hash_funcs = [hashlib.md5, hashlib.sha1, hashlib.sha256]
+
+
+def _munge_release_file(url: str) -> bytes:
+ """Given a Release file URL, rewrite it for our modified Packages content."""
+ original = requests.get(MIRROR + "/" + url).content.decode('utf-8')
+ packages_content = _packages_arch_content(
+ f"{DISTRO}/dists/{DIST}/main/binary-{ARCH}/Packages"
+ )
+ size = str(len(packages_content))
+ sums = [
+ hash_func(packages_content).hexdigest()
+ for hash_func in hash_funcs
+ ]
+ new_lines = []
+ filename = f"main/binary-{ARCH}/Packages"
+ for line in original.splitlines():
+ if line.startswith('No-Support-for-Architecture-all:'):
+ continue
+ if line.startswith('Architectures:'):
+ if ' all' not in line:
+ line += ' all'
+ new_lines.append(line)
+ continue
+ if line.startswith('Acquire-By-Hash:'):
+ new_lines.append('Acquire-By-Hash: no')
+ continue
+ if not line.endswith(filename):
+ new_lines.append(line)
+ continue
+ new_lines.append(" ".join(["", sums.pop(0), size, filename]))
+
+ result = "\n".join(new_lines)
+ return result.encode('utf-8')
+
+
+@functools.lru_cache
+def _packages_arch_content(url: str) -> bytes:
+ """Given an arch-specific Packages URL, fetch it and filter out arch:all."""
+ resp = requests.get(MIRROR + "/" + url + ".gz")
+ upstream_content = gzip.decompress(resp.content)
+
+ filtered_sections = []
+ with tempfile.NamedTemporaryFile() as tmp_f:
+ tmp_f.write(upstream_content)
+ tmp_f.flush()
+
+ with TagFile(tmp_f.name) as tf:
+ for section in tf:
+ if section.get('Architecture') == 'all':
+ continue
+ filtered_sections.append(section)
+
+ result = '\n'.join([str(s) for s in filtered_sections])
+ return result.encode('utf-8')
+
+
+@functools.lru_cache
+def _packages_indep_content(url: str) -> bytes:
+ """Given an arch:all Packages URL, just return its uncompressed contents."""
+ resp = requests.get(MIRROR + "/" + url + ".gz")
+ upstream_content = gzip.decompress(resp.content)
+
+ return upstream_content
+
+
+@app.route("/<path:url>", methods=["GET", "POST"])
+def root(url):
+ """Handler for all requests."""
+ if (
+ url == f"{DISTRO}/dists/{DIST}/InRelease"
+ or "by-hash" in url
+ or "Packages.xz" in url
+ or "Packages.gz" in url
+ ):
+ # 404 these URLs to force clients to fetch by path and without compression, to
+ # make MITM easier
+ return "", 404
+ if url == f"{DISTRO}/dists/{DIST}/Release":
+ # If Release is being fetched, return our modified version
+ return _munge_release_file(url)
+ if url == f"{DISTRO}/dists/{DIST}/main/binary-all/Packages":
+ return _packages_indep_content(url)
+ if url == f"{DISTRO}/dists/{DIST}/main/binary-{ARCH}/Packages":
+ # If Packages is being fetched, return our modified version
+ return _packages_arch_content(url)
+ # For anything we don't need to modify, redirect clients to upstream mirror
+ return redirect(f"{MIRROR}/{url}")
diff --git a/debian/tests/arch-all-support b/debian/tests/arch-all-support
new file mode 100755
index 0000000..b6b5608
--- /dev/null
+++ b/debian/tests/arch-all-support
@@ -0,0 +1,30 @@
+#!/bin/sh
+
+# This test runs the arch-all-mitm.py Flask app which debootstrap is then
+# pointed at.
+# It is used to pretend an archive in legacy-compatibility mode is only
+# supporting the split-arch-all mode, to test if debootstrap will handle
+# that situation correctly.
+
+export FLASK_ARCH="$(dpkg --print-architecture)"
+export FLASK_DIST=testing
+export FLASK_DISTRO=debian
+export FLASK_MIRROR=http://deb.debian.org
+export PATH=$PATH:/usr/sbin
+
+# Launch our MitM "mirror" server, ensure that request logging is sent to stdout
+PYTHONDONTWRITEBYTECODE=true FLASK_APP=debian/tests/arch-all-mitm.py flask run 2>&1 &
+flask_pid=$!
+
+# Give Flask time to come up
+sleep 2
+
+tempdir=$(mktemp -d)
+# Run debootstrap against our MitM "mirror", ignoring the inevitable GPG errors
+./debootstrap --download-only --variant minbase --no-check-gpg ${FLASK_DIST} $tempdir http://127.0.0.1:5000/${FLASK_DISTRO}/
+rc=$?
+
+rm -rf $tempdir
+kill $flask_pid
+
+exit $rc
diff --git a/debian/tests/control b/debian/tests/control
new file mode 100644
index 0000000..27a3a6c
--- /dev/null
+++ b/debian/tests/control
@@ -0,0 +1,27 @@
+Tests: debian-testing
+Depends:
+ debootstrap,
+ libdistro-info-perl,
+ libdpkg-perl,
+ libipc-run-perl,
+ perl,
+ systemd [linux-any],
+ systemd-container [linux-any],
+ ca-certificates,
+Restrictions: allow-stderr, needs-root
+
+Tests: unsorted-packages-files
+Depends:
+ debootstrap,
+ python3-debian,
+ python3-flask,
+ python3-requests,
+Restrictions: allow-stderr
+
+Tests: arch-all-support
+Depends:
+ debootstrap,
+ python3-apt,
+ python3-flask,
+ python3-requests,
+Restrictions: allow-stderr
diff --git a/debian/tests/debian-testing b/debian/tests/debian-testing
new file mode 100755
index 0000000..df94254
--- /dev/null
+++ b/debian/tests/debian-testing
@@ -0,0 +1,394 @@
+#!/usr/bin/perl
+# Verify that debootstrap'ing Debian testing produces a usable chroot,
+# and in particular that using it with early 2017 versions of schroot and
+# pbuilder results in working pseudo-terminals (#817236)
+#
+# Copyright © 2017 Simon McVittie
+# SPDX-License-Identifier: MIT
+# (see debian/copyright)
+
+use strict;
+use warnings;
+
+use Cwd qw(getcwd);
+use Debian::DistroInfo;
+use Dpkg::Version;
+use IPC::Run qw(run);
+use Test::More;
+
+my $srcdir = getcwd;
+
+sub verbose_run {
+ my $argv = shift;
+ diag("Running: @{$argv}");
+ return run($argv, @_);
+}
+
+sub capture {
+ my $output;
+ my $argv = shift;
+ ok(verbose_run($argv, '>', \$output), "@{$argv}");
+ chomp $output;
+ return $output;
+}
+
+my $check_non_docker_env;
+if (run([qw(grep docker.*cgroup /proc/1/mountinfo)], '&>', '/dev/null')) {
+ diag("it seems docker environment");
+ $check_non_docker_env = 0;
+}
+else {
+ diag("okay, it's not docker environment");
+ $check_non_docker_env = 1;
+}
+
+my @maybe_unshare_mount_ns;
+
+if (verbose_run(['unshare', '-m', 'true'])) {
+ diag('can unshare mount namespace');
+ @maybe_unshare_mount_ns = ('unshare', '-m');
+}
+else {
+ diag('cannot unshare mount namespace, are we in a container?');
+}
+
+sub check_fake_schroot {
+ my %params = @_;
+ my $reference = $params{reference};
+ my $version = $params{version} || '1.6.10-3';
+ my $extra_argv = $params{extra_argv} || [];
+
+ # Use unshare -m to make sure the /dev mount gets cleaned up on exit, even
+ # on failures
+ my $response = capture([@maybe_unshare_mount_ns,
+ "$srcdir/debian/tests/fake/schroot-$version", @{$extra_argv},
+ $params{chroot},
+ qw(runuser -u nobody --),
+ qw(script -q -c), 'cat /etc/debian_version', '/dev/null']);
+ $response =~ s/\r//g;
+ is($response, $reference, 'script(1) should work under (fake) schroot');
+}
+
+sub check_fake_pbuilder {
+ my %params = @_;
+ my $reference = $params{reference};
+ my $version = $params{version} || '0.228.4-1';
+
+ my $response = capture([@maybe_unshare_mount_ns,
+ "$srcdir/debian/tests/fake/pbuilder-$version", $params{chroot},
+ qw(runuser -u nobody --),
+ qw(script -q -c), 'cat /etc/debian_version', '/dev/null']);
+ $response =~ s/\r//g;
+ is($response, $reference,
+ 'script(1) should work under (fake) pbuilder');
+}
+
+sub check_chroot {
+ my %params = @_;
+ my $chroot = $params{chroot};
+ my $response;
+
+ ok(-f "$chroot/etc/debian_version",
+ 'chroot should have /etc/debian_version');
+ ok(-x "$chroot/usr/bin/env",
+ 'chroot should have /usr/bin/env which is Essential');
+
+ if ($params{has_systemd}) {
+ for my $p (
+ "root/.ssh", "run/lock/subsys",
+ "var/cache/private", "var/lib/private",
+ "var/lib/systemd/coredump", "var/lib/systemd/pstore",
+ "var/log/README", "var/log/private"
+ )
+ {
+ ok( -e "$chroot/$p",
+ "chroot should have /$p created by systemd-tmpfiles" );
+ }
+ }
+
+ ok(-x "$chroot/usr/bin/hello", 'chroot should have /usr/bin/hello due to --include');
+ ok(-d "$chroot/usr/share/doc", 'chroot should have /usr/share/doc');
+
+ if (!defined $ENV{container} || $ENV{container} ne "mmdebstrap-unshare") {
+ diag("not running with container=mmdebstrap-unshare");
+ ok(-c "$chroot/dev/full", '/dev/full should be a character device');
+ is(capture(['/usr/bin/stat', '--printf=%t %T %a', "$chroot/dev/full"]),
+ '1 7 666', '/dev/full should be device 1,7 with 0666 permissions');
+ ok(-c "$chroot/dev/null");
+ is(capture(['/usr/bin/stat', '--printf=%t %T %a', "$chroot/dev/null"]),
+ '1 3 666', '/dev/null should be device 1,3 with 0666 permissions');
+ }
+
+ my $did_mknod_ptmx;
+ my $output;
+
+ if (verbose_run([qw(ls -l), "$chroot/dev/ptmx"], '>', \$output)) {
+ diag("$chroot/dev/ptmx: $output");
+ }
+ else {
+ diag("Unable to list $chroot/dev/ptmx");
+ }
+ if (verbose_run([qw(ls -l), "$chroot/dev/pts/ptmx"], '>', \$output)) {
+ diag("$chroot/dev/pts/ptmx: $output");
+ }
+ else {
+ diag("Unable to list $chroot/dev/pts/ptmx");
+ }
+
+ if (-l "$chroot/dev/ptmx") {
+ # Necessary if debootstrap is run inside some containers, see
+ # https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=817236#77
+ diag("/dev/ptmx is a symbolic link");
+ like(readlink("$chroot/dev/ptmx"), qr{(?:/dev/)?pts/ptmx},
+ 'if /dev/ptmx is a symlink it should be to /dev/pts/ptmx');
+ $did_mknod_ptmx = 0;
+ }
+ else {
+ diag("/dev/ptmx is not a symbolic link");
+ ok(-c "$chroot/dev/ptmx",
+ 'if /dev/ptmx is not a symlink it should be a character device');
+ is(capture(['/usr/bin/stat', '--printf=%t %T %a',
+ "$chroot/dev/ptmx"]), '5 2 666',
+ 'if /dev/ptmx is a device node it should be 5,2 with 0666 permissions');
+ $did_mknod_ptmx = 1;
+ }
+
+ if ($params{can_mknod_ptmx}) {
+ ok($did_mknod_ptmx, 'able to mknod ptmx so should have done so');
+ }
+
+ my $reference = capture(['cat', "$chroot/etc/debian_version"]);
+
+ is(capture([qw(chroot chroot.d runuser -u nobody --
+ cat /etc/debian_version)]),
+ $reference);
+
+ # The remaining tests rely on device nodes to either exist or already
+ # being bind-mounted. Their setups are not prepared to deal with the
+ # conditions in an environment with an unshared user namespace as
+ # used in mmdebstrap.
+ if (defined $ENV{container} && $ENV{container} eq "mmdebstrap-unshare") {
+ return;
+ }
+
+ # The schroot behaviour proposed to fix #856877 and #983423 works,
+ # even inside (privileged) lxc.
+ check_fake_schroot(%params, reference => $reference, version => 'proposed');
+ check_fake_schroot(%params, reference => $reference, version => 'proposed',
+ extra_argv => ['--sbuild']);
+
+ # As of 1.6.10-3, or equivalently 1.6.10-11, the default profile
+ # certainly doesn't work in lxc >= 3 or in Docker:
+ # https://bugs.debian.org/983423
+ # It probably won't work in other container managers either, for
+ # similar reasons.
+ if (defined $params{container}) {
+ TODO: {
+ local $TODO = "schroot default profile doesn't work in lxc >= 3 or Docker";
+ check_fake_schroot(%params, reference => $reference,
+ version => '1.6.10-3');
+ }
+ }
+ else {
+ check_fake_schroot(%params, reference => $reference,
+ version => '1.6.10-3');
+ }
+
+ # schroot 1.6.10-3's sbuild profile does work in lxc, but only on newer
+ # kernels: https://bugs.debian.org/856877
+ if (Dpkg::Version->new($params{kernel}) < Dpkg::Version->new('4.7') &&
+ defined $params{container} && $params{container} eq 'lxc') {
+ TODO: {
+ local $TODO = "schroot --sbuild doesn't work in lxc on older ".
+ "kernels";
+ check_fake_schroot(%params, reference => $reference,
+ extra_argv => ['--sbuild']);
+ }
+ }
+ elsif (! $params{can_mknod_ptmx}) {
+ TODO: {
+ local $TODO = "schroot --sbuild doesn't work when /dev/ptmx is ".
+ "a symlink to /dev/pts/ptmx";
+ check_fake_schroot(%params, reference => $reference,
+ extra_argv => ['--sbuild']);
+ }
+ }
+ else {
+ check_fake_schroot(%params, reference => $reference,
+ extra_argv => ['--sbuild']);
+ }
+
+ # pbuilder >= 0.228.6 works fine
+ check_fake_pbuilder(%params, reference => $reference,
+ version => '0.231');
+
+ # Older pbuilder doesn't work if we are in a container where we can't
+ # create the /dev/ptmx device node: https://bugs.debian.org/841935
+ if (! $params{can_mknod_ptmx}) {
+ TODO: {
+ local $TODO = "pbuilder 0.228.4-1 doesn't work when /dev/ptmx is ".
+ "a symlink to /dev/pts/ptmx";
+ check_fake_pbuilder(%params, reference => $reference,
+ version => '0.228.4-1');
+ }
+ }
+ else {
+ check_fake_pbuilder(%params, reference => $reference,
+ version => '0.228.4-1');
+ }
+}
+
+# Specify https mirror to check https mirror specific problem
+# https://bugs.debian.org/896071
+my $mirror = 'https://deb.debian.org/debian';
+my $tmp = $ENV{AUTOPKGTEST_TMP};
+die "no autopkgtest temporary directory specified" unless $tmp;
+chdir $tmp or die "chdir $tmp: $!";
+
+$ENV{LC_ALL} = 'C.UTF-8';
+
+# Try to inherit a Debian mirror from the host
+foreach my $file ('/etc/apt/sources.list',
+ glob('/etc/apt/sources.list.d/*.list')) {
+ open(my $fh, '<', $file);
+ while (<$fh>) {
+ if (m{^deb\s+(http://[-a-zA-Z0-9.:]+/debian)\s}) {
+ $mirror = $1;
+ last;
+ }
+ }
+ close $fh;
+}
+
+if (run(['ischroot'], '>&2')) {
+ diag("In a chroot according to ischroot(1)");
+}
+else {
+ diag("Not in a chroot according to ischroot(1)");
+}
+
+my $virtualization;
+if ($^O ne 'linux') {
+ diag("Cannot use systemd-detect-virt on non-Linux");
+}
+elsif (run(['systemd-detect-virt', '--vm'], '>', \$virtualization)) {
+ chomp $virtualization;
+ diag("Virtualization: $virtualization");
+}
+else {
+ $virtualization = undef;
+ diag("Virtualization: (not in a virtual machine)");
+}
+
+my $in_container = 0;
+my $container;
+if ($^O ne 'linux') {
+ diag("Cannot use systemd-detect-virt on non-Linux");
+}
+elsif (run(['systemd-detect-virt', '--container'], '>', \$container)) {
+ $in_container = 1;
+ chomp $container;
+ diag("Container: $container");
+}
+else {
+ $container = undef;
+ diag("Container: (not in a container)");
+}
+
+my $kernel = capture([qw(uname -r)]);
+chomp $kernel;
+
+open(my $fh, '<', '/proc/self/mountinfo');
+while (<$fh>) {
+ chomp;
+ diag("mountinfo: $_");
+}
+close $fh;
+
+my $output;
+if (verbose_run([qw(ls -l /dev/ptmx)], '>', \$output)) {
+ diag("/dev/ptmx: $output");
+}
+else {
+ diag("Unable to list /dev/ptmx");
+}
+if (verbose_run([qw(ls -l /dev/pts/ptmx)], '>', \$output)) {
+ diag("/dev/pts/ptmx: $output");
+}
+else {
+ diag("Unable to list /dev/pts/ptmx");
+}
+
+my $can_mknod_ptmx;
+if (run([qw(mknod -m000 ptmx c 5 2)], '&>', '/dev/null')) {
+ diag("mknod ptmx succeeded");
+ $can_mknod_ptmx = 1;
+}
+else {
+ diag("mknod ptmx failed, are we in a container?");
+ $can_mknod_ptmx = 0;
+}
+
+my $distro_info = DebianDistroInfo->new;
+my $testing = $distro_info->testing;
+
+# Should specify multiple components for checking (see Bug#898738)
+if (!verbose_run([length($ENV{DEBOOTSTRAP_SCRIPT}) ? $ENV{DEBOOTSTRAP_SCRIPT} : 'debootstrap',
+ '--include=debootstrap,debian-archive-keyring,gnupg,hello,systemd',
+ '--variant=minbase',
+ '--components=main,contrib,non-free',
+ $testing, 'chroot.d', $mirror], '>&2')) {
+ BAIL_OUT("debootstrap failed: $?");
+}
+
+if (!verbose_run([qw(find chroot.d/dev -ls)], '>&2')) {
+ BAIL_OUT("Unable to list contents of chroot's /dev: $?");
+}
+
+if ($check_non_docker_env) {
+ check_chroot(chroot => 'chroot.d', can_mknod_ptmx => $can_mknod_ptmx,
+ kernel => $kernel, container => $container, has_systemd => 1);
+}
+
+if ($^O ne 'linux') {
+ diag("Cannot use systemd-nspawn on non-Linux");
+}
+elsif ($in_container) {
+ diag('in a container according to systemd-detect-virt, not trying to '.
+ 'use systemd-nspawn');
+}
+elsif (defined $ENV{container} && length $ENV{container}) {
+ diag('in a container according to $container, not trying to '.
+ 'use systemd-nspawn');
+}
+elsif (! -d '/run/systemd/system') {
+ diag('systemd not booted, not trying to use systemd-nspawn');
+}
+else {
+ if (!verbose_run(['systemd-nspawn', '-D', 'chroot.d',
+ "--bind=$ENV{AUTOPKGTEST_TMP}:/mnt",
+ '--bind-ro=/usr/sbin/debootstrap',
+ '--bind-ro=/usr/share/debootstrap',
+ '--',
+ 'debootstrap', '--include=hello', '--variant=minbase',
+ $testing, '/mnt/from-nspawn.d', $mirror], '>&2')) {
+ BAIL_OUT("debootstrap wrapped in systemd-nspawn failed: $?");
+ }
+
+ check_chroot(chroot => "$ENV{AUTOPKGTEST_TMP}/from-nspawn.d", can_mknod_ptmx => 0,
+ kernel => $kernel, container => "nspawn");
+}
+
+if (!defined $ENV{container} || $ENV{container} ne "mmdebstrap-unshare") {
+ if (!run([qw(rm -fr --one-file-system chroot.d)], '>&2')) {
+ BAIL_OUT('Unable to remove chroot.d');
+ }
+} else {
+ if (!run([qw(env --chdir=chroot.d find . -mount -mindepth 1 -delete)], '>&2')) {
+ BAIL_OUT('Unable to remove contents of chroot.d');
+ }
+}
+
+done_testing;
+
+# vim:set sw=4 sts=4 et:
diff --git a/debian/tests/fake/pbuilder-0.228.4-1 b/debian/tests/fake/pbuilder-0.228.4-1
new file mode 100755
index 0000000..80b0bf3
--- /dev/null
+++ b/debian/tests/fake/pbuilder-0.228.4-1
@@ -0,0 +1,37 @@
+#!/bin/sh
+# fake/pbuilder-0.228.4-1 -- emulate how pbuilder/0.228.4-1 would chroot.
+#
+# Please do not modify this script without verifying that its behaviour
+# is still equivalent to the stated versions of pbuilder.
+#
+# This version has #841935 unfixed. It mounts /dev/pts, without explicitly
+# requesting a new instance or a usable /dev/pts/ptmx.
+# (There is of course a lot more that it does, but these are the parts that
+# affect pty users like script(1).)
+#
+# Reference: pbuilder/pbuilder-modules, search for dev/pts.
+#
+# Copyright © 2017 Simon McVittie
+# SPDX-License-Identifier: MIT
+# (see debian/copyright)
+
+set -e
+
+chroot="$1"
+shift
+if test -z "$chroot" || test -z "$1"; then
+ echo "Usage: $0 CHROOT COMMAND...">&2
+ exit 2
+fi
+
+mkdir -p "$chroot/dev/pts"
+mount -t devpts none "$chroot/dev/pts" -onoexec,nosuid,gid=5,mode=620
+
+ls -l "$chroot/dev/ptmx" | sed -e 's/^/# fake-pbuilder: /' >&2
+ls -l "$chroot/dev/pts/ptmx" | sed -e 's/^/# fake-pbuilder: /' >&2
+
+e=0
+chroot "$chroot" "$@" || e=$?
+
+umount "$chroot/dev/pts"
+exit "$e"
diff --git a/debian/tests/fake/pbuilder-0.231 b/debian/tests/fake/pbuilder-0.231
new file mode 100755
index 0000000..e49e29f
--- /dev/null
+++ b/debian/tests/fake/pbuilder-0.231
@@ -0,0 +1,64 @@
+#!/bin/sh
+# fake/pbuilder-0.231 -- emulate how pbuilder >= 0.228.6 sets up its chroot
+#
+# Please do not modify this script without verifying that its behaviour
+# is still equivalent to the stated versions of pbuilder. If a future
+# version of pbuilder changes its behaviour, please copy this script and
+# modify the copy instead.
+#
+# This has #841935 fixed (commit 4a4134dd). It was checked for equivalence
+# to pbuilder 0.231, which is the version included in Debian 11 and 12,
+# but the versions in Debian 10 and 9 have equivalent code here.
+#
+# Reference: pbuilder/pbuilder-modules, search for dev/pts.
+#
+# Copyright © 2017-2021 Simon McVittie
+# SPDX-License-Identifier: MIT
+# (see debian/copyright)
+
+set -e
+
+BUILDPLACE="$1"
+shift
+if test -z "$BUILDPLACE" || test -z "$1"; then
+ echo "Usage: $0 CHROOT COMMAND...">&2
+ exit 2
+fi
+
+mkdir -p "$BUILDPLACE/dev/pts"
+TTYGRP=5
+TTYMODE=620
+mount -t devpts devpts "$BUILDPLACE/dev/pts" -o "newinstance,noexec,nosuid,gid=$TTYGRP,mode=$TTYMODE,ptmxmode=0666"
+
+mounted_ptmx=no
+
+if ! [ -L "$BUILDPLACE/dev/ptmx" ]; then
+ echo "# fake-pbuilder: redirecting /dev/ptmx to /dev/pts/ptmx" >&2
+ mount --bind "$BUILDPLACE/dev/pts/ptmx" "$BUILDPLACE/dev/ptmx"
+ mounted_ptmx=yes
+fi
+
+mounted_console=no
+
+if stdin_tty="$(tty)"; then
+ if [ ! -e "$BUILDPLACE/dev/console" ]; then
+ echo "# fake-pbuilder: creating /dev/console" >&2
+ mknod -m600 "$BUILDPLACE/dev/console" c 5 1
+ fi
+
+ echo "# fake-pbuilder: mounting $stdin_tty over /dev/console" >&2
+ mount --bind "$stdin_tty" "$BUILDPLACE/dev/console"
+ mounted_console=yes
+fi
+
+ls -l "$BUILDPLACE/dev/console" | sed -e 's/^/# fake-pbuilder: /' >&2
+ls -l "$BUILDPLACE/dev/ptmx" | sed -e 's/^/# fake-pbuilder: /' >&2
+ls -l "$BUILDPLACE/dev/pts/ptmx" | sed -e 's/^/# fake-pbuilder: /' >&2
+
+e=0
+chroot "$BUILDPLACE" "$@" || e=$?
+
+[ "$mounted_console" = no ] || umount "$BUILDPLACE/dev/console"
+[ "$mounted_ptmx" = no ] || umount "$BUILDPLACE/dev/ptmx"
+umount "$BUILDPLACE/dev/pts"
+exit "$e"
diff --git a/debian/tests/fake/schroot-1.6.10-3 b/debian/tests/fake/schroot-1.6.10-3
new file mode 100755
index 0000000..6ebd6dc
--- /dev/null
+++ b/debian/tests/fake/schroot-1.6.10-3
@@ -0,0 +1,58 @@
+#!/bin/sh
+# fake/schroot-1.6.10-3 -- emulate how schroot/1.6.10-3 would chroot.
+#
+# Please do not modify this script without verifying that its behaviour
+# is still equivalent to the stated versions of schroot. If a future
+# version of schroot changes its behaviour, please copy this script and
+# modify the copy instead.
+#
+# This version has #856877 unfixed. It bind-mounts /dev/pts and maybe
+# /dev from the host system, rather than creating a new instance of /dev/pts.
+# (There is of course a lot more that it does, but these are the parts that
+# affect pty users like script(1).)
+#
+# Copyright © 2017-2023 Simon McVittie
+# SPDX-License-Identifier: MIT
+# (see debian/copyright)
+
+set -e
+
+# Reference: /etc/schroot/default/fstab
+# (in schroot source tree: etc/profile-templates/default/linux/fstab)
+bind_dev=yes
+
+while true; do
+ case "$1" in
+ (--sbuild)
+ shift
+ # Reference: /etc/schroot/sbuild/fstab
+ # (source: etc/profile-templates/sbuild/linux/fstab)
+ bind_dev=no
+ ;;
+ (*)
+ break
+ esac
+done
+
+chroot="$1"
+shift
+if test -z "$chroot" || test -z "$1"; then
+ echo "Usage: $0 CHROOT COMMAND...">&2
+ exit 2
+fi
+
+[ "$bind_dev" = no ] || mount --bind /dev "$chroot/dev"
+mount --bind /dev/pts "$chroot/dev/pts"
+
+ls -l "/dev/ptmx" | sed -e 's/^/# fake-schroot: outside chroot: /' >&2
+ls -l "/dev/pts/ptmx" | sed -e 's/^/# fake-schroot: outside chroot: /' >&2
+ls -l "$chroot/dev/ptmx" | sed -e 's/^/# fake-schroot: /' >&2
+ls -l "$chroot/dev/pts/ptmx" | sed -e 's/^/# fake-schroot: /' >&2
+
+e=0
+chroot "$chroot" "$@" || e=$?
+
+umount "$chroot/dev/pts"
+[ "$bind_dev" = no ] || umount "$chroot/dev"
+
+exit "$e"
diff --git a/debian/tests/fake/schroot-proposed b/debian/tests/fake/schroot-proposed
new file mode 100755
index 0000000..9157dbc
--- /dev/null
+++ b/debian/tests/fake/schroot-proposed
@@ -0,0 +1,87 @@
+#!/bin/sh
+# fake/schroot-proposed -- emulate proposed mount behaviour for schroot
+#
+# This version emulates the behaviour proposed on #856877. If it needs
+# changing, please update the proposed patch on #856877 too.
+#
+# Copyright © 2017-2023 Simon McVittie
+# SPDX-License-Identifier: MIT
+# (see debian/copyright)
+
+set -e
+
+# Reference: /etc/schroot/default/fstab
+# (in schroot source tree: etc/profile-templates/default/linux/fstab)
+bind_dev=yes
+
+while true; do
+ case "$1" in
+ (--sbuild)
+ shift
+ # Reference: /etc/schroot/sbuild/fstab
+ # (source: etc/profile-templates/sbuild/linux/fstab)
+ bind_dev=no
+ ;;
+ (*)
+ break
+ esac
+done
+
+CHROOT_PATH="$1"
+shift
+if test -z "$CHROOT_PATH" || test -z "$1"; then
+ echo "Usage: $0 CHROOT COMMAND...">&2
+ exit 2
+fi
+
+[ "$bind_dev" = no ] || mount --bind /dev "$CHROOT_PATH/dev"
+mount -t devpts -o rw,newinstance,ptmxmode=666,mode=620,gid=5 /dev/pts "$CHROOT_PATH/dev/pts"
+
+ls -l "/dev/ptmx" | sed -e 's/^/# fake-schroot: outside chroot: /' >&2
+ls -l "/dev/pts/ptmx" | sed -e 's/^/# fake-schroot: outside chroot: /' >&2
+
+ls -l "$CHROOT_PATH/dev/ptmx" | sed -e 's/^/# fake-schroot: after first step: /' >&2
+ls -l "$CHROOT_PATH/dev/pts/ptmx" | sed -e 's/^/# fake-schroot: after first step: /' >&2
+
+mounted_ptmx=no
+
+# Depending on how /dev was set up, /dev/ptmx might either be
+# character device (5,2), or a symbolic link to pts/ptmx.
+# Either way we want it to be equivalent to /dev/pts/ptmx, assuming
+# both exist.
+if [ -e "$CHROOT_PATH/dev/pts/ptmx" ] && \
+ [ -e "$CHROOT_PATH/dev/ptmx" ] && \
+ ! [ "$CHROOT_PATH/dev/pts/ptmx" -ef "$CHROOT_PATH/dev/ptmx" ]; then
+ mount --bind "$CHROOT_PATH/dev/pts/ptmx" "$CHROOT_PATH/dev/ptmx"
+ mounted_ptmx=yes
+fi
+
+mounted_console=no
+
+# If schroot was invoked from a terminal, we still want to be able to
+# access that terminal. lxc and systemd-nspawn achieve this by
+# binding it onto /dev/console; so can we.
+if stdin_tty="$(tty)"; then
+ if [ ! -e "$CHROOT_PATH/dev/console" ]; then
+ # We need something to mount onto, and it might as well be
+ # the correctly-numbered device node.
+ mknod -m700 "$CHROOT_PATH/dev/console" c 5 1
+ fi
+
+ mount --bind "$stdin_tty" "$CHROOT_PATH/dev/console"
+ mounted_console=yes
+fi
+
+ls -l "$CHROOT_PATH/dev/console" | sed -e 's/^/# fake-schroot: after fixing mounts: /' >&2
+ls -l "$CHROOT_PATH/dev/ptmx" | sed -e 's/^/# fake-schroot: after fixing mounts: /' >&2
+ls -l "$CHROOT_PATH/dev/pts/ptmx" | sed -e 's/^/# fake-schroot: after fixing mounts: /' >&2
+
+e=0
+chroot "$CHROOT_PATH" "$@" || e=$?
+
+[ "$mounted_console" = no ] || umount "$CHROOT_PATH/dev/console"
+[ "$mounted_ptmx" = no ] || umount "$CHROOT_PATH/dev/ptmx"
+umount "$CHROOT_PATH/dev/pts"
+[ "$bind_dev" = no ] || umount "$CHROOT_PATH/dev"
+
+exit "$e"
diff --git a/debian/tests/out-of-order-mitm.py b/debian/tests/out-of-order-mitm.py
new file mode 100644
index 0000000..8a96c1b
--- /dev/null
+++ b/debian/tests/out-of-order-mitm.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2023 Debian Install System Team <debian-boot@lists.debian.org>
+#
+# SPDX-License-Identifier: MIT
+
+"""Flask app which MITM's an archive to generate out-of-order apt lists.
+
+Specifically, it prepends an additional Packages file stanza for a non-existent
+lower version of apt: a fixed version of debootstrap will find the second
+(correct) apt stanza and succeed; a broken version of debootstrap will find
+only the first (non-existent) apt stanza and fail.
+"""
+import functools
+import gzip
+import hashlib
+import os
+
+import requests
+from debian.deb822 import Packages
+from flask import Flask, redirect
+
+app = Flask(__name__)
+
+ARCH = os.environ.get("FLASK_ARCH", "amd64")
+DIST = os.environ.get("FLASK_DIST", "trixie")
+DISTRO = os.environ.get("FLASK_DISTRO", "debian")
+MIRROR = os.environ.get("FLASK_MIRROR", "http://deb.debian.org")
+
+
+if DISTRO in ("debian", "pureos"):
+ hash_funcs = [hashlib.md5, hashlib.sha256]
+else:
+ # Ubuntu includes SHA1 still
+ hash_funcs = [hashlib.md5, hashlib.sha1, hashlib.sha256]
+
+
+def _munge_release_file(url: str) -> bytes:
+ """Given a Release file URL, rewrite it for our modified Packages content."""
+ original = requests.get(MIRROR + "/" + url).content
+ packages_content = _packages_content(
+ f"{DISTRO}/dists/{DIST}/main/binary-{ARCH}/Packages"
+ )
+ size = bytes(str(len(packages_content)), "ascii")
+ sums = [
+ bytes(hash_func(packages_content).hexdigest(), "ascii")
+ for hash_func in hash_funcs
+ ]
+ new_lines = []
+ filename = f"main/binary-{ARCH}/Packages".encode("ascii")
+ for line in original.splitlines():
+ if not line.endswith(filename):
+ new_lines.append(line)
+ continue
+ new_lines.append(b" ".join([b"", sums.pop(0), size, filename]))
+ return b"\n".join(new_lines)
+
+
+@functools.lru_cache
+def _packages_content(url: str) -> bytes:
+ """Given a Packages URL, fetch it and prepend a broken apt stanza."""
+ resp = requests.get(MIRROR + "/" + url + ".gz")
+ upstream_content = gzip.decompress(resp.content)
+
+ # Find the first `apt` stanza
+ for stanza in Packages.iter_paragraphs(upstream_content):
+ if stanza["Package"] == "apt":
+ break
+
+ # Generate the broken stanza
+ new_version = stanza["Version"] + "~test"
+ stanza["Filename"] = stanza["Filename"].replace(stanza["Version"], new_version)
+ stanza["Version"] = new_version
+
+ # Prepend the stanza to the upstream content
+ return bytes(stanza) + b"\n" + upstream_content
+
+
+@app.route("/<path:url>", methods=["GET", "POST"])
+def root(url):
+ """Handler for all requests."""
+ if (
+ url == f"{DISTRO}/dists/{DIST}/InRelease"
+ or "by-hash" in url
+ or "Packages.xz" in url
+ or "Packages.gz" in url
+ ):
+ # 404 these URLs to force clients to fetch by path and without compression, to
+ # make MITM easier
+ return "", 404
+ if url == f"{DISTRO}/dists/{DIST}/Release":
+ # If Release is being fetched, return our modified version
+ return _munge_release_file(url)
+ if url == f"{DISTRO}/dists/{DIST}/main/binary-{ARCH}/Packages":
+ # If Packages is being fetched, return our modified version
+ return _packages_content(url)
+ # For anything we don't need to modify, redirect clients to upstream mirror
+ return redirect(f"{MIRROR}/{url}")
diff --git a/debian/tests/unsorted-packages-files b/debian/tests/unsorted-packages-files
new file mode 100755
index 0000000..0b9bd5a
--- /dev/null
+++ b/debian/tests/unsorted-packages-files
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+# This test runs the out-of-order-mitm.py Flask app which debootstrap is then
+# pointed at.
+# out-of-order-mitm.py will return a Packages file which has an additional
+# `apt` stanza prepended, with the Version and Filename adjusted to point at
+# a lower, non-existent version. Versions of debootstrap which process
+# _all_ Packages files entries will find the original stanza later in the file
+# (and succesfully fetch the corresponding package file): versions that don't
+# will find the prepended stanza and fail (with a 404 of the nonexistent
+# package file).
+
+export FLASK_ARCH="$(dpkg --print-architecture)"
+export FLASK_DIST=testing
+export FLASK_DISTRO=debian
+export FLASK_MIRROR=http://deb.debian.org
+export PATH=$PATH:/usr/sbin
+
+# Launch our MitM "mirror" server, ensure that request logging is sent to stdout
+PYTHONDONTWRITEBYTECODE=true FLASK_APP=debian/tests/out-of-order-mitm.py flask run 2>&1 &
+flask_pid=$!
+
+# Give Flask time to come up
+sleep 2
+
+tempdir=$(mktemp -d)
+# Run debootstrap against our MitM "mirror", ignoring the inevitable GPG errors
+debootstrap --download-only --variant minbase --no-check-gpg ${FLASK_DIST} $tempdir http://127.0.0.1:5000/${FLASK_DISTRO}/
+rc=$?
+
+rm -rf $tempdir
+kill $flask_pid
+
+exit $rc