From 318c2b258b628ca64a89aaee08a4cbf0295fcc44 Mon Sep 17 00:00:00 2001 From: "Arrobo, Gabriel" Date: Thu, 6 Aug 2026 21:06:35 -0700 Subject: [PATCH 1/2] Fix AMF/SMF match filters, add TAI filter, cache regex Signed-off-by: Arrobo, Gabriel --- VERSION | 2 +- nrfcache/match_filters.go | 78 ++++++++++++++++++++-------- nrfcache/nrfcache_test.go | 105 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 23 deletions(-) diff --git a/VERSION b/VERSION index 08ec77d0..ccbccc3d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.1.6-dev +2.2.0 diff --git a/nrfcache/match_filters.go b/nrfcache/match_filters.go index 442c3194..0daccdcb 100644 --- a/nrfcache/match_filters.go +++ b/nrfcache/match_filters.go @@ -18,12 +18,15 @@ import ( "slices" "strconv" "strings" + "sync" "github.com/omec-project/openapi/v2/Nnrf_NFDiscovery" "github.com/omec-project/openapi/v2/logger" "github.com/omec-project/openapi/v2/models" ) +var regexpCache sync.Map + type MatchFilter func(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) type MatchFilters map[models.NFType]MatchFilter @@ -91,7 +94,7 @@ func MatchSmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A } } - // validate dnn + // validate dnn within the S-NSSAI context when snssais filter is also active dnn := opts.GetDnn() if dnn != nil { // if a dnn is provided by the upper layer, check for the exact match @@ -102,6 +105,18 @@ func MatchSmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A if hasSmfInfo { matchDnnLoop: for _, s := range smfInfo.GetSNssaiSmfInfoList() { + if snssais != nil { + entrySnssaiMatch := false + for _, reqSnssai := range *snssais { + if s.SNssai.GetSst() == reqSnssai.GetSst() && s.SNssai.GetSd() == reqSnssai.GetSd() { + entrySnssaiMatch = true + break + } + } + if !entrySnssaiMatch { + continue + } + } for _, d := range s.GetDnnSmfInfoList() { if d.GetDnn() == *dnn || d.GetDnn() == "*" { dnnMatched = true @@ -131,10 +146,17 @@ func matchSupiRange(supi string, supiRange []models.SupiRange) bool { func matchSingleSupiRange(supi string, supiRange models.SupiRange) bool { // Handle regex pattern matching (preferred method) if pattern := supiRange.GetPattern(); pattern != "" { - r, err := regexp.Compile(pattern) - if err != nil { - logger.NrfcacheLog.Errorf("invalid SUPI pattern '%s': %v", pattern, err) - return false + var r *regexp.Regexp + if cached, ok := regexpCache.Load(pattern); ok { + r = cached.(*regexp.Regexp) + } else { + var err error + r, err = regexp.Compile(pattern) + if err != nil { + logger.NrfcacheLog.Errorf("invalid SUPI pattern '%s': %v", pattern, err) + return false + } + regexpCache.Store(pattern, r) } return r.MatchString(supi) } @@ -234,22 +256,19 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A targetPlmnList := opts.GetTargetPlmnList() if targetPlmnList != nil && len(*targetPlmnList) > 0 { profilePlmnList := profile.GetPlmnList() - if len(profilePlmnList) == 0 { - logger.NrfcacheLog.Debugf("amf match failed: no profile PLMNs for %s", profile.GetNfInstanceId()) - return false, nil - } - - found := false - for _, targetPlmn := range *targetPlmnList { - if slices.Contains(profilePlmnList, targetPlmn) { - found = true - break + // A profile without plmnList is available to all PLMNs (TS 29.510 Table 6.2.3.2.3.1-1 [Query-11]) + if len(profilePlmnList) > 0 { + found := false + for _, targetPlmn := range *targetPlmnList { + if slices.Contains(profilePlmnList, targetPlmn) { + found = true + break + } + } + if !found { + logger.NrfcacheLog.Debugf("amf match failed: no PLMN match for %s", profile.GetNfInstanceId()) + return false, nil } - } - - if !found { - logger.NrfcacheLog.Debugf("amf match failed: no PLMN match for %s", profile.GetNfInstanceId()) - return false, nil } } @@ -278,9 +297,14 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A logger.NrfcacheLog.Debugf("amf match failed: AMF set ID mismatch for %s", profile.GetNfInstanceId()) return false, nil } + tai := opts.GetTai() + // Absent taiList means unrestricted (TS 29.510 Table 6.1.6.2.11-1) + if tai != nil && len(amfInfo.GetTaiList()) > 0 && !taiInList(*tai, amfInfo.GetTaiList()) { + logger.NrfcacheLog.Debugf("amf match failed: TAI mismatch for %s", profile.GetNfInstanceId()) + return false, nil + } } else { - // Handle case where AMF-specific filters are provided but AmfInfo is nil - if opts.GetGuami() != nil || opts.GetAmfRegionId() != nil || opts.GetAmfSetId() != nil { + if opts.GetGuami() != nil || opts.GetAmfRegionId() != nil || opts.GetAmfSetId() != nil || opts.GetTai() != nil { logger.NrfcacheLog.Debugf("amf match failed: AMF filters provided but no AmfInfo for %s", profile.GetNfInstanceId()) return false, nil } @@ -290,6 +314,16 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A return true, nil } +// taiInList compares PlmnId and Tac only; Nid (SNPN) is omitted intentionally. +func taiInList(tai models.Tai, list []models.Tai) bool { + for _, t := range list { + if t.GetPlmnId() == tai.GetPlmnId() && t.GetTac() == tai.GetTac() { + return true + } + } + return false +} + func MatchPcfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) { if profile == nil { return false, fmt.Errorf("profile cannot be nil") diff --git a/nrfcache/nrfcache_test.go b/nrfcache/nrfcache_test.go index 7d5a5de5..86453265 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1309,3 +1309,108 @@ func TestUdrProfileIsSelectableFromCache(t *testing.T) { t.Error("SUPI outside the declared range must not match") } } + +// TestAmfMatchesProfileWithoutPlmnList validates TS 29.510 Table 6.2.3.2.3.1-1 +// (target-plmn-list entry): a profile that carries no plmnList is available to +// all PLMNs and must be included in any target-plmn-list-filtered discovery result. +func TestAmfMatchesProfileWithoutPlmnList(t *testing.T) { + profile := models.NFProfileDiscovery{ + NfInstanceId: "AMF-no-plmn", + NfType: models.NFTYPE_AMF, + NfStatus: models.NFSTATUS_REGISTERED, + } + param := createAmfParamWithPlmns([]models.PlmnId{{Mcc: "208", Mnc: "93"}}) + match, err := MatchAmfProfile(&profile, param) + if err != nil { + t.Fatalf("MatchAmfProfile returned error: %v", err) + } + if !match { + t.Error("AMF profile without plmnList must match any PLMN filter (TS 29.510 Table 6.2.3.2.3.1-1, target-plmn-list entry)") + } +} + +// TestAmfTaiFilter validates that the tai query parameter filters AMF profiles +// by amfInfo.taiList (TS 29.510 Table 6.2.3.2.3.1-1, tai entry; absence of +// taiList in AmfInfo means the AMF serves any TAI, per Table 6.1.6.2.11-1). +func TestAmfTaiFilter(t *testing.T) { + matchingTai := models.Tai{PlmnId: models.PlmnId{Mcc: "208", Mnc: "93"}, Tac: "000001"} + otherTai := models.Tai{PlmnId: models.PlmnId{Mcc: "208", Mnc: "93"}, Tac: "000002"} + + profile := models.NFProfileDiscovery{ + NfInstanceId: "AMF-tai", + NfType: models.NFTYPE_AMF, + NfStatus: models.NFSTATUS_REGISTERED, + AmfInfo: &models.AmfInfo{ + AmfRegionId: "ca", + AmfSetId: "3f8", + TaiList: []models.Tai{matchingTai}, + }, + } + + paramMatch := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Tai(matchingTai) + if match, err := MatchAmfProfile(&profile, paramMatch); err != nil || !match { + t.Errorf("expected TAI match: match=%v err=%v", match, err) + } + + paramMiss := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Tai(otherTai) + if match, err := MatchAmfProfile(&profile, paramMiss); err != nil || match { + t.Errorf("expected TAI miss: match=%v err=%v", match, err) + } + + // AmfInfo present but taiList absent means unrestricted (TS 29.510 Table 6.1.6.2.11-1). + unrestricted := models.NFProfileDiscovery{ + NfInstanceId: "AMF-no-tail", + NfType: models.NFTYPE_AMF, + NfStatus: models.NFSTATUS_REGISTERED, + AmfInfo: &models.AmfInfo{AmfRegionId: "ca", AmfSetId: "3f8"}, + } + if match, err := MatchAmfProfile(&unrestricted, paramMatch); err != nil || !match { + t.Errorf("AMF with absent taiList must match any TAI query: match=%v err=%v", match, err) + } +} + +// TestSmfDnnScopedToMatchedSnssai guards that a DNN filter is only satisfied +// when the DNN appears in an sNssaiSmfInfoList entry whose S-NSSAI matches the +// snssais filter. Without this, an SMF with {snssai-A/dnn-X, snssai-B/dnn-Y} +// would falsely match a query for {snssai-A, dnn-Y}. +func TestSmfDnnScopedToMatchedSnssai(t *testing.T) { + snssaiA := models.Snssai{Sst: 1, Sd: openapi.PtrString("010203")} + snssaiB := models.Snssai{Sst: 1, Sd: openapi.PtrString("0a0b0c")} + + profile := models.NFProfileDiscovery{ + NfInstanceId: "SMF-cross-snssai", + NfType: models.NFTYPE_SMF, + SmfInfo: &models.SmfInfo{ + SNssaiSmfInfoList: []models.SnssaiSmfInfoItem{ + { + SNssai: snssaiA, + DnnSmfInfoList: []models.DnnSmfInfoItem{ + {Dnn: "internet"}, + }, + }, + { + SNssai: snssaiB, + DnnSmfInfoList: []models.DnnSmfInfoItem{ + {Dnn: "ims"}, + }, + }, + }, + }, + } + + // snssai-A + dnn "internet" must match (same entry) + paramMatch := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}. + Snssais([]models.Snssai{snssaiA}). + Dnn("internet") + if match, err := MatchSmfProfile(&profile, paramMatch); err != nil || !match { + t.Errorf("expected match for snssai-A/internet: match=%v err=%v", match, err) + } + + // snssai-A + dnn "ims" must not match (ims is only in snssai-B) + paramCross := Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}. + Snssais([]models.Snssai{snssaiA}). + Dnn("ims") + if match, err := MatchSmfProfile(&profile, paramCross); err != nil || match { + t.Errorf("expected no match for snssai-A/ims cross-SNSSAI: match=%v err=%v", match, err) + } +} From 27d0a92bae692c1a56e2e05fe190a0f44ba338fc Mon Sep 17 00:00:00 2001 From: "Arrobo, Gabriel" Date: Thu, 6 Aug 2026 21:37:10 -0700 Subject: [PATCH 2/2] Address Copilot's comments Signed-off-by: Arrobo, Gabriel --- nrfcache/match_filters.go | 22 ++++++++++++++++----- nrfcache/nrfcache_test.go | 40 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/nrfcache/match_filters.go b/nrfcache/match_filters.go index 0daccdcb..3353baaf 100644 --- a/nrfcache/match_filters.go +++ b/nrfcache/match_filters.go @@ -19,13 +19,20 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "github.com/omec-project/openapi/v2/Nnrf_NFDiscovery" "github.com/omec-project/openapi/v2/logger" "github.com/omec-project/openapi/v2/models" ) -var regexpCache sync.Map +// maxRegexpCacheEntries caps the SUPI-pattern cache to prevent unbounded growth. +const maxRegexpCacheEntries = 1024 + +var ( + regexpCache sync.Map + regexpCacheCount atomic.Int64 +) type MatchFilter func(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.ApiSearchNFInstancesRequest) (bool, error) @@ -156,7 +163,11 @@ func matchSingleSupiRange(supi string, supiRange models.SupiRange) bool { logger.NrfcacheLog.Errorf("invalid SUPI pattern '%s': %v", pattern, err) return false } - regexpCache.Store(pattern, r) + if regexpCacheCount.Load() < maxRegexpCacheEntries { + if _, loaded := regexpCache.LoadOrStore(pattern, r); !loaded { + regexpCacheCount.Add(1) + } + } } return r.MatchString(supi) } @@ -298,7 +309,8 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A return false, nil } tai := opts.GetTai() - // Absent taiList means unrestricted (TS 29.510 Table 6.1.6.2.11-1) + // Absent taiList and taiRangeList means unrestricted (TS 29.510 Table 6.1.6.2.11-1). + // taiRangeList matching is not yet implemented; profiles with only taiRangeList pass through. if tai != nil && len(amfInfo.GetTaiList()) > 0 && !taiInList(*tai, amfInfo.GetTaiList()) { logger.NrfcacheLog.Debugf("amf match failed: TAI mismatch for %s", profile.GetNfInstanceId()) return false, nil @@ -314,10 +326,10 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A return true, nil } -// taiInList compares PlmnId and Tac only; Nid (SNPN) is omitted intentionally. +// taiInList matches PlmnId, Tac, and Nid; an absent Nid (PLMN context) matches only another absent Nid. func taiInList(tai models.Tai, list []models.Tai) bool { for _, t := range list { - if t.GetPlmnId() == tai.GetPlmnId() && t.GetTac() == tai.GetTac() { + if t.GetPlmnId() == tai.GetPlmnId() && t.GetTac() == tai.GetTac() && t.GetNid() == tai.GetNid() { return true } } diff --git a/nrfcache/nrfcache_test.go b/nrfcache/nrfcache_test.go index 86453265..4f65ccb3 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1359,7 +1359,7 @@ func TestAmfTaiFilter(t *testing.T) { // AmfInfo present but taiList absent means unrestricted (TS 29.510 Table 6.1.6.2.11-1). unrestricted := models.NFProfileDiscovery{ - NfInstanceId: "AMF-no-tail", + NfInstanceId: "AMF-no-tai", NfType: models.NFTYPE_AMF, NfStatus: models.NFSTATUS_REGISTERED, AmfInfo: &models.AmfInfo{AmfRegionId: "ca", AmfSetId: "3f8"}, @@ -1369,6 +1369,44 @@ func TestAmfTaiFilter(t *testing.T) { } } +// TestAmfTaiFilterSnpn validates SNPN TAI matching: the Nid field must be +// included in the comparison so that TAIs with equal PLMN+TAC but different +// NIDs do not produce false-positive matches across SNPNs (TS 23.501 5.30.2.1). +func TestAmfTaiFilterSnpn(t *testing.T) { + nid1 := openapi.PtrString("1234567890a") + nid2 := openapi.PtrString("b0987654321") + plmn := models.PlmnId{Mcc: "208", Mnc: "93"} + tac := "000001" + + snpnTai1 := models.Tai{PlmnId: plmn, Tac: tac, Nid: nid1} + snpnTai2 := models.Tai{PlmnId: plmn, Tac: tac, Nid: nid2} + plmnTai := models.Tai{PlmnId: plmn, Tac: tac} // no Nid – standard PLMN context + + profile := models.NFProfileDiscovery{ + NfInstanceId: "AMF-snpn", + NfType: models.NFTYPE_AMF, + NfStatus: models.NFSTATUS_REGISTERED, + AmfInfo: &models.AmfInfo{ + AmfRegionId: "ca", + AmfSetId: "3f8", + TaiList: []models.Tai{snpnTai1}, + }, + } + + // Same NID: must match. + if match, err := MatchAmfProfile(&profile, Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Tai(snpnTai1)); err != nil || !match { + t.Errorf("expected match for same NID: match=%v err=%v", match, err) + } + // Different NID, same PLMN+TAC: must not match (cross-SNPN false positive). + if match, err := MatchAmfProfile(&profile, Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Tai(snpnTai2)); err != nil || match { + t.Errorf("expected no match for different NID: match=%v err=%v", match, err) + } + // Query without NID against SNPN profile TAI: must not match. + if match, err := MatchAmfProfile(&profile, Nnrf_NFDiscovery.ApiSearchNFInstancesRequest{}.Tai(plmnTai)); err != nil || match { + t.Errorf("expected no match for PLMN query vs SNPN profile: match=%v err=%v", match, err) + } +} + // TestSmfDnnScopedToMatchedSnssai guards that a DNN filter is only satisfied // when the DNN appears in an sNssaiSmfInfoList entry whose S-NSSAI matches the // snssais filter. Without this, an SMF with {snssai-A/dnn-X, snssai-B/dnn-Y}