diff --git a/common/meson.build b/common/meson.build index 67cd3881..7609d8bb 100644 --- a/common/meson.build +++ b/common/meson.build @@ -11,6 +11,7 @@ common_sources = files( 'rpmindexcopy.cc', 'raptoptions.cc', 'rsources.cc', + 'rsource_deb822.cc', 'rcacheactor.cc', 'rpackagelistactor.cc', 'rtagcollbuilder.cc', diff --git a/common/rpackagelister.cc b/common/rpackagelister.cc index 246a1ade..13f2b213 100644 --- a/common/rpackagelister.cc +++ b/common/rpackagelister.cc @@ -2211,11 +2211,7 @@ bool RPackageLister::xapianSearch(string searchString) bool RPackageLister::isMultiarchSystem() { -#ifdef WITH_APT_MULTIARCH_SUPPORT - return (APT::Configuration::getArchitectures().size() > 1); -#else - return false; -#endif + return _system->MultiArchSupported(); } // vim:ts=3:sw=3:et diff --git a/common/rsource_deb822.cc b/common/rsource_deb822.cc new file mode 100644 index 00000000..fdef6897 --- /dev/null +++ b/common/rsource_deb822.cc @@ -0,0 +1,357 @@ +/* rsource_deb822.cc - Deb822 format sources support + * + * Copyright (c) 2025 Synaptic development team + */ + +#include "rsource_deb822.h" +#include +#include +#include +#include +#include +#include +#include +#include "i18n.h" +#include +#include + +bool RDeb822Source::ParseDeb822File(const std::string& path, std::vector& entries) { + std::ifstream file(path); + if (!file.is_open()) { + return false; + } + std::string line; + std::map fields; + // Comment lines seen since the last stanza ended. deb822 has no comment + // syntax of its own, so apt treats '#' lines as belonging to the stanza + // that follows them -- which is also where WriteDeb822File emits them. + std::string pendingComment; + int stanza_count = 0; + while (std::getline(file, line)) { + // A whitespace-only line separates stanzas just like an empty one + // (deb822 / RFC 822). Testing line.empty() alone made " " count as + // content, so the following stanza's fields overwrote this one's via + // fields[key] = value and the earlier stanza was silently dropped. + // Also handles CRLF files, where the separator line is "\r". + if (line.find_first_not_of(" \t\r") == std::string::npos) { + if (!fields.empty()) { + Deb822Entry entry; + // Check required fields + if (fields.find("Types") == fields.end() || fields.find("URIs") == fields.end() || fields.find("Suites") == fields.end()) { + fields.clear(); + pendingComment.clear(); + continue; + } + entry.Types = fields["Types"]; + entry.URIs = fields["URIs"]; + entry.Suites = fields["Suites"]; + entry.Components = fields.count("Components") ? fields["Components"] : ""; + entry.SignedBy = fields.count("Signed-By") ? fields["Signed-By"] : ""; + entry.Architectures = fields.count("Architectures") ? fields["Architectures"] : ""; + entry.Languages = fields.count("Languages") ? fields["Languages"] : ""; + entry.Targets = fields.count("Targets") ? fields["Targets"] : ""; + // Handle Enabled/Disabled fields + if (fields.count("Enabled")) { + std::string enabled_val = fields["Enabled"]; + std::transform(enabled_val.begin(), enabled_val.end(), enabled_val.begin(), ::tolower); + entry.Enabled = (enabled_val == "yes" || enabled_val == "true" || enabled_val == "1"); + } else if (fields.count("Disabled")) { + std::string disabled_val = fields["Disabled"]; + std::transform(disabled_val.begin(), disabled_val.end(), disabled_val.begin(), ::tolower); + entry.Enabled = !(disabled_val == "yes" || disabled_val == "true" || disabled_val == "1"); + } else { + entry.Enabled = true; // Default to enabled + } + entry.Comment = pendingComment; + pendingComment.clear(); + entries.push_back(entry); + stanza_count++; + fields.clear(); + } + continue; + } + if (line[0] == '#') { + pendingComment += line; + pendingComment += '\n'; + continue; + } + size_t colon = line.find(':'); + if (colon == std::string::npos) { + continue; + } + std::string key = line.substr(0, colon); + std::string value = line.substr(colon + 1); + // Trim whitespace + key.erase(0, key.find_first_not_of(" \t")); + key.erase(key.find_last_not_of(" \t") + 1); + value.erase(0, value.find_first_not_of(" \t")); + value.erase(value.find_last_not_of(" \t") + 1); + fields[key] = value; + } + // Handle last stanza if file does not end with blank line + if (!fields.empty()) { + Deb822Entry entry; + if (fields.find("Types") == fields.end() || fields.find("URIs") == fields.end() || fields.find("Suites") == fields.end()) { + // No debug print, just skip + } else { + entry.Types = fields["Types"]; + entry.URIs = fields["URIs"]; + entry.Suites = fields["Suites"]; + entry.Components = fields.count("Components") ? fields["Components"] : ""; + entry.SignedBy = fields.count("Signed-By") ? fields["Signed-By"] : ""; + entry.Architectures = fields.count("Architectures") ? fields["Architectures"] : ""; + entry.Languages = fields.count("Languages") ? fields["Languages"] : ""; + entry.Targets = fields.count("Targets") ? fields["Targets"] : ""; + // Handle Enabled/Disabled fields + if (fields.count("Enabled")) { + std::string enabled_val = fields["Enabled"]; + std::transform(enabled_val.begin(), enabled_val.end(), enabled_val.begin(), ::tolower); + entry.Enabled = (enabled_val == "yes" || enabled_val == "true" || enabled_val == "1"); + } else if (fields.count("Disabled")) { + std::string disabled_val = fields["Disabled"]; + std::transform(disabled_val.begin(), disabled_val.end(), disabled_val.begin(), ::tolower); + entry.Enabled = !(disabled_val == "yes" || disabled_val == "true" || disabled_val == "1"); + } else { + entry.Enabled = true; // Default to enabled + } + entry.Comment = pendingComment; + pendingComment.clear(); + entries.push_back(entry); + stanza_count++; + } + } + return true; +} + +bool RDeb822Source::WriteDeb822File(const std::string& path, const std::vector& entries) { + std::ofstream file(path); + if (!file) { + return _error->Error(_("Cannot write to %s"), path.c_str()); + } + + for (size_t i = 0; i < entries.size(); ++i) { + const auto& entry = entries[i]; + + // Write preserved comments before stanza + if (!entry.Comment.empty()) { + file << entry.Comment; + if (entry.Comment.back() != '\n') file << "\n"; + } + + // Write Enabled field + if (entry.Enabled) { + file << "Enabled: yes" << std::endl; + } else { + file << "Enabled: no" << std::endl; + } + + file << "Types: " << entry.Types << std::endl; + file << "URIs: " << entry.URIs << std::endl; + file << "Suites: " << entry.Suites << std::endl; + + if (!entry.Components.empty()) { + file << "Components: " << entry.Components << std::endl; + } + if (!entry.SignedBy.empty()) { + file << "Signed-By: " << entry.SignedBy << std::endl; + } + if (!entry.Architectures.empty()) { + file << "Architectures: " << entry.Architectures << std::endl; + } + if (!entry.Languages.empty()) { + file << "Languages: " << entry.Languages << std::endl; + } + if (!entry.Targets.empty()) { + file << "Targets: " << entry.Targets << std::endl; + } + + // Only add empty line between entries, not after the last one + if (i < entries.size() - 1) { + file << std::endl; + } + } + + return true; +} + +bool RDeb822Source::ConvertToSourceRecord(const Deb822Entry& entry, SourcesList::SourceRecord& record) { + // Parse types + bool has_deb = false; + bool has_deb_src = false; + std::istringstream typeStream(entry.Types); + std::string type; + while (std::getline(typeStream, type, ' ')) { + TrimWhitespace(type); + if (type == "deb") has_deb = true; + if (type == "deb-src") has_deb_src = true; + } + + record.Type = 0; + if (has_deb) record.Type |= SourcesList::Deb; + if (has_deb_src) record.Type |= SourcesList::DebSrc; + if (!entry.Enabled) record.Type |= SourcesList::Disabled; + + // Parse URIs + std::istringstream uriStream(entry.URIs); + std::string uri; + while (std::getline(uriStream, uri, ' ')) { + TrimWhitespace(uri); + if (!uri.empty()) { + record.URI = uri; + break; + } + } + + // Parse suites + std::istringstream suiteStream(entry.Suites); + std::string suite; + while (std::getline(suiteStream, suite, ' ')) { + TrimWhitespace(suite); + if (!suite.empty()) { + record.Dist = suite; + break; + } + } + + // Parse components + std::istringstream compStream(entry.Components); + std::string comp; + std::vector sections; + while (std::getline(compStream, comp, ' ')) { + TrimWhitespace(comp); + if (!comp.empty()) { + sections.push_back(comp); + } + } + + // Set sections + if (!sections.empty()) { + record.NumSections = sections.size(); + record.Sections = new std::string[record.NumSections]; + for (unsigned short i = 0; i < record.NumSections; i++) { + record.Sections[i] = sections[i]; + } + } + + // Preserve extra fields in Comment + std::stringstream commentStream; + if (!entry.SignedBy.empty()) { + commentStream << "Signed-By: " << entry.SignedBy << std::endl; + } + if (!entry.Architectures.empty()) { + commentStream << "Architectures: " << entry.Architectures << std::endl; + } + if (!entry.Languages.empty()) { + commentStream << "Languages: " << entry.Languages << std::endl; + } + if (!entry.Targets.empty()) { + commentStream << "Targets: " << entry.Targets << std::endl; + } + record.Comment = commentStream.str(); + + return true; +} + +bool RDeb822Source::ConvertFromSourceRecord(const SourcesList::SourceRecord& record, Deb822Entry& entry) { + // Set types + std::stringstream typeStream; + if (record.Type & SourcesList::Deb) { + typeStream << "deb "; + } + if (record.Type & SourcesList::DebSrc) { + typeStream << "deb-src "; + } + entry.Types = typeStream.str(); + TrimWhitespace(entry.Types); + + // Set URI + entry.URIs = record.URI; + + // Set suite + entry.Suites = record.Dist; + + // Set components + std::stringstream compStream; + for (unsigned short i = 0; i < record.NumSections; i++) { + compStream << record.Sections[i] << " "; + } + entry.Components = compStream.str(); + TrimWhitespace(entry.Components); + + // Set enabled state + entry.Enabled = !(record.Type & SourcesList::Disabled); + + // Parse extra fields from Comment + if (!record.Comment.empty()) { + std::istringstream iss(record.Comment); + std::string line; + while (std::getline(iss, line)) { + size_t colon = line.find(":"); + if (colon == std::string::npos) continue; + std::string key = line.substr(0, colon); + std::string value = line.substr(colon + 1); + TrimWhitespace(key); + TrimWhitespace(value); + if (key == "Signed-By") entry.SignedBy = value; + else if (key == "Architectures") entry.Architectures = value; + else if (key == "Languages") entry.Languages = value; + else if (key == "Targets") entry.Targets = value; + } + } + + return true; +} + +void RDeb822Source::TrimWhitespace(std::string& str) { + const std::string whitespace = " \t\r\n"; + size_t start = str.find_first_not_of(whitespace); + if (start == std::string::npos) { + str.clear(); + return; + } + size_t end = str.find_last_not_of(whitespace); + str = str.substr(start, end - start + 1); +} + +bool RDeb822Source::ParseStanza(std::ifstream& file, std::map& fields) { + std::string line; + bool inStanza = false; + + while (std::getline(file, line)) { + // Skip empty lines + if (line.empty()) { + if (inStanza) { + return true; + } + continue; + } + + // Skip comments + if (line[0] == '#') { + continue; + } + + // Check for stanza start + if (line.find("Types:") != std::string::npos) { + inStanza = true; + } + + if (inStanza) { + size_t colonPos = line.find(':'); + if (colonPos != std::string::npos) { + std::string key = line.substr(0, colonPos); + std::string value = line.substr(colonPos + 1); + + // Trim whitespace + key.erase(0, key.find_first_not_of(" \t")); + key.erase(key.find_last_not_of(" \t") + 1); + value.erase(0, value.find_first_not_of(" \t")); + value.erase(value.find_last_not_of(" \t") + 1); + + fields[key] = value; + } + } + } + + return !fields.empty(); +} \ No newline at end of file diff --git a/common/rsource_deb822.h b/common/rsource_deb822.h new file mode 100644 index 00000000..3eaffed2 --- /dev/null +++ b/common/rsource_deb822.h @@ -0,0 +1,48 @@ +/* rsource_deb822.h - Deb822 format sources support + * + * Copyright (c) 2025 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. + */ + +#ifndef RSOURCE_DEB822_H +#define RSOURCE_DEB822_H + +#include +#include +#include +#include +#include +#include +#include +#include "rsources.h" + +class RDeb822Source { +public: + struct Deb822Entry { + std::string Types; // Space-separated list of types + std::string URIs; // Space-separated list of URIs + std::string Suites; // Space-separated list of suites + std::string Components; // Space-separated list of components + std::string SignedBy; // Path to keyring file + std::string Architectures; // Space-separated list of architectures + std::string Languages; // Space-separated list of languages + std::string Targets; // Space-separated list of targets + bool Enabled; // Whether the source is enabled + std::string Comment; // Any comments associated with this entry + }; + + static bool ParseDeb822File(const std::string& path, std::vector& entries); + static bool WriteDeb822File(const std::string& path, const std::vector& entries); + static bool ConvertToSourceRecord(const Deb822Entry& entry, SourcesList::SourceRecord& record); + static bool ConvertFromSourceRecord(const SourcesList::SourceRecord& record, Deb822Entry& entry); + static void TrimWhitespace(std::string& str); + +private: + static bool ParseStanza(std::ifstream& file, std::map& fields); +}; + +#endif // RSOURCE_DEB822_H \ No newline at end of file diff --git a/common/rsources.cc b/common/rsources.cc index f2d574d6..cd441919 100644 --- a/common/rsources.cc +++ b/common/rsources.cc @@ -28,6 +28,8 @@ #include "rsources.h" #include "i18n.h" +#include "rsource_deb822.h" +#include #include #include @@ -89,6 +91,7 @@ bool SourcesList::ReadSourcePart(string listpath) ifs.getline(buf, sizeof(buf)); rec.SourceFile = listpath; + rec.PreserveOriginalURI = true; // Preserve original URI format when reading while (isspace(*p)) p++; if (*p == '#') { @@ -98,15 +101,10 @@ bool SourcesList::ReadSourcePart(string listpath) p++; } - if (*p == '\r' || *p == '\n' || *p == 0) { - rec.Type = Comment; - rec.Comment = p; - - AddSourceNode(rec); - continue; - } - + // Try to parse as a source line after '#'. If not valid, treat as comment. bool Failed = true; + string orig_buf = buf; + const char* orig_p = p; if (ParseQuoteWord(p, Type) == true && rec.SetType(Type) == true && ParseQuoteWord(p, VURI) == true) { if (VURI[0] == '[') { @@ -121,10 +119,10 @@ bool SourcesList::ReadSourcePart(string listpath) } if (Failed == true) { + // If this was a disabled line (started with '#'), but not a valid source, treat as comment if (rec.Type == Disabled) { - // treat as a comment field rec.Type = Comment; - rec.Comment = buf; + rec.Comment = orig_buf; } else { // syntax error on line rec.Type = Comment; @@ -133,6 +131,8 @@ bool SourcesList::ReadSourcePart(string listpath) record_ok = false; // return _error->Error(_("Syntax error in line %s"), buf); } + AddSourceNode(rec); + continue; } #ifndef HAVE_RPM // check for absolute dist @@ -229,12 +229,23 @@ bool SourcesList::ReadSources() bool Res = true; + // Deb822 .sources and one-line .list files both live in apt's + // Dir::Etc::sourceparts, so a single scan of it covers both. + // + // This used to also scan "Dir::Etc::sourcelist.d" first. That is not an apt + // configuration key: FindDir returns "/" for it, which the old code then + // rewrote to a hardcoded /etc/apt/sources.list.d/ -- the very directory + // sourceparts already points at on a normal system. Every .sources file was + // therefore read twice and every repository appeared twice in the dialog. string Parts = _config->FindDir("Dir::Etc::sourceparts"); - if (FileExists(Parts) == true) + if (FileExists(Parts) == true) { + Res &= ReadDeb822SourceDir(Parts); Res &= ReadSourceDir(Parts); + } string Main = _config->FindFile("Dir::Etc::sourcelist"); - if (FileExists(Main) == true) + if (FileExists(Main) == true) { Res &= ReadSourcePart(Main); + } return Res; } @@ -300,52 +311,109 @@ void SourcesList::SwapSources(SourceRecord *&rec_one, SourceRecord *&rec_two) bool SourcesList::UpdateSources() { - list filenames; - for (list::iterator it = SourceRecords.begin(); - it != SourceRecords.end(); - it++) { - if ((*it)->SourceFile == "") - continue; - filenames.push_front((*it)->SourceFile); + // Group sources by their source file + map> sourcesByFile; + for (list::const_iterator it = SourceRecords.begin(); + it != SourceRecords.end(); it++) { + sourcesByFile[(*it)->SourceFile].push_back(*it); } - filenames.sort(); - filenames.unique(); - for (list::iterator fi = filenames.begin(); fi != filenames.end(); - fi++) { - ofstream ofs((*fi).c_str(), ios::out); - if (!ofs != 0) - return false; + // Write each source file + for (const auto& pair : sourcesByFile) { + const string& sourcePath = pair.first; + const vector& records = pair.second; - for (list::iterator it = SourceRecords.begin(); - it != SourceRecords.end(); - it++) { - if ((*fi) != (*it)->SourceFile) - continue; - string S; - if (((*it)->Type & Comment) != 0) { - S = (*it)->Comment; - } else if ((*it)->URI.empty() || (*it)->Dist.empty()) { - continue; - } else { - if (((*it)->Type & Disabled) != 0) - S = "# "; + // Skip empty source files + if (records.empty()) { + continue; + } - S += (*it)->GetType() + " "; + // Trim trailing blank/comment lines (empty or whitespace-only comments) + std::vector trimmed_records = records; + while (!trimmed_records.empty()) { + SourceRecord* rec = trimmed_records.back(); + bool is_blank_comment = (rec->Type == Comment) && (rec->Comment.find_first_not_of(" \t\r\n") == std::string::npos); + if (is_blank_comment) { + trimmed_records.pop_back(); + } else { + break; + } + } - if ((*it)->VendorID.empty() == false) - S += "[" + (*it)->VendorID + "] "; + // Check if this is a Deb822 format file (only .sources files) + bool isDeb822 = false; + if (sourcePath.size() > 8 && sourcePath.substr(sourcePath.size() - 8) == ".sources") { + isDeb822 = true; + } - S += (*it)->URI + " "; - S += (*it)->Dist + " "; + // Open the appropriate file for writing + ofstream out(sourcePath.c_str(), ios::out); + if (!out) { + return _error->Error(_("Error writing to %s"), sourcePath.c_str()); + } - for (unsigned int J = 0; J < (*it)->NumSections; J++) - S += (*it)->Sections[J] + " "; + if (isDeb822) { + // Write Deb822 format + vector entries; + for (const auto& record : trimmed_records) { + RDeb822Source::Deb822Entry entry; + if (!RDeb822Source::ConvertFromSourceRecord(*record, entry)) { + return _error->Error(_("Failed to convert source record to Deb822 format")); + } + entries.push_back(entry); + } + if (!RDeb822Source::WriteDeb822File(sourcePath, entries)) { + return false; } - ofs << S << endl; + } else { + // Write classic format (deb lines) + for (const auto& record : trimmed_records) { + if (record->Type == Comment) { + out << record->Comment << endl; + } else { + // Write as a standard deb/deb-src line, comment if disabled + string line; + if (record->Type & Disabled) { + line += "# "; + } + if (record->Type & Deb) { + line += "deb "; + } else if (record->Type & DebSrc) { + line += "deb-src "; + } else if (record->Type & Rpm) { + line += "rpm "; + } else if (record->Type & RpmSrc) { + line += "rpm-src "; + } else if (record->Type & RpmDir) { + line += "rpm-dir "; + } else if (record->Type & RpmSrcDir) { + line += "rpm-src-dir "; + } else if (record->Type & Repomd) { + line += "repomd "; + } else if (record->Type & RepomdSrc) { + line += "repomd-src "; + } else { + line += "deb "; // fallback + } + line += record->URI + " " + record->Dist; + for (unsigned int J = 0; J < record->NumSections; J++) { + line += " " + record->Sections[J]; + } + // Trim trailing space + if (!line.empty() && line[line.length()-1] == ' ') { + line.erase(line.length()-1); + } + out << line << endl; + } + } + } + + out.close(); + if (!out) { + return _error->Error(_("Error writing to %s"), sourcePath.c_str()); } - ofs.close(); } + return true; } @@ -373,6 +441,19 @@ bool SourcesList::SourceRecord::SetType(string S) return true; } +string SourcesList::SourceRecord::GetTypeLabel() +{ + const bool isDeb = (Type & Deb) != 0; + const bool isDebSrc = (Type & DebSrc) != 0; + if (isDeb && isDebSrc) + return "deb, deb-src"; + if (isDeb) + return "deb"; + if (isDebSrc) + return "deb-src"; + return GetType(); +} + string SourcesList::SourceRecord::GetType() { if ((Type & Deb) != 0) @@ -406,8 +487,8 @@ bool SourcesList::SourceRecord::SetURI(string S) S = SubstVar(S, "$(VERSION)", _config->Find("APT::DistroVersion")); URI = S; - // append a / to the end if one is not already there - if (URI[URI.size() - 1] != '/') + // Only append / if we're not preserving the original format + if (!PreserveOriginalURI && URI[URI.size() - 1] != '/') URI += '/'; return true; @@ -428,6 +509,7 @@ SourcesList::SourceRecord &SourcesList::SourceRecord::operator=( NumSections = rhs.NumSections; Comment = rhs.Comment; SourceFile = rhs.SourceFile; + PreserveOriginalURI = rhs.PreserveOriginalURI; return *this; } @@ -532,38 +614,36 @@ void SourcesList::RemoveVendor(VendorRecord *&rec) ostream &operator<<(ostream &os, const SourcesList::SourceRecord &rec) { - os << "Type: "; - if ((rec.Type & SourcesList::Comment) != 0) - os << "Comment "; - if ((rec.Type & SourcesList::Disabled) != 0) - os << "Disabled "; - if ((rec.Type & SourcesList::Deb) != 0) - os << "Deb"; - if ((rec.Type & SourcesList::DebSrc) != 0) - os << "DebSrc"; - if ((rec.Type & SourcesList::Rpm) != 0) - os << "Rpm"; - if ((rec.Type & SourcesList::RpmSrc) != 0) - os << "RpmSrc"; - if ((rec.Type & SourcesList::RpmDir) != 0) - os << "RpmDir"; - if ((rec.Type & SourcesList::RpmSrcDir) != 0) - os << "RpmSrcDir"; - if ((rec.Type & SourcesList::Repomd) != 0) - os << "Repomd"; - if ((rec.Type & SourcesList::RepomdSrc) != 0) - os << "RepomdSrc"; - os << endl; - os << "SourceFile: " << rec.SourceFile << endl; - os << "VendorID: " << rec.VendorID << endl; - os << "URI: " << rec.URI << endl; - os << "Dist: " << rec.Dist << endl; - os << "Section(s):" << endl; -#if 0 + if (rec.Type == SourcesList::Comment) { + os << rec.Comment << endl; + return os; + } + if (rec.Type & SourcesList::Disabled) { + os << "# "; + } + if (rec.Type & SourcesList::Deb) { + os << "deb "; + } else if (rec.Type & SourcesList::DebSrc) { + os << "deb-src "; + } else if (rec.Type & SourcesList::Rpm) { + os << "rpm "; + } else if (rec.Type & SourcesList::RpmSrc) { + os << "rpm-src "; + } else if (rec.Type & SourcesList::RpmDir) { + os << "rpm-dir "; + } else if (rec.Type & SourcesList::RpmSrcDir) { + os << "rpm-src-dir "; + } else if (rec.Type & SourcesList::Repomd) { + os << "repomd "; + } else if (rec.Type & SourcesList::RepomdSrc) { + os << "repomd-src "; + } else { + os << "deb "; // fallback + } + os << rec.URI << " " << rec.Dist; for (unsigned int J = 0; J < rec.NumSections; J++) { - cout << "\t" << rec.Sections[J] << endl; + os << " " << rec.Sections[J]; } -#endif os << endl; return os; } @@ -576,4 +656,79 @@ ostream &operator<<(ostream &os, const SourcesList::VendorRecord &rec) return os; } +bool SourcesList::ReadDeb822SourcePart(string listpath) { + vector entries; + if (!RDeb822Source::ParseDeb822File(listpath, entries)) { + return false; + } + + for (const auto& entry : entries) { + SourceRecord rec; + rec.SourceFile = listpath; + + if (!RDeb822Source::ConvertToSourceRecord(entry, rec)) { + return _error->Error(_("Failed to convert Deb822 entry in %s"), listpath.c_str()); + } + + rec.Type |= Deb822; // Mark as Deb822 format + AddSourceNode(rec); + } + + return true; +} + +bool SourcesList::ReadDeb822SourceDir(string Dir) { + 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; + + // Only look at files ending in .sources. Comparing at + // d_name + strlen(d_name) - 8 walks in front of the buffer for any + // name shorter than ".sources" itself, so measure the length first. + const size_t NameLen = strlen(Ent->d_name); + const size_t SuffixLen = 8; // strlen(".sources") + if (NameLen < SuffixLen || + strcmp(Ent->d_name + NameLen - SuffixLen, ".sources") != 0) + continue; + + // 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); + } + closedir(D); + + sort(List.begin(), List.end()); + + // Read the files + for (vector::const_iterator I = List.begin(); I != List.end(); I++) { + if (ReadDeb822SourcePart(*I) == false) + return false; + } + return true; +} + +bool SourcesList::WriteDeb822Source(SourceRecord *record, string path) { + if (!record || !(record->Type & Deb822)) { + return _error->Error(_("Not a Deb822 format source")); + } + + vector entries; + RDeb822Source::Deb822Entry entry; + + if (!RDeb822Source::ConvertFromSourceRecord(*record, entry)) { + return _error->Error(_("Failed to convert source record to Deb822 format")); + } + + entries.push_back(entry); + return RDeb822Source::WriteDeb822File(path, entries); +} + // vim:sts=4:sw=4 diff --git a/common/rsources.h b/common/rsources.h index ccc181e5..aa778785 100644 --- a/common/rsources.h +++ b/common/rsources.h @@ -44,7 +44,8 @@ class SourcesList RpmDir = 1 << 6, RpmSrcDir = 1 << 7, Repomd = 1 << 8, - RepomdSrc = 1 << 9 + RepomdSrc = 1 << 9, + Deb822 = 1 << 10 // New type for Deb822 format }; struct SourceRecord @@ -57,12 +58,20 @@ class SourcesList unsigned short NumSections; std::string Comment; std::string SourceFile; + bool PreserveOriginalURI; // Flag to preserve original URI format bool SetType(std::string); std::string GetType(); + // The type as shown to the user. GetType() returns the first matching + // type only, which cannot express a deb822 stanza declaring both + // ("Types: deb deb-src"); this returns "deb, deb-src" for that case. + // The repository dialog stores this in its type column and parses it + // back when the row is edited, so the two must agree or editing such a + // row drops a type. + std::string GetTypeLabel(); bool SetURI(std::string); - SourceRecord() : Type(0), Sections(0), NumSections(0) + SourceRecord() : Type(0), Sections(0), NumSections(0), PreserveOriginalURI(false) {} ~SourceRecord() { @@ -102,6 +111,11 @@ class SourcesList bool ReadSources(); bool UpdateSources(); + // New methods for Deb822 support + bool ReadDeb822SourcePart(std::string listpath); + bool ReadDeb822SourceDir(std::string Dir); + bool WriteDeb822Source(SourceRecord *record, std::string path); + VendorRecord *AddVendor(std::string VendorID, std::string FingerPrint, std::string Description); diff --git a/gtk/rgrepositorywin.cc b/gtk/rgrepositorywin.cc index ca45e8a4..07901b07 100644 --- a/gtk/rgrepositorywin.cc +++ b/gtk/rgrepositorywin.cc @@ -31,6 +31,7 @@ #include "rggtkbuilderwindow.h" #include "rguserdialog.h" #include "rgutils.h" +#include "rsource_deb822.h" #include "ruserdialog.h" #include @@ -43,7 +44,9 @@ #include #include #include +#include #include +#include class RGWindow; @@ -143,6 +146,7 @@ RGRepositoryEditor::RGRepositoryEditor(RGWindow *parent) _userDialog = new RGUserDialog(_win); _applied = false; _lastIter = NULL; + _config = new Configuration(); setTitle(_("Repositories")); gtk_window_set_modal(GTK_WINDOW(_win), TRUE); @@ -403,6 +407,7 @@ RGRepositoryEditor::~RGRepositoryEditor() { // gtk_widget_destroy(_win); delete _userDialog; + delete _config; } @@ -417,7 +422,6 @@ bool RGRepositoryEditor::Run() _savedList.ReadSources(); if (_lst.ReadVendors() == false) { - _error->Error(_("Cannot read vendors.list file")); _userDialog->showErrors(); return false; } @@ -428,6 +432,7 @@ bool RGRepositoryEditor::Run() it++) { if ((*it)->Type & SourcesList::Comment) continue; + string Sections; for (unsigned int J = 0; J < (*it)->NumSections; J++) { Sections += (*it)->Sections[J]; @@ -440,7 +445,7 @@ bool RGRepositoryEditor::Run() STATUS_COLUMN, !((*it)->Type & SourcesList::Disabled), TYPE_COLUMN, - utf8((*it)->GetType().c_str()), + utf8((*it)->GetTypeLabel().c_str()), VENDOR_COLUMN, utf8((*it)->VendorID.c_str()), URI_COLUMN, @@ -454,6 +459,7 @@ bool RGRepositoryEditor::Run() DISABLED_COLOR_COLUMN, (*it)->Type & SourcesList::Disabled ? &_gray : NULL, -1); + } @@ -575,6 +581,10 @@ void RGRepositoryEditor::doEdit() gtk_tree_model_get(model, _lastIter, RECORD_COLUMN, &rec, -1); assert(rec); + // --- PATCH: Preserve Deb822 flag --- + bool was_deb822 = (rec->Type & SourcesList::Deb822) != 0; + // --- END PATCH --- + rec->Type = 0; gboolean status; gtk_tree_model_get( @@ -582,40 +592,21 @@ void RGRepositoryEditor::doEdit() if (!status) rec->Type |= SourcesList::Disabled; - GtkTreeIter item; - int type; - gtk_combo_box_get_active_iter(GTK_COMBO_BOX(_optType), &item); - gtk_tree_model_get(GTK_TREE_MODEL(_optTypeMenu), &item, 1, &type, -1); - - switch (type) { - case ITEM_TYPE_DEB: - rec->Type |= SourcesList::Deb; - break; - case ITEM_TYPE_DEBSRC: - rec->Type |= SourcesList::DebSrc; - break; - case ITEM_TYPE_RPM: - rec->Type |= SourcesList::Rpm; - break; - case ITEM_TYPE_RPMSRC: - rec->Type |= SourcesList::RpmSrc; - break; - case ITEM_TYPE_RPMDIR: - rec->Type |= SourcesList::RpmDir; - break; - case ITEM_TYPE_RPMSRCDIR: - rec->Type |= SourcesList::RpmSrcDir; - break; - case ITEM_TYPE_REPOMD: - rec->Type |= SourcesList::Repomd; - break; - case ITEM_TYPE_REPOMDSRC: - rec->Type |= SourcesList::RepomdSrc; - break; - default: - _userDialog->error(_("Unknown source type")); - return; - } + // --- NEW: For Deb822, allow both deb and deb-src to be set --- + // Parse the type_display string from the TYPE_COLUMN + gchar* type_str = NULL; + gtk_tree_model_get(GTK_TREE_MODEL(_sourcesListStore), _lastIter, TYPE_COLUMN, &type_str, -1); + std::string type_val = type_str ? type_str : ""; + g_free(type_str); + bool set_deb = (type_val.find("deb") != std::string::npos); + bool set_debsrc = (type_val.find("deb-src") != std::string::npos); + if (set_deb) rec->Type |= SourcesList::Deb; + if (set_debsrc) rec->Type |= SourcesList::DebSrc; + // --- END NEW --- + + // --- PATCH: Restore Deb822 flag if it was set --- + if (was_deb822) rec->Type |= SourcesList::Deb822; + // --- END PATCH --- #if 0 // PORTME, no vendor id support right now gtk_combo_box_get_active_iter(GTK_COMBO_BOX(_optVendor), &item); @@ -631,15 +622,26 @@ void RGRepositoryEditor::doEdit() rec->NumSections = 0; const char *Section = gtk_entry_get_text(GTK_ENTRY(_entrySect)); - if (Section != 0 && Section[0] != 0) - rec->NumSections++; - - rec->Sections = new string[rec->NumSections]; - rec->NumSections = 0; - Section = gtk_entry_get_text(GTK_ENTRY(_entrySect)); + if (Section != 0 && Section[0] != 0) { + // Parse sections properly - split by spaces + string sectionsStr = Section; + vector sections; + stringstream ss(sectionsStr); + string section; + + while (ss >> section) { + sections.push_back(section); + } - if (Section != 0 && Section[0] != 0) - rec->Sections[rec->NumSections++] = Section; + rec->NumSections = sections.size(); + rec->Sections = new string[rec->NumSections]; + for (unsigned int I = 0; I < rec->NumSections; I++) { + rec->Sections[I] = sections[I]; + } + } else { + rec->Sections = new string[0]; + rec->NumSections = 0; + } string Sect; for (unsigned int I = 0; I < rec->NumSections; I++) { @@ -653,7 +655,7 @@ void RGRepositoryEditor::doEdit() STATUS_COLUMN, !(rec->Type & SourcesList::Disabled), TYPE_COLUMN, - utf8(rec->GetType().c_str()), + utf8(rec->GetTypeLabel().c_str()), VENDOR_COLUMN, utf8(rec->VendorID.c_str()), URI_COLUMN, @@ -845,3 +847,54 @@ void RGRepositoryEditor::DoUpDown(GtkWidget *self, gpointer data) else me->_lst.SwapSources(rec, rec_p); } + +bool RGRepositoryEditor::ConvertToDeb822() { + GtkWidget *dialog = gtk_message_dialog_new(GTK_WINDOW(_win), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + GTK_MESSAGE_QUESTION, + GTK_BUTTONS_YES_NO, + _("Convert to Deb822 format?")); + + gtk_message_dialog_format_secondary_text(GTK_MESSAGE_DIALOG(dialog), + _("This will convert your sources to the new Deb822 format.\n" + "The conversion will be done in-place and cannot be undone.\n\n" + "Do you want to proceed?")); + + gint result = gtk_dialog_run(GTK_DIALOG(dialog)); + gtk_widget_destroy(dialog); + + if (result != GTK_RESPONSE_YES) { + return false; +} + + // Convert each source record to Deb822 format + for (SourcesListIter I = _lst.SourceRecords.begin(); I != _lst.SourceRecords.end(); I++) { + SourcesList::SourceRecord *rec = *I; + if (rec == NULL) continue; + + // Create Deb822 entry + RDeb822Source::Deb822Entry entry; + if (!RDeb822Source::ConvertFromSourceRecord(*rec, entry)) { + _userDialog->error(_("Failed to convert source record to Deb822 format")); + return false; + } + + // Update the source record + if (!RDeb822Source::ConvertToSourceRecord(entry, *rec)) { + _userDialog->error(_("Failed to update source record with Deb822 format")); + return false; + } + } + + return true; +} + +void RGRepositoryEditor::SaveClicked() { + // Remove auto-conversion to Deb822. Only update sources. + if (!_lst.UpdateSources()) { + _userDialog->error(_("Failed to update sources list")); + return; + } + + _dirty = false; +} diff --git a/gtk/rgrepositorywin.h b/gtk/rgrepositorywin.h index 05da1965..0c2f0147 100644 --- a/gtk/rgrepositorywin.h +++ b/gtk/rgrepositorywin.h @@ -28,8 +28,10 @@ #include "config.h" // IWYU pragma: associated #include "rggtkbuilderwindow.h" +#include "rguserdialog.h" #include "rsources.h" +#include #include #include #include @@ -73,6 +75,9 @@ class RGRepositoryEditor : RGGtkBuilderWindow bool _dirty; const GdkRGBA _gray = {0xAA00, 0xAA00, 0xAA00, 1.0}; + // Configuration + Configuration *_config; + void UpdateVendorMenu(); int VendorMenuIndex(std::string VendorID); @@ -93,10 +98,21 @@ class RGRepositoryEditor : RGGtkBuilderWindow // get values void doEdit(); - public: RGRepositoryEditor(RGWindow *parent); ~RGRepositoryEditor(); bool Run(); + + // Deb822 support + bool ConvertToDeb822(); + void SaveClicked(); +}; + +class RGRepositoryWin { +public: + // ... existing declarations ... + +private: + // ... existing private members ... }; diff --git a/po/POTFILES.in b/po/POTFILES.in index 86fb9ab5..21384c7b 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -17,6 +17,7 @@ common/rpmindexcopy.cc common/rpackageview.h common/rpackageview.cc common/rsources.cc +common/rsource_deb822.cc gtk/gsynaptic.cc gtk/rgcdscanner.cc gtk/rgcacheprogress.cc diff --git a/tests/meson.build b/tests/meson.build index 5b488923..b1e870f8 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -45,6 +45,56 @@ test_gtkpkglist = executable( link_with: libsynaptic, ) +test_deb822_separator = executable( + 'test_deb822_separator', + 'test_deb822_separator.cc', + cpp_args: gtk_cpp_args + rpm_compile_args + ['-O0', '-g3'], + dependencies: test_common_deps, + include_directories: [root_inc, common_inc, gtk_inc], + link_with: libsynaptic, +) + +test_deb822_roundtrip = executable( + 'test_deb822_roundtrip', + 'test_deb822_roundtrip.cc', + cpp_args: gtk_cpp_args + rpm_compile_args + ['-O0', '-g3'], + dependencies: test_common_deps, + include_directories: [root_inc, common_inc, gtk_inc], + link_with: libsynaptic, +) + +test_deb822_dialog = executable( + 'test_deb822_dialog', + 'test_deb822_dialog.cc', + cpp_args: gtk_cpp_args + rpm_compile_args + ['-O0', '-g3'], + dependencies: test_common_deps, + include_directories: [root_inc, common_inc, gtk_inc], + link_with: libsynaptic, +) + +test_deb822_sourceparts = executable( + 'test_deb822_sourceparts', + 'test_deb822_sourceparts.cc', + cpp_args: gtk_cpp_args + rpm_compile_args + ['-O0', '-g3'], + dependencies: test_common_deps, + include_directories: [root_inc, common_inc, gtk_inc], + link_with: libsynaptic, +) + +test_deb822_types_roundtrip = executable( + 'test_deb822_types_roundtrip', + 'test_deb822_types_roundtrip.cc', + cpp_args: gtk_cpp_args + rpm_compile_args + ['-O0', '-g3'], + dependencies: test_common_deps, + include_directories: [root_inc, common_inc, gtk_inc], + link_with: libsynaptic, +) + test('test_rpackage', test_rpackage, env: test_env) test('test_rpackagefilter', test_rpackagefilter, env: test_env) test('test_rpackageview', test_rpackageview, env: test_env) +test('test_deb822_separator', test_deb822_separator, env: test_env) +test('test_deb822_roundtrip', test_deb822_roundtrip, env: test_env) +test('test_deb822_dialog', test_deb822_dialog, env: test_env) +test('test_deb822_sourceparts', test_deb822_sourceparts, env: test_env) +test('test_deb822_types_roundtrip', test_deb822_types_roundtrip, env: test_env) diff --git a/tests/test_deb822_dialog.cc b/tests/test_deb822_dialog.cc new file mode 100644 index 00000000..f34b9246 --- /dev/null +++ b/tests/test_deb822_dialog.cc @@ -0,0 +1,118 @@ +// Drives the real repository-dialog population path headlessly. +// +// mvo5's blocking report on this feature was functional -- "I only get an +// empty window when I open the repository dialog" -- and that path was the +// one thing never exercised. This walks the same steps RGRepositoryEditor +// does when it fills the list: read the sources, then build the display +// string for each record exactly as the dialog does. +// +// It deliberately does NOT construct the GTK window (that needs the full +// builder resources and a real display); it exercises the data that decides +// whether the window comes up populated or empty, which is what the report +// is about. + +#include "config.h" // IWYU pragma: associated + +#include "rsources.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +static int failures = 0; + +// The exact type-display logic from rgrepositorywin.cc, so a Deb822 stanza +// carrying both types is shown as "deb, deb-src" rather than one of them. +static string type_display(SourcesList::SourceRecord *rec) +{ + bool is_deb = (rec->Type & SourcesList::Deb) != 0; + bool is_debsrc = (rec->Type & SourcesList::DebSrc) != 0; + if (is_deb && is_debsrc) + return "deb, deb-src"; + if (is_deb) + return "deb"; + if (is_debsrc) + return "deb-src"; + return rec->GetType(); +} + +// Counts the rows the dialog would append, applying the same Comment skip. +static unsigned rows_for(const string &body, const string &name) +{ + string path = string(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp") + + "/synaptic-dlg-" + name + ".sources"; + { + ofstream out(path.c_str(), ios::binary); + out << body; + } + + SourcesList lst; + lst.ReadDeb822SourcePart(path); + + unsigned rows = 0; + for (list::const_iterator it = + lst.SourceRecords.begin(); + it != lst.SourceRecords.end(); it++) { + if ((*it)->Type & SourcesList::Comment) + continue; + cerr << " row: " << type_display(*it) << " " << (*it)->URI << " " + << (*it)->Dist << endl; + rows++; + } + + remove(path.c_str()); + return rows; +} + +static void check(const string &name, const string &body, unsigned expected) +{ + cerr << " " << name << ":" << endl; + unsigned got = rows_for(body, name); + if (got != expected) { + cerr << "FAIL " << name << ": dialog would show " << got << " row(s), expected " + << expected << endl; + failures++; + } else { + cerr << "ok " << name << ": " << got << " row(s)" << endl; + } +} + +// Verbatim from mvo5's comment reporting the empty window. +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"; + +int main(int argc, char *argv[]) +{ + pkgInitConfig(*_config); + pkgInitSystem(*_config, _system); + + // The report itself: this file must not produce an empty list. + check("mvo5-reported-file", MVO5_SOURCES, 2); + + // An empty file legitimately yields an empty dialog -- the negative + // control, so "2 rows" above cannot be an artifact of always counting. + check("empty-file-is-empty", "", 0); + + if (failures > 0) { + cerr << failures << " check(s) failed" << endl; + return 1; + } + cerr << "all checks passed" << endl; + return 0; +} diff --git a/tests/test_deb822_roundtrip.cc b/tests/test_deb822_roundtrip.cc new file mode 100644 index 00000000..5e3e27a7 --- /dev/null +++ b/tests/test_deb822_roundtrip.cc @@ -0,0 +1,136 @@ +#include "config.h" // IWYU pragma: associated + +#include "rsource_deb822.h" + +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +static int failures = 0; + +static string tmppath(const string &name) +{ + return string(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp") + + "/synaptic-rt-" + name + ".sources"; +} + +static void write_file(const string &path, const string &body) +{ + ofstream out(path.c_str(), ios::binary); + out << body; +} + +static string read_file(const string &path) +{ + ifstream in(path.c_str(), ios::binary); + ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +// Reads `body`, then writes the parsed entries straight back out, exercising +// the same ParseDeb822File/WriteDeb822File pair the repository dialog uses. +// Checks the resulting FILE TEXT, not the parsed structs: a struct comparison +// reads the field through the parser on both sides, so it cannot see a field +// the parser never populates. +static void check_preserved(const string &name, const string &body, + const string &needle) +{ + string in = tmppath(name + "-in"); + string out = tmppath(name + "-out"); + write_file(in, body); + + vector entries; + bool ok = RDeb822Source::ParseDeb822File(in, entries); + if (ok) + ok = RDeb822Source::WriteDeb822File(out, entries); + + string saved = ok ? read_file(out) : string(); + remove(in.c_str()); + remove(out.c_str()); + + if (!ok) { + cerr << "FAIL " << name << ": read/write returned false" << endl; + failures++; + return; + } + if (saved.find(needle) == string::npos) { + cerr << "FAIL " << name << ": \"" << needle + << "\" is missing from the saved file" << endl; + failures++; + return; + } + cerr << "ok " << name << ": \"" << needle << "\" survived the save" << endl; +} + +// A field the parser DOES read. This is the positive control: if it ever +// fails, the harness itself is broken rather than the field handling. +static void control() +{ + check_preserved("control-components", + "Types: deb\n" + "URIs: http://deb.debian.org/debian\n" + "Suites: bookworm\n" + "Components: main contrib\n", + "Components: main contrib"); +} + +static const char *MULTIARCH = + "Types: deb\n" + "URIs: http://deb.debian.org/debian\n" + "Suites: bookworm\n" + "Components: main\n" + "Architectures: amd64 arm64\n" + "Languages: en de\n" + "Targets: deb-src\n"; + +// A comment attached to a stanza. deb822 has no comment syntax of its own, so +// apt treats '#' lines as belonging to the stanza that follows -- which is +// where WriteDeb822File already emits Deb822Entry::Comment. Only the parser +// dropped them, so a save deleted the user's own annotations. +static const char *COMMENTED = + "# Modernized from /etc/apt/sources.list\n" + "# See: https://wiki.debian.org/SourcesList\n" + "Types: deb\n" + "URIs: http://deb.debian.org/debian\n" + "Suites: bookworm\n" + "Components: main\n" + "\n" + "# security updates\n" + "Types: deb\n" + "URIs: http://security.debian.org/debian-security\n" + "Suites: bookworm-security\n" + "Components: main\n"; + +int main(int argc, char *argv[]) +{ + control(); + + // Each of these is emitted by WriteDeb822File and declared in Deb822Entry, + // but was never populated by ParseDeb822File -- so a save truncated the + // user's file and dropped the field. + check_preserved("architectures", MULTIARCH, "Architectures: amd64 arm64"); + check_preserved("languages", MULTIARCH, "Languages: en de"); + check_preserved("targets", MULTIARCH, "Targets: deb-src"); + + // Comments, including one carrying a colon, which must not be mistaken for + // a field, and one attached to the second stanza rather than the first. + check_preserved("comment-first-stanza", COMMENTED, + "# Modernized from /etc/apt/sources.list"); + check_preserved("comment-with-colon", COMMENTED, + "# See: https://wiki.debian.org/SourcesList"); + check_preserved("comment-second-stanza", COMMENTED, "# security updates"); + + if (failures > 0) { + cerr << failures << " check(s) failed" << endl; + return 1; + } + cerr << "all checks passed" << endl; + return 0; +} diff --git a/tests/test_deb822_separator.cc b/tests/test_deb822_separator.cc new file mode 100644 index 00000000..400f7f05 --- /dev/null +++ b/tests/test_deb822_separator.cc @@ -0,0 +1,97 @@ +#include "config.h" // IWYU pragma: associated + +#include "rsources.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +static int failures = 0; + +// Writes `body` to a temporary .sources file, reads it back through the real +// SourcesList code path, and checks how many records came out. +static void check(const string &name, const string &body, unsigned expected) +{ + string path = string(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp") + + "/synaptic-test-" + name + ".sources"; + + { + // binary, so the \r in the CRLF case survives being written out + ofstream out(path.c_str(), ios::binary); + out << body; + } + + SourcesList sl; + bool ok = sl.ReadDeb822SourcePart(path); + unsigned got = 0; + for (list::const_iterator I = + sl.SourceRecords.begin(); + I != sl.SourceRecords.end(); ++I) + got++; + + remove(path.c_str()); + + if (!ok || got != expected) { + cerr << "FAIL " << name << ": expected " << expected << " record(s), got " + << got << (ok ? "" : " (read returned false)") << endl; + failures++; + } else { + cerr << "ok " << name << ": " << got << " record(s)" << endl; + } +} + +// Same two stanzas in every case below; only the separator differs. +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"; + +int main(int argc, char **argv) +{ + pkgInitConfig(*_config); + pkgInitSystem(*_config, _system); + + // Control: an ordinary blank separator. If this one ever fails, the harness + // itself is broken and the cases below say nothing. + check("blank-separator", string(STANZA_A) + "\n" + STANZA_B, 2); + + // A separator line of spaces/tabs also ends a stanza (deb822 / RFC 822). + // Testing line.empty() alone let the next stanza's fields overwrite this + // one's, so the FIRST source disappeared with no error at all. + check("whitespace-separator", string(STANZA_A) + " \n" + STANZA_B, 2); + check("tab-separator", string(STANZA_A) + "\t\n" + STANZA_B, 2); + + // CRLF files hit the same path: std::getline leaves "\r" on the separator. + check("crlf-separator", string(STANZA_A) + "\r\n" + STANZA_B, 2); + + // Guard the surrounding behaviour, so a future fix here cannot quietly + // start inventing or dropping records. + check("single-stanza", string(STANZA_A), 1); + check("trailing-blank-lines", string(STANZA_A) + "\n\n\n", 1); + check("comments-only", "# just a comment\n\n# another\n", 0); + check("empty-file", "", 0); + check("stanza-missing-required-field", "Types: deb\nComponents: main\n", 0); + + if (failures > 0) { + cerr << failures << " check(s) failed" << endl; + return 1; + } + cerr << "all checks passed" << endl; + return 0; +} diff --git a/tests/test_deb822_sourceparts.cc b/tests/test_deb822_sourceparts.cc new file mode 100644 index 00000000..570d3c62 --- /dev/null +++ b/tests/test_deb822_sourceparts.cc @@ -0,0 +1,101 @@ +#include "config.h" // IWYU pragma: associated + +#include "rsources.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +static int failures = 0; + +static const char *STANZA = + "Types: deb\n" + "URIs: http://deb.debian.org/debian\n" + "Suites: bookworm\n" + "Components: main\n"; + +static void put(const string &path, const string &body) +{ + ofstream out(path.c_str(), ios::binary); + out << body; +} + +// Enumerating the sources directory is the maintainer's stated acceptance +// criterion: every *.sources under apt's Dir::Etc::sourceparts must show up, +// not just one file. +int main(int argc, char *argv[]) +{ + pkgInitConfig(*_config); + pkgInitSystem(*_config, _system); + + // Honour TMPDIR like the other tests here: a build host may have /tmp + // read-only or redirected. + string tmplstr = string(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp") + + "/synaptic-dir-XXXXXX"; + vector tmpl(tmplstr.begin(), tmplstr.end()); + tmpl.push_back('\0'); + const char *root = mkdtemp(tmpl.data()); + if (root == nullptr) { + cerr << "FAIL: could not create a temp dir" << endl; + return 1; + } + string parts = string(root) + "/sources.list.d"; + mkdir(parts.c_str(), 0755); + + // Three well-formed files. "a.sources" is 9 characters; the shortest name + // that can exist here is ".sources" itself, so cover a short name too. + put(parts + "/debian.sources", STANZA); + put(parts + "/ubuntu.sources", STANZA); + put(parts + "/a.sources", STANZA); + // Files that must be ignored, including short names that are NOT .sources. + put(parts + "/legacy.list", "deb http://x.example/ y z\n"); + put(parts + "/ab", "not a sources file\n"); + put(parts + "/x", "not a sources file\n"); + + // Point apt at the sandbox exactly as the dialog would see it. + _config->Set("Dir::Etc::sourceparts", parts); + _config->Set("Dir::Etc::sourcelist", string(root) + "/sources.list"); + + SourcesList lst; + lst.ReadSources(); + + unsigned records = 0; + for (list::const_iterator I = + lst.SourceRecords.begin(); + I != lst.SourceRecords.end(); ++I) { + if ((*I)->Type & SourcesList::Comment) + continue; + records++; + } + + // Three .sources stanzas plus the one-line legacy.list entry, which apt + // reads from the same directory and which ReadSourceDir is right to pick + // up. Each must appear exactly once: before the sourcelist.d/sourceparts + // de-duplication this was 6, because every .sources file was read twice. + if (records != 4) { + cerr << "FAIL enumerate-sourceparts: expected 4 record(s) (3 .sources + " + "1 .list), got " << records << endl; + failures++; + } else { + cerr << "ok enumerate-sourceparts: " << records << " record(s), no duplicates" + << endl; + } + + if (failures > 0) { + cerr << failures << " check(s) failed" << endl; + return 1; + } + cerr << "all checks passed" << endl; + return 0; +} diff --git a/tests/test_deb822_types_roundtrip.cc b/tests/test_deb822_types_roundtrip.cc new file mode 100644 index 00000000..fa03ce7d --- /dev/null +++ b/tests/test_deb822_types_roundtrip.cc @@ -0,0 +1,117 @@ +#include "config.h" // IWYU pragma: associated + +#include "rsources.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace std; + +static int failures = 0; + +// mvo5's own file: Types carries BOTH deb and deb-src. +static const char *BOTH_TYPES = + "Types: deb deb-src\n" + "URIs: http://ftp.de.debian.org/debian/\n" + "Suites: trixie\n" + "Components: main non-free-firmware\n"; + +// The dialog fills TYPE_COLUMN from SourceRecord::GetTypeLabel() and DoEdit() +// parses that same column back into the record, so the two must agree or a +// round trip through the dialog loses a type. This test links the real +// GetTypeLabel() out of libsynaptic rather than reimplementing it, so a +// regression in that function fails here. + +// Edit side, rgrepositorywin.cc DoEdit(): substring test on that column. +static unsigned reparsed_type(const string &type_val) +{ + unsigned t = 0; + if (type_val.find("deb") != string::npos) + t |= SourcesList::Deb; + if (type_val.find("deb-src") != string::npos) + t |= SourcesList::DebSrc; + return t; +} + +int main(int argc, char *argv[]) +{ + pkgInitConfig(*_config); + pkgInitSystem(*_config, _system); + + // Honour TMPDIR like the other tests here, and check mkdtemp: dereferencing + // a null root below would crash instead of reporting a failure. + string tmplstr = string(getenv("TMPDIR") ? getenv("TMPDIR") : "/tmp") + + "/synaptic-types-XXXXXX"; + vector tmpl(tmplstr.begin(), tmplstr.end()); + tmpl.push_back('\0'); + const char *root = mkdtemp(tmpl.data()); + if (root == nullptr) { + cerr << "FAIL: could not create a temp dir" << endl; + return 1; + } + string parts = string(root) + "/sources.list.d"; + mkdir(parts.c_str(), 0755); + { + ofstream out((parts + "/debian.sources").c_str(), ios::binary); + out << BOTH_TYPES; + } + _config->Set("Dir::Etc::sourceparts", parts); + _config->Set("Dir::Etc::sourcelist", string(root) + "/sources.list"); + + SourcesList lst; + lst.ReadSources(); + + for (list::const_iterator I = + lst.SourceRecords.begin(); + I != lst.SourceRecords.end(); ++I) { + if ((*I)->Type & SourcesList::Comment) + continue; + + const bool had_deb = ((*I)->Type & SourcesList::Deb) != 0; + const bool had_src = ((*I)->Type & SourcesList::DebSrc) != 0; + if (!(had_deb && had_src)) { + cerr << "FAIL setup: the record should carry deb AND deb-src" << endl; + failures++; + break; + } + + // What the dialog puts in the column, then reads back out when the user + // edits the row. + const string shown = (*I)->GetTypeLabel(); + const unsigned back = reparsed_type(shown); + + const bool keeps_deb = (back & SourcesList::Deb) != 0; + const bool keeps_src = (back & SourcesList::DebSrc) != 0; + + cerr << " stored in TYPE_COLUMN: \"" << shown << "\"" << endl; + cerr << " re-parsed on edit : deb=" << keeps_deb + << " deb-src=" << keeps_src << endl; + + if (!keeps_deb || !keeps_src) { + cerr << "FAIL types-survive-edit: a source with \"Types: deb deb-src\" " + "loses deb-src when the row is edited" << endl; + failures++; + } else { + cerr << "ok types-survive-edit: both types survive the round trip" + << endl; + } + break; + } + + if (failures > 0) { + cerr << failures << " check(s) failed" << endl; + return 1; + } + cerr << "all checks passed" << endl; + return 0; +}