diff --git a/HACKING.md b/HACKING.md new file mode 100644 index 00000000..4d0dd2d4 --- /dev/null +++ b/HACKING.md @@ -0,0 +1,55 @@ +# Hacking on synaptic + +## Dependencies + +On Debian or Ubuntu the quickest way to get everything is + + sudo apt build-dep synaptic + +The authoritative list is `Build-Depends` in `debian/control`. + +## Building + + meson setup build + ninja -C build + +The binary is `build/gtk/synaptic`. Build options live in +`meson_options.txt`; list them and their current values with + + meson configure build + +and change one with e.g. `meson configure build -Dpkg_hold=true`. + +## Running the tests + + ninja -C build test + +is the `make test` equivalent. For more control use meson directly: + + meson test -C build --print-errorlogs + meson test -C build test_rsources + meson test -C build test_rsources --gtest-args='--gtest_shuffle --gtest_repeat=5' + +`test_rsources` uses GoogleTest and is only built when `gtest` is found. +`-Dtests=enabled` makes a missing `libgtest-dev` a configure error, which is +what the Debian package build does; `-Dtests=disabled` skips it. + +`tests/test_gtkpkglist` is an interactive viewer for the package list widget, +not a test, so it is built but not registered with `meson test`. + +## Style checks + + ninja -C build lint # whitespace errors introduced relative to master + ninja -C build clang-format-check # formatting per .clang-format + +`./fmt` runs clang-format in place over every C++ file under `common/`, +`gtk/` and `tests/`; for a single file use `clang-format -i path/to/file.cc`. +Boolean arguments at call sites are annotated systemd style, e.g. +`GetListOfFilesInDir(Dir, ext, /* SortList */ true)`. + +## Building the Debian package + + gbp buildpackage + +or plain `dpkg-buildpackage -us -uc`; `debian/gbp.conf` holds the branch +layout for git-buildpackage. diff --git a/common/rsources.cc b/common/rsources.cc index f2d574d6..d1f306df 100644 --- a/common/rsources.cc +++ b/common/rsources.cc @@ -34,14 +34,12 @@ #include #include #include +#include #include -#include -#include #include #include #include #include -#include #include using namespace std; @@ -67,6 +65,11 @@ SourcesList::SourceRecord *SourcesList::AddSourceNode(SourceRecord &rec) return newrec; } +static string ExpandArch(const string &S) +{ + return SubstVar(S, "$(ARCH)", _config->Find("APT::Architecture")); +} + bool SourcesList::ReadSourcePart(string listpath) { // cout << "SourcesList::ReadSourcePart() "<< listpath << endl; @@ -141,8 +144,7 @@ bool SourcesList::ReadSourcePart(string listpath) if (ParseQuoteWord(p, Section) == true) return _error->Error(_("Syntax error in line %s"), buf); - rec.Dist = - SubstVar(rec.Dist, "$(ARCH)", _config->Find("APT::Architecture")); + rec.Dist = ExpandArch(rec.Dist); AddSourceNode(rec); continue; @@ -179,54 +181,94 @@ bool SourcesList::ReadSourcePart(string listpath) return record_ok; } -bool SourcesList::ReadSourceDir(string Dir) +// copy libapt:sourcelist.cc FindMultiValue() +// TODO: expose in libapt as pkgTagSection::FindMultiValue() and drop this +static vector FindMultiValue(const pkgTagSection &Sec, + const char *Field) { - // cout << "SourcesList::ReadSourceDir() " << Dir << endl; - - DIR *D = opendir(Dir.c_str()); - if (D == 0) - return _error->Errno("opendir", _("Unable to read %s"), Dir.c_str()); - - vector List; - for (struct dirent *Ent = readdir(D); Ent != 0; Ent = readdir(D)) { - if (Ent->d_name[0] == '.') - continue; + string value = Sec.FindS(Field); + replace_if(value.begin(), value.end(), isspace_ascii, ' '); + vector parts = VectorizeString(value, ' '); + parts.erase(remove_if(parts.begin(), + parts.end(), + [](const string &s) { return s.empty(); }), + parts.end()); + return parts; +} - // Skip bad file names ala run-parts - const char *C = Ent->d_name; - for (; *C != 0; C++) - if (isalpha(*C) == 0 && isdigit(*C) == 0 && *C != '_' && *C != '-' && - *C != '.') - break; - if (*C != 0) - continue; +bool SourcesList::ReadDeb822SourcePart(string path) +{ + FileFd Fd; + // FileFd::Open() already queued an error with the errno + if (Fd.Open(path, FileFd::ReadOnly) == false) + return false; - // Only look at files ending in .list to skip .rpmnew etc files - if (strcmp(Ent->d_name + strlen(Ent->d_name) - 5, ".list") != 0) + pkgTagFile Tags(&Fd, pkgTagFile::SUPPORT_COMMENTS); + pkgTagSection Sec; + bool record_ok = true; + while (Tags.Step(Sec)) { + SourceRecord rec; + rec.SourceFile = path; + rec.Format = Deb822; + + bool types_ok = true; + for (const string &T : FindMultiValue(Sec, "Types")) + if (rec.SetType(T) == false) + types_ok = false; + if (!types_ok || rec.Type == 0) { + record_ok = false; continue; + } + // absent means enabled (like in libapt) + string Enabled = Sec.FindS("Enabled"); + if (Enabled.empty() == false && StringToBool(Enabled) == false) + rec.Type |= Disabled; + + // expanded like SetURI() does it, but without its trailing slash, which + // only makes sense for a single URI + vector uris = FindMultiValue(Sec, "URIs"); + for (string &uri : uris) + uri = ExpandArch(uri); + rec.URI = APT::String::Join(uris, " "); + // apt expands $(ARCH) in deb822 suites too + vector suites = FindMultiValue(Sec, "Suites"); + for (string &suite : suites) + suite = ExpandArch(suite); + rec.Dist = APT::String::Join(suites, " "); + + vector comps = FindMultiValue(Sec, "Components"); + rec.NumSections = comps.size(); + rec.Sections = new string[rec.NumSections]; + for (unsigned short i = 0; i < rec.NumSections; i++) + rec.Sections[i] = comps[i]; - // Make sure it is a file and not something else - string File = flCombine(Dir, Ent->d_name); - struct stat St; - if (stat(File.c_str(), &St) != 0 || S_ISREG(St.st_mode) == 0) - continue; - List.push_back(File); + AddSourceNode(rec); } - closedir(D); - - sort(List.begin(), List.end()); + return record_ok; +} - // Read the files - for (vector::const_iterator I = List.begin(); I != List.end(); I++) - if (ReadSourcePart(*I) == false) - return false; - return true; +bool SourcesList::ReadSourceDir(string Dir) +{ + bool ok = true; + for (const string &File : + GetListOfFilesInDir(Dir, + vector{"list", "sources"}, + /* SortList */ true)) { + if (flExtension(File) == "sources") + ok = ReadDeb822SourcePart(File) && ok; + else + ok = ReadSourcePart(File) && ok; + } + return ok; } bool SourcesList::ReadSources() { // cout << "SourcesList::ReadSources() " << endl; + // libapt queued notices about odd files in sources.list.d when it read them + // at startup and they were shown then; do not queue them again on success + _error->PushToStack(); bool Res = true; string Parts = _config->FindDir("Dir::Etc::sourceparts"); @@ -236,6 +278,10 @@ bool SourcesList::ReadSources() if (FileExists(Main) == true) Res &= ReadSourcePart(Main); + if (Res) + _error->RevertToStack(); + else + _error->MergeWithStack(); return Res; } @@ -304,7 +350,9 @@ bool SourcesList::UpdateSources() for (list::iterator it = SourceRecords.begin(); it != SourceRecords.end(); it++) { - if ((*it)->SourceFile == "") + // we cannot represent a deb822 in a SourceRecord yet so skip + // writing until we can represent and write them + if ((*it)->SourceFile == "" || (*it)->Format == Deb822) continue; filenames.push_front((*it)->SourceFile); } @@ -373,7 +421,14 @@ bool SourcesList::SourceRecord::SetType(string S) return true; } -string SourcesList::SourceRecord::GetType() +string SourcesList::SourceRecord::TypeLabel() const +{ + if ((Type & Deb) != 0 && (Type & DebSrc) != 0) + return "deb deb-src"; + return GetType(); +} + +string SourcesList::SourceRecord::GetType() const { if ((Type & Deb) != 0) return "deb"; @@ -402,9 +457,7 @@ bool SourcesList::SourceRecord::SetURI(string S) if (S.find(':') == string::npos) return false; - S = SubstVar(S, "$(ARCH)", _config->Find("APT::Architecture")); - S = SubstVar(S, "$(VERSION)", _config->Find("APT::DistroVersion")); - URI = S; + URI = ExpandArch(S); // append a / to the end if one is not already there if (URI[URI.size() - 1] != '/') @@ -428,6 +481,7 @@ SourcesList::SourceRecord &SourcesList::SourceRecord::operator=( NumSections = rhs.NumSections; Comment = rhs.Comment; SourceFile = rhs.SourceFile; + Format = rhs.Format; return *this; } diff --git a/common/rsources.h b/common/rsources.h index ccc181e5..3a770b59 100644 --- a/common/rsources.h +++ b/common/rsources.h @@ -47,6 +47,10 @@ class SourcesList RepomdSrc = 1 << 9 }; + // record the type because deb822 is read-only for now + // (until we can fully represent it) + enum FileFormat { OneLine, Deb822 }; + struct SourceRecord { unsigned int Type; @@ -57,12 +61,15 @@ class SourcesList unsigned short NumSections; std::string Comment; std::string SourceFile; + FileFormat Format; bool SetType(std::string); - std::string GetType(); + std::string GetType() const; + // For display: "deb deb-src" when a stanza declares both types + std::string TypeLabel() const; bool SetURI(std::string); - SourceRecord() : Type(0), Sections(0), NumSections(0) + SourceRecord() : Type(0), Sections(0), NumSections(0), Format(OneLine) {} ~SourceRecord() { @@ -98,6 +105,7 @@ class SourcesList void RemoveSource(SourceRecord *&); void SwapSources(SourceRecord *&, SourceRecord *&); bool ReadSourcePart(std::string listpath); + bool ReadDeb822SourcePart(std::string path); bool ReadSourceDir(std::string Dir); bool ReadSources(); bool UpdateSources(); diff --git a/debian/control b/debian/control index 3f1ee893..7a16a9cd 100644 --- a/debian/control +++ b/debian/control @@ -2,7 +2,7 @@ Source: synaptic Section: admin Priority: optional Maintainer: Michael Vogt -Build-Depends: debhelper-compat (= 12), gettext, meson, ninja-build, libapt-pkg-dev, libgtk-3-dev, libvte-2.91-dev, libpolkit-gobject-1-dev, libsm-dev, lsb-release, libxapian-dev, xmlto +Build-Depends: debhelper-compat (= 12), gettext, meson, ninja-build, libapt-pkg-dev, libgtk-3-dev, libvte-2.91-dev, libpolkit-gobject-1-dev, libsm-dev, lsb-release, libxapian-dev, xmlto, libgtest-dev Build-Conflicts: librpm-dev Standards-Version: 4.5.0 Vcs-Git: https://github.com/mvo5/synaptic.git diff --git a/debian/rules b/debian/rules index 7c6f7c90..c5b48d99 100755 --- a/debian/rules +++ b/debian/rules @@ -6,7 +6,8 @@ DIST = $(shell lsb_release -i -s) DHFLAGS=--parallel MESON_FLAGS= --prefix=/usr \ --localstatedir=/var/lib/synaptic \ - -Dpkg_hold=true + -Dpkg_hold=true \ + -Dtests=enabled %: dh $@ $(DHFLAGS) --buildsystem=meson --without autoreconf --without autotools-dev diff --git a/gtk/gtkbuilder/window_repositories.ui b/gtk/gtkbuilder/window_repositories.ui index ffd41852..6c43d9c5 100644 --- a/gtk/gtkbuilder/window_repositories.ui +++ b/gtk/gtkbuilder/window_repositories.ui @@ -277,6 +277,20 @@ 1 + + + True + True + 0 + True + True + + + False + True + 2 + + True diff --git a/gtk/rgrepositorywin.cc b/gtk/rgrepositorywin.cc index ca45e8a4..3298d6fa 100644 --- a/gtk/rgrepositorywin.cc +++ b/gtk/rgrepositorywin.cc @@ -90,6 +90,20 @@ enum { COL_TYPE, }; +// Shown under the edit fields for the selected source +static string SourceHint(const SourcesList::SourceRecord *rec) +{ + gchar *text = g_strdup_printf(_("Defined in %s."), rec->SourceFile.c_str()); + string hint = text; + g_free(text); + if (rec->Format == SourcesList::Deb822) { + hint += " "; + hint += _("This source uses the deb822 format and can not be edited " + "here yet."); + } + return hint; +} + void RGRepositoryEditor::item_toggled(GtkCellRendererToggle *cell, gchar *path_str, gpointer data) @@ -107,6 +121,15 @@ void RGRepositoryEditor::item_toggled(GtkCellRendererToggle *cell, gtk_tree_model_get( model, &iter, STATUS_COLUMN, &toggle_item, SECTIONS_COLUMN, §ion, -1); + SourcesList::SourceRecord *rec; + gtk_tree_model_get(model, &iter, RECORD_COLUMN, &rec, -1); + if (rec->Format == SourcesList::Deb822) { + // read-only for now, see SourcesList::FileFormat + g_free(section); + gtk_tree_path_free(path); + return; + } + /* do something with the value */ toggle_item ^= 1; @@ -392,6 +415,9 @@ RGRepositoryEditor::RGRepositoryEditor(RGWindow *parent) _editTable = GTK_WIDGET(gtk_builder_get_object(_builder, "table_edit")); assert(_editTable); gtk_widget_set_sensitive(_editTable, FALSE); + _hintLabel = + GTK_WIDGET(gtk_builder_get_object(_builder, "label_source_hint")); + assert(_hintLabel); gtk_window_resize(GTK_WINDOW(_win), 620, 400); skipTaskbar(true); @@ -440,7 +466,7 @@ bool RGRepositoryEditor::Run() STATUS_COLUMN, !((*it)->Type & SourcesList::Disabled), TYPE_COLUMN, - utf8((*it)->GetType().c_str()), + utf8((*it)->TypeLabel().c_str()), VENDOR_COLUMN, utf8((*it)->VendorID.c_str()), URI_COLUMN, @@ -574,6 +600,8 @@ void RGRepositoryEditor::doEdit() SourcesList::SourceRecord *rec; gtk_tree_model_get(model, _lastIter, RECORD_COLUMN, &rec, -1); assert(rec); + if (rec->Format == SourcesList::Deb822) + return; rec->Type = 0; gboolean status; @@ -653,7 +681,7 @@ void RGRepositoryEditor::doEdit() STATUS_COLUMN, !(rec->Type & SourcesList::Disabled), TYPE_COLUMN, - utf8(rec->GetType().c_str()), + utf8(rec->TypeLabel().c_str()), VENDOR_COLUMN, utf8(rec->VendorID.c_str()), URI_COLUMN, @@ -728,12 +756,6 @@ void RGRepositoryEditor::SelectionChanged(GtkTreeSelection *selection, GtkTreeIter iter; GtkTreeModel *model; - gtk_widget_set_sensitive(me->_editTable, TRUE); - - gtk_widget_set_sensitive(me->_upBut, TRUE); - gtk_widget_set_sensitive(me->_downBut, TRUE); - gtk_widget_set_sensitive(me->_deleteBut, TRUE); - if (gtk_tree_selection_get_selected(selection, &model, &iter)) { me->doEdit(); // save the old row if (me->_lastIter != NULL) @@ -743,6 +765,15 @@ void RGRepositoryEditor::SelectionChanged(GtkTreeSelection *selection, const SourcesList::SourceRecord *rec; gtk_tree_model_get(model, &iter, RECORD_COLUMN, &rec, -1); + // deb822 stanzas are shown but not editable yet, see + // SourcesList::FileFormat + const bool editable = rec->Format != SourcesList::Deb822; + gtk_widget_set_sensitive(me->_editTable, editable); + gtk_widget_set_sensitive(me->_upBut, editable); + gtk_widget_set_sensitive(me->_downBut, editable); + gtk_widget_set_sensitive(me->_deleteBut, editable); + gtk_label_set_text(GTK_LABEL(me->_hintLabel), SourceHint(rec).c_str()); + int id = ITEM_TYPE_DEB; if (rec->Type & SourcesList::DebSrc) id = ITEM_TYPE_DEBSRC; @@ -778,6 +809,7 @@ void RGRepositoryEditor::SelectionChanged(GtkTreeSelection *selection, } else { // cout << "no selection" << endl; gtk_widget_set_sensitive(me->_editTable, FALSE); + gtk_label_set_text(GTK_LABEL(me->_hintLabel), ""); gtk_widget_set_sensitive(me->_upBut, FALSE); gtk_widget_set_sensitive(me->_downBut, FALSE); diff --git a/gtk/rgrepositorywin.h b/gtk/rgrepositorywin.h index 05da1965..127cc006 100644 --- a/gtk/rgrepositorywin.h +++ b/gtk/rgrepositorywin.h @@ -54,6 +54,7 @@ class RGRepositoryEditor : RGGtkBuilderWindow GtkTreeIter *_lastIter; GtkWidget *_editTable; + GtkWidget *_hintLabel; GtkWidget *_optVendor; GtkListStore *_optVendorMenu; GtkWidget *_entryURI; @@ -71,7 +72,8 @@ class RGRepositoryEditor : RGGtkBuilderWindow bool _applied; bool _dirty; - const GdkRGBA _gray = {0xAA00, 0xAA00, 0xAA00, 1.0}; + // GdkRGBA channels are doubles in 0..1, not 16-bit GdkColor values + const GdkRGBA _gray = {0xAA / 255.0, 0xAA / 255.0, 0xAA / 255.0, 1.0}; void UpdateVendorMenu(); int VendorMenuIndex(std::string VendorID); diff --git a/meson_options.txt b/meson_options.txt index 05ced0ba..45aa3a7c 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -32,3 +32,10 @@ option( value: true, description: 'Build with apt multiarch support', ) + +option( + 'tests', + type: 'feature', + value: 'auto', + description: 'Build the gtest based unit tests', +) diff --git a/tests/fake_sources_dir.h b/tests/fake_sources_dir.h new file mode 100644 index 00000000..13c39cff --- /dev/null +++ b/tests/fake_sources_dir.h @@ -0,0 +1,77 @@ +/* fake_sources_dir.h - a throwaway apt tree for source-list tests + * + * Copyright (c) 2026 Synaptic development team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of the + * License, or (at your option) any later version. + */ + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Creates /synaptic-test-XXXXXX with a sources.list.d/ inside and +// points apt's Dir::Etc::sourceparts and Dir::Etc::sourcelist at it, so the +// code under test sees exactly what the repository dialog would see. +class FakeSourcesDir +{ + public: + std::string root; + std::string parts; + std::string main; + + FakeSourcesDir() + { + GError *err = nullptr; + gchar *dir = g_dir_make_tmp("synaptic-test-XXXXXX", &err); + if (dir == nullptr) { + std::cerr << "FAIL: cannot create a temporary directory: " + << err->message << std::endl; + std::exit(1); + } + root = dir; + g_free(dir); + parts = root + "/sources.list.d"; + main = root + "/sources.list"; + mkdir(parts.c_str(), 0755); + _config->Set("Dir::Etc::sourceparts", parts); + _config->Set("Dir::Etc::sourcelist", main); + } + + ~FakeSourcesDir() + { + std::error_code ec; + std::filesystem::remove_all(root, ec); + } + + // Paths are relative to the fake tree's root, e.g. + // "sources.list.d/a.sources". + std::string path(const std::string &rel) const + { + return root + "/" + rel; + } + + void put(const std::string &rel, const std::string &body) const + { + std::ofstream out(path(rel), std::ios::binary); + out << body; + } + + std::string get(const std::string &rel) const + { + std::ifstream in(path(rel), std::ios::binary); + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); + } +}; diff --git a/tests/meson.build b/tests/meson.build index 5b488923..ce7acc4c 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -2,6 +2,7 @@ test_env = environment() test_env.set('LC_ALL', 'C') test_common_deps = gui_deps +gtest_dep = dependency('gtest', required: get_option('tests')) test_rpackage = executable( 'test_rpackage', @@ -30,6 +31,18 @@ test_rpackageview = executable( link_with: libsynaptic, ) +if gtest_dep.found() + test_rsources = executable( + 'test_rsources', + 'test_rsources.cc', + cpp_args: gtk_cpp_args + rpm_compile_args + ['-O0', '-g3'], + dependencies: test_common_deps + [gtest_dep], + include_directories: [root_inc, common_inc, gtk_inc], + link_with: libsynaptic, + ) + test('test_rsources', test_rsources, env: test_env) +endif + test_gtkpkglist = executable( 'test_gtkpkglist', [ diff --git a/tests/test_rsources.cc b/tests/test_rsources.cc new file mode 100644 index 00000000..ee8225c7 --- /dev/null +++ b/tests/test_rsources.cc @@ -0,0 +1,348 @@ +#include "config.h" // IWYU pragma: associated + +#include "fake_sources_dir.h" +#include "rsources.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +class RSourcesTest : public ::testing::Test +{ + protected: + FakeSourcesDir box; + + static vector read_files(const SourcesList &lst) + { + vector files; + for (const SourcesList::SourceRecord *rec : lst.SourceRecords) { + if (rec->Type & SourcesList::Comment) + continue; + files.push_back(rec->SourceFile.substr(rec->SourceFile.rfind('/') + 1)); + } + return files; + } + + static vector records(const SourcesList &lst) + { + vector recs; + for (const SourcesList::SourceRecord *rec : lst.SourceRecords) + if (!(rec->Type & SourcesList::Comment)) + recs.push_back(rec); + return recs; + } + + static vector sections(const SourcesList::SourceRecord *rec) + { + return vector(rec->Sections, rec->Sections + rec->NumSections); + } +}; + +static const char *MVO5_SOURCES = + "Types: deb deb-src\n" + "URIs: http://ftp.de.debian.org/debian/\n" + "Suites: trixie\n" + "Components: main non-free-firmware\n" + "\n" + "Types: deb\n" + "URIs: http://security.debian.org/debian-security/\n" + "Suites: trixie-security\n" + "Components: main non-free-firmware\n"; + +static const char *STANZA_A = + "Types: deb\n" + "URIs: http://a.example/debian\n" + "Suites: stable\n" + "Components: main\n"; + +static const char *STANZA_B = + "Types: deb\n" + "URIs: http://b.example/debian\n" + "Suites: testing\n" + "Components: main\n"; + +// Which files in Dir::Etc::sourceparts are picked up, and in what order. +// Every entry below is one line, so the record count equals the file count. +TEST_F(RSourcesTest, OnlyListFilesInSortedOrder) +{ + const string line = "deb http://deb.debian.org/debian bookworm main\n"; + // good + box.put("sources.list.d/b.list", line); + box.put("sources.list.d/a.list", line); + box.put("sources.list.d/legacy.list", line); + + // bad (ignore) + box.put("sources.list.d/ab", line); + box.put("sources.list.d/x", line); + box.put("sources.list.d/bad name.list", line); + box.put("sources.list.d/foo.list.bak", line); + box.put("sources.list.d/.hidden.list", line); + mkdir(box.path("sources.list.d/dir.list").c_str(), 0755); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + EXPECT_EQ(read_files(lst), (vector{"a.list", "b.list", "legacy.list"})); +} + +// GetListOfFilesInDir() queues a notice for every oddly named file; a +// successful read must leave the queue exactly as it found it +TEST_F(RSourcesTest, SuccessfulReadLeavesErrorQueueUntouched) +{ + const string line = "deb http://deb.debian.org/debian bookworm main\n"; + box.put("sources.list.d/a.list", line); + box.put("sources.list.d/old.list.old", line); + _error->Warning("unrelated"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + EXPECT_EQ(read_files(lst), (vector{"a.list"})); + string msg; + EXPECT_FALSE(_error->PopMessage(msg)); + EXPECT_EQ(msg, "unrelated"); + EXPECT_TRUE(_error->empty()); +} + +TEST_F(RSourcesTest, Deb822Stanzas) +{ + box.put("sources.list.d/debian.sources", MVO5_SOURCES); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 2u); + + EXPECT_EQ(recs[0]->Type, SourcesList::Deb | SourcesList::DebSrc); + EXPECT_EQ(recs[0]->TypeLabel(), "deb deb-src"); + EXPECT_EQ(recs[0]->URI, "http://ftp.de.debian.org/debian/"); + EXPECT_EQ(recs[0]->Dist, "trixie"); + EXPECT_EQ(sections(recs[0]), (vector{"main", "non-free-firmware"})); + EXPECT_EQ(recs[0]->Format, SourcesList::Deb822); + EXPECT_EQ(recs[0]->SourceFile, box.path("sources.list.d/debian.sources")); + + EXPECT_EQ(recs[1]->Type, SourcesList::Deb); + EXPECT_EQ(recs[1]->TypeLabel(), "deb"); + EXPECT_EQ(recs[1]->Dist, "trixie-security"); +} + +TEST_F(RSourcesTest, Deb822EnabledField) +{ + box.put("sources.list.d/e.sources", + string(STANZA_A) + "Enabled: no\n\n" + STANZA_A + "Enabled: false\n\n" + + STANZA_A + "Enabled: yes\n\n" + STANZA_A + "Enabled: bogus\n\n" + + STANZA_A); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 5u); + EXPECT_TRUE(recs[0]->Type & SourcesList::Disabled); + EXPECT_TRUE(recs[1]->Type & SourcesList::Disabled); + EXPECT_FALSE(recs[2]->Type & SourcesList::Disabled); + EXPECT_FALSE(recs[3]->Type & SourcesList::Disabled); // like apt: unknown = enabled + EXPECT_FALSE(recs[4]->Type & SourcesList::Disabled); // absent = enabled +} + +// A stanza may list several URIs and suites. The record keeps them all, in +// the URI and Dist strings, rather than silently showing only the first. +TEST_F(RSourcesTest, Deb822MultiValueFields) +{ + box.put("sources.list.d/ubuntu.sources", + "Types: deb\n" + "URIs: http://archive.ubuntu.com/ubuntu/ http://mirror.example/ubuntu/\n" + "Suites: noble noble-updates noble-backports\n" + "Components: main universe restricted multiverse\n" + "\n" + "Types: deb\n" + "URIs:\n" + " http://a.example/debian\n" + " http://b.example/debian\n" + "Suites:\tstable testing\n" + "Components: main\n"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 2u); + EXPECT_EQ(recs[0]->URI, + "http://archive.ubuntu.com/ubuntu/ http://mirror.example/ubuntu/"); + EXPECT_EQ(recs[0]->Dist, "noble noble-updates noble-backports"); + EXPECT_EQ(sections(recs[0]), + (vector{"main", "universe", "restricted", "multiverse"})); + // continuation lines and odd whitespace collapse to single spaces + EXPECT_EQ(recs[1]->URI, "http://a.example/debian http://b.example/debian"); + EXPECT_EQ(recs[1]->Dist, "stable testing"); +} + +// $(ARCH) and $(VERSION) are expanded as they are for one-line sources, so +// the same repository shows the same URI whichever file format it came from. +// like apt: $(ARCH) is expanded, anything else is left alone +TEST_F(RSourcesTest, Deb822ArchIsExpanded) +{ + _config->Set("APT::Architecture", "riscv64"); + box.put("sources.list.d/v.sources", + "Types: deb\n" + "URIs: http://a.example/$(ARCH)/debian http://b.example/$(VERSION)\n" + "Suites: $(ARCH)-stable\n" + "Components: main\n"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 1u); + EXPECT_EQ(recs[0]->URI, "http://a.example/riscv64/debian http://b.example/$(VERSION)"); + EXPECT_EQ(recs[0]->Dist, "riscv64-stable"); +} + +TEST_F(RSourcesTest, Deb822MultilineSignedBy) +{ + box.put("sources.list.d/key.sources", + "Types: deb\n" + "URIs: https://pkg.example.com/apt\n" + "Suites: stable\n" + "Components: main\n" + "Signed-By:\n" + " -----BEGIN PGP PUBLIC KEY BLOCK-----\n" + " # an indented hash is content, not a comment\n" + " mQINBF\n" + " -----END PGP PUBLIC KEY BLOCK-----\n"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 1u); + EXPECT_EQ(recs[0]->URI, "https://pkg.example.com/apt"); + EXPECT_EQ(sections(recs[0]), (vector{"main"})); +} + +TEST_F(RSourcesTest, Deb822Comments) +{ + box.put("sources.list.d/c.sources", + string("# leading comment\n") + STANZA_A + + "# comment between two stanzas\n\n" + "# comment before B\n" + + "Types: deb\n" + "# comment between fields\n" + "URIs: http://b.example/debian\n" + "Suites: testing\n" + "Components: main\n" + "\n" + "# trailing comment\n"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 2u); + EXPECT_EQ(recs[0]->URI, "http://a.example/debian"); + EXPECT_EQ(recs[1]->URI, "http://b.example/debian"); + EXPECT_EQ(recs[1]->Dist, "testing"); +} + +TEST_F(RSourcesTest, Deb822CrlfSeparator) +{ + box.put("sources.list.d/crlf.sources", string(STANZA_A) + "\r\n" + STANZA_B); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + EXPECT_EQ(records(lst).size(), 2u); +} + +// To apt a line of only spaces continues the previous field, it does not end +// the stanza (pkgTagSection::Scan). We must agree, or the dialog would show a +// repository apt never loads. +TEST_F(RSourcesTest, Deb822WhitespaceOnlyLineIsNotASeparator) +{ + box.put("sources.list.d/ws.sources", string(STANZA_A) + " \n" + STANZA_B); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 1u); + EXPECT_EQ(recs[0]->URI, "http://b.example/debian"); +} + +TEST_F(RSourcesTest, Deb822EmptyAndCommentOnlyFiles) +{ + box.put("sources.list.d/empty.sources", ""); + box.put("sources.list.d/comments.sources", "# nothing here\n\n# still nothing\n"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + EXPECT_EQ(records(lst).size(), 0u); +} + +TEST_F(RSourcesTest, Deb822BadStanzasAreSkipped) +{ + box.put("sources.list.d/bad.sources", + string(STANZA_A) + "\n" + + "URIs: http://no-types.example/\nSuites: stable\n\n" + + "Types: deb foo\nURIs: http://unknown-type.example/\nSuites: stable\n"); + + SourcesList lst; + EXPECT_FALSE(lst.ReadSources()); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 1u); + EXPECT_EQ(recs[0]->URI, "http://a.example/debian"); +} + +TEST_F(RSourcesTest, SourcepartsMixesFormatsInSortedOrder) +{ + const string line = "deb http://deb.debian.org/debian bookworm main\n"; + box.put("sources.list.d/b.list", line); + box.put("sources.list.d/legacy.list", line); + box.put("sources.list.d/a.sources", STANZA_A); + box.put("sources.list.d/z.sources", STANZA_B); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + EXPECT_EQ(read_files(lst), + (vector{"a.sources", "b.list", "legacy.list", "z.sources"})); + auto recs = records(lst); + ASSERT_EQ(recs.size(), 4u); + EXPECT_EQ(recs[0]->Format, SourcesList::Deb822); + EXPECT_EQ(recs[1]->Format, SourcesList::OneLine); +} + +// Until there is a writer that edits stanzas in place, saving must not touch +// .sources files at all, whatever happened to their records in memory. +TEST_F(RSourcesTest, Deb822FilesAreNeverWritten) +{ + const string ubuntu = + "# See sources.list(5) for details\n" + "Types: deb\n" + "URIs: http://archive.ubuntu.com/ubuntu/\n" + "Suites: noble noble-updates noble-backports\n" + "Components: main universe restricted multiverse\n" + "Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n"; + box.put("sources.list.d/ubuntu.sources", ubuntu); + box.put("sources.list", "deb http://deb.debian.org/debian bookworm main\n"); + + SourcesList lst; + EXPECT_TRUE(lst.ReadSources()); + for (SourcesList::SourceRecord *rec : lst.SourceRecords) { + if (rec->Format != SourcesList::Deb822) + continue; + rec->Type |= SourcesList::Disabled; + rec->URI = "http://changed.example/"; + } + EXPECT_TRUE(lst.UpdateSources()); + + EXPECT_EQ(box.get("sources.list.d/ubuntu.sources"), ubuntu); + // the one-line file is still rewritten as before + EXPECT_NE(box.get("sources.list").find("deb http://deb.debian.org/debian/ bookworm main"), + string::npos); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + pkgInitConfig(*_config); + pkgInitSystem(*_config, _system); + return RUN_ALL_TESTS(); +}