diff --git a/src/models/DeclaredBands.cpp b/src/models/DeclaredBands.cpp index 52375c989..a41204aa6 100644 --- a/src/models/DeclaredBands.cpp +++ b/src/models/DeclaredBands.cpp @@ -2,6 +2,8 @@ #include "BandDefs.h" +#include + namespace AetherSDR { namespace { @@ -20,6 +22,57 @@ constexpr bool isDeclarable(const BandDef& def) return def.highMhz >= 1.0; } +// Accepted alternate spellings for a band, mapping to the canonical kBands +// name. AE's band vocabulary is internally inconsistent — the 70cm band is +// named "440" — but a gateway (or a ham typing a profile) naturally spells it +// "70cm". Rather than force every radio adapter to know AE's quirk, accept the +// conventional name here and resolve it to the canonical one. +// +// SECURITY INVARIANT (Principle VII): an alias may ONLY map to a name that +// already exists in kBands. It is a second *spelling* of a known band, never a +// new band — so it cannot introduce anything the band UI doesn't already have, +// and the resolved name still runs the isDeclarable + kBands allow-list below. +// Kept deliberately minimal: only real-world mismatches that have actually been +// observed from a gateway (currently just 70cm; the same class of mismatch was +// patched per-adapter in Aether-gate PRs #14/#15/#16 — this fixes it once here). +struct BandAlias { + const char* alias; + const char* canonical; +}; +constexpr BandAlias kBandAliases[] = { + {"70cm", "440"}, // UHF: every ham + gateway spells it 70cm; AE names it 440 +}; + +// Enforce the security invariant at COMPILE TIME: every alias's canonical must +// name an existing kBands entry. This makes Principle VII hold by construction +// (an alias can only ever be a second spelling of a real band) rather than by +// review — a future typo'd or renamed canonical fails the build instead of +// silently dropping the band. +constexpr bool aliasCanonicalsExist() +{ + for (const auto& a : kBandAliases) { + bool found = false; + for (const auto& def : kBands) + if (std::string_view(def.name) == a.canonical) { found = true; break; } + if (!found) + return false; + } + return true; +} +static_assert(aliasCanonicalsExist(), + "every kBandAliases canonical must name an existing kBands entry"); + +// If `name` is a known alias, return its canonical kBands spelling; otherwise +// return `name` unchanged. Case-insensitive on the alias key. +QString resolveAlias(const QString& name) +{ + for (const auto& a : kBandAliases) { + if (name.compare(QLatin1String(a.alias), Qt::CaseInsensitive) == 0) + return QString::fromLatin1(a.canonical); + } + return name; +} + } // namespace QStringList parseDeclaredBands(const QString& csv) @@ -27,7 +80,10 @@ QStringList parseDeclaredBands(const QString& csv) QStringList out; const QStringList parts = csv.split(',', Qt::SkipEmptyParts); for (const QString& part : parts) { - const QString name = part.trimmed(); + // Resolve a conventional spelling (e.g. "70cm") to its canonical kBands + // name before matching. A non-alias token passes through unchanged, so + // the allow-list below is still the sole gate on what gets rendered. + const QString name = resolveAlias(part.trimmed()); for (const auto& def : kBands) { if (!isDeclarable(def)) continue; diff --git a/src/models/DeclaredBands.h b/src/models/DeclaredBands.h index 1aa80325b..321875c71 100644 --- a/src/models/DeclaredBands.h +++ b/src/models/DeclaredBands.h @@ -14,6 +14,11 @@ namespace AetherSDR { // non-declarable per #4027's non-goals, even though they are kBands entries // (see the isDeclarable() firewall in DeclaredBands.cpp). An empty/absent // value yields an empty list, which is the real-Flex path (band UI unchanged). +// +// A short alias table also accepts conventional spellings (e.g. "70cm") and +// resolves them to the canonical kBands name ("440"), so gateways need not +// know AE's naming quirks. Aliases only ever map to names already in kBands, +// so the allow-list guarantee above is unchanged (see kBandAliases in the .cpp). QStringList parseDeclaredBands(const QString& csv); } // namespace AetherSDR diff --git a/tests/declared_bands_test.cpp b/tests/declared_bands_test.cpp index 81da47943..336d3612b 100644 --- a/tests/declared_bands_test.cpp +++ b/tests/declared_bands_test.cpp @@ -86,6 +86,31 @@ int main(int argc, char** argv) eq(parseDeclaredBands(QStringLiteral("2m,440,2m")), {QStringLiteral("2m"), QStringLiteral("440")})); + // Band-name aliases: a conventional spelling resolves to the canonical + // kBands name. "70cm" is what every ham + gateway spells UHF; AE names it + // "440" (see Aether-gate PRs #14/#15). The alias maps only to a name that + // already exists in kBands, so Principle VII is unchanged (junk still drops). + report("alias (70cm -> [440])", + eq(parseDeclaredBands(QStringLiteral("70cm")), + {QStringLiteral("440")})); + report("alias case-fold (70CM -> [440])", + eq(parseDeclaredBands(QStringLiteral("70CM")), + {QStringLiteral("440")})); + report("alias among reals (2m,70cm,23cm -> [2m,440,23cm])", + eq(parseDeclaredBands(QStringLiteral("2m,70cm,23cm")), + {QStringLiteral("2m"), QStringLiteral("440"), QStringLiteral("23cm")})); + // Both spellings sent -> dedup collapses to one canonical entry (either order). + report("alias dedup (440,70cm -> [440])", + eq(parseDeclaredBands(QStringLiteral("440,70cm")), + {QStringLiteral("440")})); + report("alias dedup (70cm,440 -> [440])", + eq(parseDeclaredBands(QStringLiteral("70cm,440")), + {QStringLiteral("440")})); + // Principle VII holds with aliasing on: the alias is kept, junk still dropped. + report("alias kept, junk dropped (70cm,junk -> [440])", + eq(parseDeclaredBands(QStringLiteral("70cm,junk")), + {QStringLiteral("440")})); + if (g_failed == 0) { std::printf("\nAll %d declared-bands tests passed.\n", g_total); return 0;