Skip to content
Merged
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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.1.6-dev
2.2.0
90 changes: 68 additions & 22 deletions nrfcache/match_filters.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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")
Expand Down
143 changes: 143 additions & 0 deletions nrfcache/nrfcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Loading