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..3353baaf 100644 --- a/nrfcache/match_filters.go +++ b/nrfcache/match_filters.go @@ -18,12 +18,22 @@ import ( "slices" "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" ) +// 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) type MatchFilters map[models.NFType]MatchFilter @@ -91,7 +101,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 +112,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 +153,21 @@ 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 + } + if regexpCacheCount.Load() < maxRegexpCacheEntries { + if _, loaded := regexpCache.LoadOrStore(pattern, r); !loaded { + regexpCacheCount.Add(1) + } + } } return r.MatchString(supi) } @@ -234,22 +267,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 +308,15 @@ 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 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 + } } 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 +326,16 @@ func MatchAmfProfile(profile *models.NFProfileDiscovery, opts Nnrf_NFDiscovery.A return true, nil } +// 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() && t.GetNid() == tai.GetNid() { + 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..4f65ccb3 100644 --- a/nrfcache/nrfcache_test.go +++ b/nrfcache/nrfcache_test.go @@ -1309,3 +1309,146 @@ 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-tai", + 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) + } +} + +// 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} +// 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) + } +}