Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion src/models/DeclaredBands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

#include "BandDefs.h"

#include <string_view>

namespace AetherSDR {

namespace {
Expand All @@ -20,14 +22,68 @@ 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
};
Comment on lines +42 to +44

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional hardening: since kBands and kBandAliases are both constexpr, the security invariant in the comment above ("an alias may ONLY map to a name that already exists in kBands") can be checked by the compiler rather than trusted to reviewers. Something like:

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");

That way a future typo'd or renamed canonical fails the build instead of silently dropping the band. Non-blocking — the current one-entry table is easy to eyeball.


// 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)
{
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;
Expand Down
5 changes: 5 additions & 0 deletions src/models/DeclaredBands.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
25 changes: 25 additions & 0 deletions tests/declared_bands_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading