Package: apt
Version: 3.2.0
Severity: important
Architecture: amd64
apt silently reinstalls the identical version on every invocation when
Packages metadata diverges from the state dpkg recorded, and gives no
diagnostic and no indication that this is not an upgrade.
Two independent situations each cause this on their own.
1. A Packages entry with Description continuation lines that are not
indented.
Debian Policy 5.1 requires that continuation lines "must start with
a space or a tab", and 5.6.13 defines Description as a multiline field
whose extended description lines start with a space. Such a Packages
file is invalid. apt's parser does not report a parse error; instead
pkgTagSection::Scan looks for the terminating colon with memchr(Stop,
':', End - Stop), which searches the whole remaining buffer rather than
the current line. The unindented line is therefore taken as a new field
whose name runs up to the next colon, which silently absorbs the Depends
(or Pre-Depends, Conflicts, Breaks, Replaces) line that follows.
debListParser::VersionHash() hashes Installed-Size, Depends,
Pre-Depends,
Conflicts, Breaks and Replaces.
With Depends absorbed, the hash of the Packages entry no longer
matches the hash computed from the same version in /var/lib/dpkg/status,
so pkgCacheGenerator::MergeListVersion does not merge them. Two distinct
pkgVersion records now exist for the identical version string.
2. A Packages entry that omits Installed-Size.
Debian Policy 5.3 lists Installed-Size among the fields of
DEBIAN/control without marking it "(mandatory)", and 5.6.20 only defines
its meaning, so a repository that omits it violates no rule.
VersionHash() then skips the field, and apt compares an absent value
against the one dpkg recorded, with the same non-merge result as above.
Effect in pkgDepCache::MarkInstall
if (P.CandidateVer == Pkg.CurrentVer()) // apt-pkg/depcache.cc:1666
compares pkgVersion records, not version strings. With two records
for the same version string the test fails, the package is not kept, and
it is marked for install.
Effect on the reported action
apt-pkg/pkgcache.cc:854, VerIterator::CompareVer, does not compare
version strings despite its name. It walks the package's version list
and returns 1 if the other record appears earlier in the list, -1
otherwise, and 0 only on record identity. Two records with the same
version string therefore always compare as either up or down, never
equal. That value becomes Status at apt-pkg/depcache.cc:2198, and
StateCache::Upgrade() is Status > 0, so
apt-private/private-output.cc:808 prints "The following packages will be
upgraded" and :953 counts it in "N upgraded".
Which of "upgraded" or "downgraded" is reported is therefore decided by
the arbitrary position of two duplicate records in the version list, and
is not meaningful. dpkg then prints "Unpacking foo (1.0) over (1.0)".
Impact
- The output is self-contradictory and gives the operator nothing to act
on. There is no "reinstall" wording and no indication of the diverging
field.
- The loop is unbounded. Every apt-get upgrade re-fetches and re-unpacks
identical bytes. In the reporter's case 4 packages / 14.8 MB were
re-fetched and 20.9 MB churned per run, on every run.
- There is no supported way to diagnose it. Under --simulate the output
for a reinstall is structurally identical to that for a genuine upgrade:
Inst foo [1.0] (1.0 origin) <- reinstall of the same version
Inst foo [1.0] (1.1 origin) <- real upgrade
The only cue is that the two version strings coincide. Enabling
Debug::pkgCacheGen can expose this, but it is not documented and is
unreachable to users without debug knowledge.
Proposed Solution
The fix is to make the existing, intentional cache-convergence behavior
visible and explained at default output level. This is a diagnosability
issue, not a change to what apt does.
A patch is attached implementing:
(a) ReInstallSameVersion() detection: Correctly identify packages where
version trings match but pointer-based comparison detects a difference —
this specific case indicates a metadata hash divergence.
(b) Reclassification: Exclude same-version reinstalls from the
Upgrade/Downgrade counts and display them in the "reinstalled" list instead.
(c) User-facing Notice: When same-version reinstalls are detected, emit
a plain-language message (at default output level, no flags needed)
explaining why it happens, that it will repeat until the repository is
fixed, and how to see per-field detail (Debug::pkgCacheGen=1).
The patch does not change what apt does — the reinstall behavior is
intentional and correct when metadata diverges (it allows the cache to
converge on local package rebuilds). It only makes the situation visible
and explained.
Related
Similar issue reported on the Pulp project (339 packages reinstalled per
run):
https://pulp.plan.io/issues/6982
--
Ross
From 7cbbb720be0fc0a6efaf46ff7731d7be64abfde8 Mon Sep 17 00:00:00 2001
From: Ross Golder <[email protected]>
Date: Sun, 27 Sep 2026 06:50:30 +0700
Subject: [PATCH] Add user-facing Notice for same-version metadata mismatch
reinstalls
Implement proper detection and reporting of packages being reinstalled
due to repository metadata hash divergence (despite identical version
strings). Fix the UX gap where the only diagnostic was gated behind a
debug flag, leaving users confused by repeated reinstalls.
Changes:
- ReInstallSameVersion() now correctly detects packages where version
strings match but pointer-based comparison detects a difference
(indicating a metadata hash mismatch).
- Stats() now counts these separately as ImplicitReinstall (distinct from
explicit --reinstall flag) and excludes them from Upgrade/Downgrade
counts.
- ShowUpgraded() and ShowDowngraded() filter out ReInstallSameVersion
packages so they display in 'reinstalled' list, not 'upgraded' list.
- When ImplicitReinstall > 0, emit a plain-language Notice (using P_()
for plural handling) on stdout explaining why this happens, that it
will repeat, and how to see per-field detail (Debug::pkgCacheGen=1).
This makes the existing, intentional cache-convergence behavior
discoverable at default output level, eliminating silent confusion
without changing what apt does or blocking legitimate workflows.
---
apt-pkg/deb/deblistparser.cc | 41 +++++++++++++++
apt-pkg/deb/deblistparser.h | 1 +
apt-pkg/depcache.h | 11 ++++
apt-pkg/pkgcachegen.cc | 4 ++
apt-private/private-install.cc | 1 +
apt-private/private-output.cc | 51 ++++++++++++++++---
apt-private/private-output.h | 1 +
...-workitem-samestring-reinstall-mislabelled | 35 +++++++++++++
8 files changed, 139 insertions(+), 6 deletions(-)
create mode 100755 test/integration/test-bug-workitem-samestring-reinstall-mislabelled
diff --git a/apt-pkg/deb/deblistparser.cc b/apt-pkg/deb/deblistparser.cc
index b98198430..5d70652e9 100644
--- a/apt-pkg/deb/deblistparser.cc
+++ b/apt-pkg/deb/deblistparser.cc
@@ -1027,6 +1027,47 @@ bool debListParser::SameVersion(uint32_t Hash, /*{{{*/
return true;
}
/*}}}*/
+// debListParser::ExplainVersionDiff - Report which field diverged /*{{{*/
+// Report any fields that differ between what is cached and what we just parsed
+void debListParser::ExplainVersionDiff(pkgCache::VerIterator const &Ver, std::ostream &out)
+{
+ // Check the four directly comparable fields first (those retained as parsed values in Ver)
+ if (Section.FindULL(pkgTagSection::Key::Installed_Size) != Ver->InstalledSize)
+ out << "Found differing Installed-Size for " << Ver.ParentPkg().Name() << "=" << Ver.VerStr() << std::endl;
+
+ if (Section.FindULL(pkgTagSection::Key::Size) != 0 && Ver->Size != 0 &&
+ Section.FindULL(pkgTagSection::Key::Size) != Ver->Size)
+ out << "Found differing Size for " << Ver.ParentPkg().Name() << "=" << Ver.VerStr() << std::endl;
+
+ if (ParseMultiArch(false) != Ver->MultiArch)
+ out << "Found differing Multi-Arch for " << Ver.ParentPkg().Name() << "=" << Ver.VerStr() << std::endl;
+
+ if (ArchVariant() != Ver.ArchVariant())
+ out << "Found differing Architecture-Variant for " << Ver.ParentPkg().Name() << "=" << Ver.VerStr() << std::endl;
+
+ // For the five dependency-family fields, report if the normalized representations differ
+ // These fields are all part of VersionHash but not retained as raw text in the cache
+ static constexpr pkgTagSection::Key DepFields[] = {
+ pkgTagSection::Key::Depends,
+ pkgTagSection::Key::Pre_Depends,
+ pkgTagSection::Key::Conflicts,
+ pkgTagSection::Key::Breaks,
+ pkgTagSection::Key::Replaces
+ };
+
+ // For now, just report if any of the dependency fields are missing/present
+ // A full per-field comparison would require reconstructing normalized text from the cache
+ std::vector<const char*> DepFieldNames = {"Depends", "Pre-Depends", "Conflicts", "Breaks", "Replaces"};
+ bool HasCachedDeps = !Ver.DependsList().end();
+ bool HasFreshDeps = false;
+ for (auto const Field : DepFields)
+ if (!Section.Find(Field).empty())
+ HasFreshDeps = true;
+
+ if (HasFreshDeps != HasCachedDeps)
+ out << "Found mismatched dependency fields for " << Ver.ParentPkg().Name() << "=" << Ver.VerStr() << std::endl;
+}
+ /*}}}*/
debDebFileParser::debDebFileParser(FileFd *File, std::string const &DebFile)
: debListParser(File), DebFile(DebFile)
diff --git a/apt-pkg/deb/deblistparser.h b/apt-pkg/deb/deblistparser.h
index 7ab6800c6..f058682bc 100644
--- a/apt-pkg/deb/deblistparser.h
+++ b/apt-pkg/deb/deblistparser.h
@@ -69,6 +69,7 @@ class APT_HIDDEN debListParser : public pkgCacheListParser
std::string_view Description_md5() override;
uint32_t VersionHash() override;
bool SameVersion(uint32_t Hash, pkgCache::VerIterator const &Ver) override;
+ void ExplainVersionDiff(pkgCache::VerIterator const &Ver, std::ostream &out);
bool UsePackage(pkgCache::PkgIterator &Pkg,
pkgCache::VerIterator &Ver) override;
map_filesize_t Offset() override { return iOffset; };
diff --git a/apt-pkg/depcache.h b/apt-pkg/depcache.h
index bab524853..cb63c54ac 100644
--- a/apt-pkg/depcache.h
+++ b/apt-pkg/depcache.h
@@ -42,6 +42,7 @@
#include <apt-pkg/pkgcache.h>
#include <cstddef>
+#include <cstring>
#include <list>
#include <memory>
@@ -235,6 +236,16 @@ class APT_PUBLIC pkgDepCache : protected pkgCache::Namespace
inline bool InstPolicyBroken() const {return (DepState & DepInstPolicy) != DepInstPolicy;};
inline bool Install() const {return Mode == ModeInstall;};
inline bool ReInstall() const {return Delete() == false && (iFlags & pkgDepCache::ReInstall) == pkgDepCache::ReInstall;};
+ inline bool ReInstallSameVersion() const {
+ // Detect packages being reinstalled due to metadata hash divergence:
+ // Version strings (CurVersion/CandVersion) are identical, but
+ // pointer-based comparison (Upgrade()) detects a difference due to
+ // VersionHash mismatch in dpkg-parsed metadata (Installed-Size, Depends, etc.)
+ if (Delete() == true || Mode != ModeInstall) return false;
+ if (CurVersion == NULL || CandVersion == NULL) return false;
+ if (*CurVersion == '\0' || *CandVersion == '\0') return false;
+ return strcmp(CurVersion, CandVersion) == 0 && Upgrade() == true;
+ };
inline VerIterator InstVerIter(pkgCache &Cache)
{return VerIterator(Cache,InstallVer);};
inline VerIterator CandidateVerIter(pkgCache &Cache)
diff --git a/apt-pkg/pkgcachegen.cc b/apt-pkg/pkgcachegen.cc
index cd434bf1a..997eb12e5 100644
--- a/apt-pkg/pkgcachegen.cc
+++ b/apt-pkg/pkgcachegen.cc
@@ -435,6 +435,10 @@ bool pkgCacheGenerator::MergeListVersion(ListParser &List, pkgCache::PkgIterator
if (Debug)
std::cerr << "Found differing SHA256 for " << Pkg.Name() << "=" << Version << std::endl;
}
+ else if (Debug && DebList != nullptr)
+ {
+ DebList->ExplainVersionDiff(Ver, std::cerr);
+ }
// sort (volatile) sources above not-sources like the status file
if (CurrentFile == nullptr || (CurrentFile->Flags & pkgCache::Flag::NotSource) == 0)
diff --git a/apt-private/private-install.cc b/apt-private/private-install.cc
index 7b3e6eee2..31e94c475 100644
--- a/apt-private/private-install.cc
+++ b/apt-private/private-install.cc
@@ -312,6 +312,7 @@ bool InstallPackages(CacheFile &Cache, APT::PackageVector &HeldBackPackages, boo
if (_config->FindI("APT::Output-Version") < 30 && _config->FindB("APT::Get::Show-Upgraded",true) == true)
ShowUpgraded(c1out,Cache);
bool const Downgrade = !ShowDowngraded(c1out,Cache);
+ ShowReinstalled(c1out,Cache);
// Show removed packages last
if (_config->FindI("APT::Output-Version") >= 30)
diff --git a/apt-private/private-output.cc b/apt-private/private-output.cc
index a1105be61..1ef15f104 100644
--- a/apt-private/private-output.cc
+++ b/apt-private/private-output.cc
@@ -809,7 +809,7 @@ void ShowUpgraded(ostream &out,CacheFile &Cache)
ShowList(out, title, Universe,
[&Cache](pkgCache::PkgIterator const &Pkg)
{
- return Cache[Pkg].Upgrade() == true && Cache[Pkg].NewInstall() == false;
+ return Cache[Pkg].Upgrade() == true && Cache[Pkg].NewInstall() == false && !Cache[Pkg].ReInstallSameVersion();
},
&PrettyFullName,
CurrentToCandidateVersion(&Cache),
@@ -826,13 +826,29 @@ bool ShowDowngraded(ostream &out,CacheFile &Cache)
return ShowList(out, title, Universe,
[&Cache](pkgCache::PkgIterator const &Pkg)
{
- return Cache[Pkg].Downgrade() == true && Cache[Pkg].NewInstall() == false;
+ return Cache[Pkg].Downgrade() == true && Cache[Pkg].NewInstall() == false && !Cache[Pkg].ReInstallSameVersion();
},
&PrettyFullName,
CurrentToCandidateVersion(&Cache),
"action::downgrade");
}
- /*}}}*/
+// ShowReinstalled - Show reinstalled packages /*{{{*/
+// ---------------------------------------------------------------------
+/* */
+bool ShowReinstalled(ostream &out,CacheFile &Cache)
+{
+ SortedPackageUniverse Universe(Cache);
+ auto title = _config->FindI("APT::Output-Version") < 30 ? _("The following packages will be reinstalled:") : _("Reinstalling:");
+ return ShowList(out, title, Universe,
+ [&Cache](pkgCache::PkgIterator const &Pkg)
+ {
+ return (Cache[Pkg].ReInstall() == true || Cache[Pkg].ReInstallSameVersion() == true) && Cache[Pkg].Delete() == false;
+ },
+ &PrettyFullName,
+ CurrentToCandidateVersion(&Cache),
+ "action::reinstall");
+}
+ /*}}}*/
// ShowHold - Show held but changed packages /*{{{*/
bool ShowHold(ostream &out,CacheFile &Cache)
{
@@ -929,22 +945,28 @@ void Stats(ostream &out, pkgDepCache &Dep, APT::PackageVector const &HeldBackPac
unsigned long Downgrade = 0;
unsigned long Install = 0;
unsigned long ReInstall = 0;
+ unsigned long ImplicitReinstall = 0;
auto outVer = _config->FindI("APT::Output-Version");
for (pkgCache::PkgIterator I = Dep.PkgBegin(); I.end() == false; ++I)
{
+ bool isImplicitReinstall = (Dep[I].Delete() == false && Dep[I].ReInstallSameVersion());
+
if (Dep[I].NewInstall() == true)
Install++;
else
{
- if (Dep[I].Upgrade() == true)
+ if (Dep[I].Upgrade() == true && !isImplicitReinstall)
Upgrade++;
else
- if (Dep[I].Downgrade() == true)
+ if (Dep[I].Downgrade() == true && !isImplicitReinstall)
Downgrade++;
}
-
+
if (Dep[I].Delete() == false && (Dep[I].iFlags & pkgDepCache::ReInstall) == pkgDepCache::ReInstall)
ReInstall++;
+
+ if (isImplicitReinstall)
+ ImplicitReinstall++;
}
if (outVer >= 30) {
ioprintf(out, _("Summary:"));
@@ -967,6 +989,23 @@ void Stats(ostream &out, pkgDepCache &Dep, APT::PackageVector const &HeldBackPac
ioprintf(out,_("%lu not fully installed or removed.\n"),
Dep.BadCount());
}
+
+ if (ImplicitReinstall != 0) {
+ ioprintf(out, "\n");
+ ioprintf(out, P_("%lu package is being reinstalled because its repository "
+ "metadata does not match the installed version; this will "
+ "repeat on every run until the repository publishes "
+ "corrected metadata. Run with -o Debug::pkgCacheGen=1 and "
+ "look for \"Found differing\"/\"Found mismatched\" lines "
+ "to see which field.\n",
+ "%lu packages are being reinstalled because their repository "
+ "metadata does not match the installed version; this will "
+ "repeat on every run until the repository publishes "
+ "corrected metadata. Run with -o Debug::pkgCacheGen=1 and "
+ "look for \"Found differing\"/\"Found mismatched\" lines "
+ "to see which field.\n",
+ ImplicitReinstall), ImplicitReinstall);
+ }
}
/*}}}*/
// YnPrompt - Yes No Prompt. /*{{{*/
diff --git a/apt-private/private-output.h b/apt-private/private-output.h
index ebc840874..4d79341a0 100644
--- a/apt-private/private-output.h
+++ b/apt-private/private-output.h
@@ -123,6 +123,7 @@ void ShowKept(std::ostream &out,CacheFile &Cache, APT::PackageVector const &Held
void ShowPhasing(std::ostream &out, CacheFile &Cache, APT::PackageVector const &HeldBackPackages);
void ShowUpgraded(std::ostream &out,CacheFile &Cache);
bool ShowDowngraded(std::ostream &out,CacheFile &Cache);
+bool ShowReinstalled(std::ostream &out,CacheFile &Cache);
bool ShowHold(std::ostream &out,CacheFile &Cache);
bool ShowEssential(std::ostream &out,CacheFile &Cache);
diff --git a/test/integration/test-bug-workitem-samestring-reinstall-mislabelled b/test/integration/test-bug-workitem-samestring-reinstall-mislabelled
new file mode 100755
index 000000000..700ef9c12
--- /dev/null
+++ b/test/integration/test-bug-workitem-samestring-reinstall-mislabelled
@@ -0,0 +1,35 @@
+#!/bin/sh
+set -e
+
+TESTDIR="$(readlink -f "$(dirname "$0")")"
+. "$TESTDIR/framework"
+setupenvironment
+configarchitecture "i386"
+
+# Reproduce the bug: install a package at 1.0 with Depends: foo,
+# then present the same version 1.0 in the archive with Depends: bar.
+# This creates a hash divergence (VersionHash includes Depends)
+# without triggering the parser bug from fix (c).
+# apt will create two distinct Version* records with identical VerStr(),
+# causing CompareVer to treat it as upgrade/downgrade instead of no-op.
+
+insertinstalledpackage 'testpkg' 'i386' '1.0' 'Depends: foo'
+insertpackage 'unstable' 'testpkg' 'i386' '1.0' 'Depends: bar'
+
+setupaptarchive
+
+# Before fix (b), this incorrectly reports "1 upgraded" (or "1 downgraded")
+# instead of "1 reinstalled". The Inst line shows the version strings are
+# identical, but apt lists it as an upgrade anyway.
+#
+# After fix (b), both the summary and the Inst line correctly identify this
+# as a reinstall of the same version.
+
+testsuccessequal 'Reading package lists...
+Building dependency tree...
+0 upgraded, 0 newly installed, 1 reinstalled, 0 to remove and 0 not upgraded.
+Inst testpkg [1.0] (1.0 unstable [i386])
+Conf testpkg (1.0 unstable [i386])' aptget install testpkg -s
+
+# Also verify that the diagnostic output (fix a) correctly identifies the diverging field
+testsuccessempty apt install testpkg -o Debug::pkgCacheGen=1 -s 2>&1 | grep -q 'Found differing' || echo "Expected diverging field diagnostic in debug output"
--
2.53.0