diff --git a/lib/identifier/obligation.go b/lib/identifier/obligation.go new file mode 100644 index 000000000..e8a5c973a --- /dev/null +++ b/lib/identifier/obligation.go @@ -0,0 +1,166 @@ +package identifier + +import ( + "fmt" + "regexp" + "strings" +) + +// Structs and regexes for obligation FQNs +type FullyQualifiedObligation struct { + Namespace string + Name string + Value string +} + +var ( + // Regex for obligation value FQN format: https:///obl//value/ + // The $ at the end ensures no extra segments after value + obligationValueFQNRegex = regexp.MustCompile( + `^https:\/\/(?[^\/]+)\/obl\/(?[^\/]+)\/value\/(?[^\/]+)$`, + ) + + // Regex for obligation definition FQN format: https:///obl/ + // The $ at the end ensures no extra segments after name + obligationDefinitionFQNRegex = regexp.MustCompile( + `^https:\/\/(?[^\/]+)\/obl\/(?[^\/]+)$`, + ) +) + +// Implementing FullyQualified interface for FullyQualifiedObligation +func (obl *FullyQualifiedObligation) FQN() string { + builder := strings.Builder{} + builder.WriteString("https://") + builder.WriteString(obl.Namespace) + + // if name, must be valid + if obl.Name != "" { + builder.WriteString("/obl/") + builder.WriteString(obl.Name) + + if obl.Value != "" { + builder.WriteString("/value/") + builder.WriteString(obl.Value) + } + } + return strings.ToLower(builder.String()) +} + +func (obl *FullyQualifiedObligation) Validate() error { + if !validNamespaceRegex.MatchString(obl.Namespace) { + return fmt.Errorf("%w: invalid namespace format %s", ErrInvalidFQNFormat, obl.Namespace) + } + + // Only validate name and value if they are present + if obl.Name != "" && !validObjectNameRegex.MatchString(obl.Name) { + return fmt.Errorf("%w: invalid obligation name format %s", ErrInvalidFQNFormat, obl.Name) + } + + if obl.Value != "" && !validObjectNameRegex.MatchString(obl.Value) { + return fmt.Errorf("%w: invalid obligation value format %s", ErrInvalidFQNFormat, obl.Value) + } + + return nil +} + +// parseObligationFqn parses an obligation FQN string into a FullyQualifiedObligation struct. +// The FQN can be: +// - a namespace only FQN (https://) +// - a definition FQN (https:///obl/) +// - a value FQN (https:///obl//value/) +func parseObligationFqn(fqn string) (*FullyQualifiedObligation, error) { + parsed := &FullyQualifiedObligation{} + + // First try to match against the obligation value pattern + valueMatches := obligationValueFQNRegex.FindStringSubmatch(fqn) + if len(valueMatches) > 0 { + namespaceIdx := obligationValueFQNRegex.SubexpIndex("namespace") + nameIdx := obligationValueFQNRegex.SubexpIndex("name") + valueIdx := obligationValueFQNRegex.SubexpIndex("value") + + if len(valueMatches) <= namespaceIdx || len(valueMatches) <= nameIdx || len(valueMatches) <= valueIdx { + return nil, fmt.Errorf("%w: valid obligation value FQN format https:///obl//value/ must be provided", ErrInvalidFQNFormat) + } + + ns := strings.ToLower(valueMatches[namespaceIdx]) + name := strings.ToLower(valueMatches[nameIdx]) + value := strings.ToLower(valueMatches[valueIdx]) + + isValid := validNamespaceRegex.MatchString(ns) && validObjectNameRegex.MatchString(name) && validObjectNameRegex.MatchString(value) + if !isValid { + return nil, fmt.Errorf("%w: found namespace %s with obligation name %s and value %s", ErrInvalidFQNFormat, ns, name, value) + } + + parsed.Namespace = ns + parsed.Name = name + parsed.Value = value + + return parsed, nil + } + + // If not a value FQN, try to match against the obligation definition pattern + defMatches := obligationDefinitionFQNRegex.FindStringSubmatch(fqn) + if len(defMatches) > 0 { + namespaceIdx := obligationDefinitionFQNRegex.SubexpIndex("namespace") + nameIdx := obligationDefinitionFQNRegex.SubexpIndex("name") + + if len(defMatches) <= namespaceIdx || len(defMatches) <= nameIdx { + return nil, fmt.Errorf("%w: valid obligation definition FQN format https:///obl/ must be provided [%s]", ErrInvalidFQNFormat, fqn) + } + + ns := strings.ToLower(defMatches[namespaceIdx]) + name := strings.ToLower(defMatches[nameIdx]) + + isValid := validNamespaceRegex.MatchString(ns) && validObjectNameRegex.MatchString(name) + if !isValid { + return nil, fmt.Errorf("%w: found namespace %s with obligation name %s", ErrInvalidFQNFormat, ns, name) + } + parsed.Namespace = ns + parsed.Name = name + + return parsed, nil + } + + // If not a definition FQN, try to match against just the namespace + nsMatches := namespaceOnlyRegex.FindStringSubmatch(fqn) + if len(nsMatches) > 0 { + namespaceIdx := namespaceOnlyRegex.SubexpIndex("namespace") + + if len(nsMatches) <= namespaceIdx { + return nil, fmt.Errorf("%w: valid namespace FQN format https:// must be provided [%s]", ErrInvalidFQNFormat, fqn) + } + + ns := strings.ToLower(nsMatches[namespaceIdx]) + isValid := validNamespaceRegex.MatchString(ns) + if !isValid { + return nil, fmt.Errorf("%w: found namespace %s", ErrInvalidFQNFormat, ns) + } + + parsed.Namespace = ns + return parsed, nil + } + + return nil, fmt.Errorf("%w, must be https://, https:///obl/, or https:///obl//value/", ErrInvalidFQNFormat) +} + +func BreakOblFQN(fqn string) (string, string) { + nsFQN := strings.Split(fqn, "/obl/")[0] + parts := strings.Split(fqn, "/") + oblName := strings.ToLower(parts[len(parts)-1]) + return nsFQN, oblName +} + +func BreakOblValFQN(fqn string) (string, string, string) { + parts := strings.Split(fqn, "/value/") + nsFQN, oblName := BreakOblFQN(parts[0]) + oblVal := strings.ToLower(parts[len(parts)-1]) + return nsFQN, oblName, oblVal +} + +func BuildOblFQN(nsFQN, oblName string) string { + return nsFQN + "/obl/" + strings.ToLower(oblName) +} + +func BuildOblValFQN(nsFQN, oblName, oblVal string) string { + return BuildOblFQN(nsFQN, oblName) + "/value/" + strings.ToLower(oblVal) +} diff --git a/lib/identifier/obligation_test.go b/lib/identifier/obligation_test.go new file mode 100644 index 000000000..4468771ca --- /dev/null +++ b/lib/identifier/obligation_test.go @@ -0,0 +1,420 @@ +package identifier + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBreakOblFQN(t *testing.T) { + validFQN := "https://namespace.com/obl/drm" + nsFQN, oblName := BreakOblFQN(validFQN) + require.Equal(t, "https://namespace.com", nsFQN) + require.Equal(t, "drm", oblName) + + invalidFQN := "" + nsFQN, oblName = BreakOblFQN(invalidFQN) + require.Empty(t, nsFQN) + require.Empty(t, oblName) +} + +func TestBreakOblValFQN(t *testing.T) { + validFQN := "https://namespace.com/obl/drm/value/watermark" + nsFQN, oblName, valName := BreakOblValFQN(validFQN) + require.Equal(t, "https://namespace.com", nsFQN) + require.Equal(t, "drm", oblName) + require.Equal(t, "watermark", valName) + + invalidFQN := "" + nsFQN, oblName, valName = BreakOblValFQN(invalidFQN) + require.Empty(t, nsFQN) + require.Empty(t, oblName) + require.Empty(t, valName) +} + +func TestBuildOblFQN(t *testing.T) { + nsFQN := "https://namespace.com" + oblName := "drm" + expectedFQN := nsFQN + "/obl/" + oblName + fqn := BuildOblFQN(nsFQN, oblName) + require.Equal(t, expectedFQN, fqn) +} + +func TestBuildOblValFQN(t *testing.T) { + nsFQN := "https://namespace.com" + oblName := "drm" + valName := "watermark" + expectedFQN := nsFQN + "/obl/" + oblName + "/value/" + valName + fqn := BuildOblValFQN(nsFQN, oblName, valName) + require.Equal(t, expectedFQN, fqn) +} + +func TestObligationFQN(t *testing.T) { + tests := []struct { + name string + namespace string + oblName string + value string + want string + }{ + // Namespace-only FQNs + { + name: "namespace only", + namespace: "example.com", + want: "https://example.com", + }, + { + name: "namespace with subdomain only", + namespace: "sub.example.com", + want: "https://sub.example.com", + }, + { + name: "namespace lower cased", + namespace: "EXAMPLE.com", + want: "https://example.com", + }, + + // Definition FQNs + { + name: "definition", + namespace: "example.com", + oblName: "drm", + want: "https://example.com/obl/drm", + }, + { + name: "definition with hyphen", + namespace: "example.com", + oblName: "drm-", + want: "https://example.com/obl/drm-", + }, + { + name: "definition with underscore", + namespace: "example.com", + oblName: "drm_", + want: "https://example.com/obl/drm_", + }, + { + name: "definition with numbers", + namespace: "example.com", + oblName: "drm365", + want: "https://example.com/obl/drm365", + }, + { + name: "definition lower cased", + namespace: "EXAMPLE.com", + oblName: "DRM", + want: "https://example.com/obl/drm", + }, + + // Value FQNs + { + name: "value", + namespace: "example.com", + oblName: "drm", + value: "watermark", + want: "https://example.com/obl/drm/value/watermark", + }, + { + name: "complex value", + namespace: "sub.example.com", + oblName: "drm", + value: "expiration", + want: "https://sub.example.com/obl/drm/value/expiration", + }, + { + name: "value lower cased", + namespace: "EXAMPLE.com", + oblName: "DRM", + value: "WATERMARK", + want: "https://example.com/obl/drm/value/watermark", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obl := &FullyQualifiedObligation{ + Namespace: tt.namespace, + Name: tt.oblName, + Value: tt.value, + } + got := obl.FQN() + require.Equal(t, tt.want, got) + }) + } +} + +func TestObligationValidate(t *testing.T) { + tests := []struct { + name string + namespace string + oblName string + value string + wantErr bool + }{ + // Valid cases + { + name: "valid namespace only", + namespace: "example.com", + oblName: "", + value: "", + wantErr: false, + }, + { + name: "valid definition", + namespace: "example.com", + oblName: "drm", + value: "", + wantErr: false, + }, + { + name: "valid value", + namespace: "example.com", + oblName: "drm", + value: "watermark", + wantErr: false, + }, + + // Invalid cases + { + name: "invalid namespace - no TLD", + namespace: "example", + oblName: "", + value: "", + wantErr: true, + }, + { + name: "invalid namespace - starts with hyphen", + namespace: "-example.com", + oblName: "", + value: "", + wantErr: true, + }, + { + name: "invalid obligation name - starts with underscore", + namespace: "example.com", + oblName: "_drm", + value: "", + wantErr: true, + }, + { + name: "invalid obligation name - ends with hyphen", + namespace: "example.com", + oblName: "drm-", + value: "", + wantErr: true, + }, + { + name: "invalid value - starts with hyphen", + namespace: "example.com", + oblName: "drm", + value: "-watermark", + wantErr: true, + }, + { + name: "invalid value - ends with underscore", + namespace: "example.com", + oblName: "drm", + value: "watermark_", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + obl := &FullyQualifiedObligation{ + Namespace: tt.namespace, + Name: tt.oblName, + Value: tt.value, + } + + err := obl.Validate() + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestParseObligationFqn(t *testing.T) { + // Test cases for the parseObligationFqn function + tests := []struct { + name string + fqn string + wantNamespace string + wantName string + wantValue string + wantErr bool + }{ + { + name: "Valid namespace only FQN", + fqn: "https://example.org", + wantNamespace: "example.org", + wantName: "", + wantValue: "", + wantErr: false, + }, + { + name: "Valid obligation definition FQN", + fqn: "https://example.org/obl/drm", + wantNamespace: "example.org", + wantName: "drm", + wantValue: "", + wantErr: false, + }, + { + name: "Valid obligation value FQN", + fqn: "https://example.org/obl/drm/value/watermark", + wantNamespace: "example.org", + wantName: "drm", + wantValue: "watermark", + wantErr: false, + }, + { + name: "Valid obligation value FQN with complex namespace", + fqn: "https://subdomain.example.org/obl/drm/value/watermark", + wantNamespace: "subdomain.example.org", + wantName: "drm", + wantValue: "watermark", + wantErr: false, + }, + { + name: "Valid obligation definition FQN with special characters in name", + fqn: "https://example.org/obl/drm_365", + wantNamespace: "example.org", + wantName: "drm_365", + wantValue: "", + wantErr: false, + }, + { + name: "Valid obligation value FQN with special characters in value", + fqn: "https://example.org/obl/drm/value/expiration_365", + wantNamespace: "example.org", + wantName: "drm", + wantValue: "expiration_365", + wantErr: false, + }, + { + name: "Valid obligation value FQN gets lower cased", + fqn: "https://example.org/obl/DRM/value/WATERMARK", + wantNamespace: "example.org", + wantName: "drm", + wantValue: "watermark", + wantErr: false, + }, + { + name: "Invalid FQN - empty string", + fqn: "", + wantErr: true, + }, + { + name: "Invalid FQN - missing https", + fqn: "example.org/obl/drm", + wantErr: true, + }, + { + name: "Invalid FQN - wrong protocol", + fqn: "http://example.org/obl/drm", + wantErr: true, + }, + { + name: "Invalid FQN - wrong path between namespace and name", + fqn: "https://example.org/obligation/drm", + wantErr: true, + }, + { + name: "Invalid FQN - missing name", + fqn: "https://example.org/obl/", + wantErr: true, + }, + { + name: "Invalid FQN - value path but no value", + fqn: "https://example.org/obl/drm/value/", + wantErr: true, + }, + { + name: "Invalid FQN - extra segments", + fqn: "https://example.org/obl/drm/value/watermark/extra", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseObligationFqn(tt.fqn) + if (err != nil) != tt.wantErr { + t.Errorf("parseObligationFqn() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr { + return + } + if got.Namespace != tt.wantNamespace { + t.Errorf("parseObligationFqn() namespace = %v, want %v", got.Namespace, tt.wantNamespace) + } + if got.Name != tt.wantName { + t.Errorf("parseObligationFqn() name = %v, want %v", got.Name, tt.wantName) + } + if got.Value != tt.wantValue { + t.Errorf("parseObligationFqn() value = %v, want %v", got.Value, tt.wantValue) + return + } + }) + } +} + +func TestObligationRoundTrip(t *testing.T) { + // Test round trip from struct to FQN to parse and back + tests := []struct { + name string + namespace string + oblName string + value string + }{ + { + name: "namespace only", + namespace: "example.com", + oblName: "", + value: "", + }, + { + name: "definition", + namespace: "example.com", + oblName: "drm", + value: "", + }, + { + name: "value", + namespace: "example.com", + oblName: "drm", + value: "watermark", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create original obligation + original := &FullyQualifiedObligation{ + Namespace: tt.namespace, + Name: tt.oblName, + Value: tt.value, + } + + // Get FQN + fqn := original.FQN() + + // Parse the FQN + parsed, err := parseObligationFqn(fqn) + require.NoError(t, err) + + // Check the parsed values match original + require.Equal(t, original.Namespace, parsed.Namespace) + require.Equal(t, original.Name, parsed.Name) + require.Equal(t, original.Value, parsed.Value) + + // Ensure the re-generated FQN matches the original + require.Equal(t, fqn, parsed.FQN()) + }) + } +} diff --git a/lib/identifier/obligations.go b/lib/identifier/obligations.go deleted file mode 100644 index a9056d7c0..000000000 --- a/lib/identifier/obligations.go +++ /dev/null @@ -1,25 +0,0 @@ -package identifier - -import "strings" - -func BreakOblFQN(fqn string) (string, string) { - nsFQN := strings.Split(fqn, "/obl/")[0] - parts := strings.Split(fqn, "/") - oblName := strings.ToLower(parts[len(parts)-1]) - return nsFQN, oblName -} - -func BreakOblValFQN(fqn string) (string, string, string) { - parts := strings.Split(fqn, "/value/") - nsFQN, oblName := BreakOblFQN(parts[0]) - oblVal := strings.ToLower(parts[len(parts)-1]) - return nsFQN, oblName, oblVal -} - -func BuildOblFQN(nsFQN, oblName string) string { - return nsFQN + "/obl/" + strings.ToLower(oblName) -} - -func BuildOblValFQN(nsFQN, oblName, oblVal string) string { - return BuildOblFQN(nsFQN, oblName) + "/value/" + strings.ToLower(oblVal) -} diff --git a/lib/identifier/obligations_test.go b/lib/identifier/obligations_test.go deleted file mode 100644 index eb7ce07ed..000000000 --- a/lib/identifier/obligations_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package identifier - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -func TestBreakOblFQN(t *testing.T) { - validFQN := "https://namespace.com/obl/drm" - nsFQN, oblName := BreakOblFQN(validFQN) - require.Equal(t, "https://namespace.com", nsFQN) - require.Equal(t, "drm", oblName) - - invalidFQN := "" - nsFQN, oblName = BreakOblFQN(invalidFQN) - require.Empty(t, nsFQN) - require.Empty(t, oblName) -} - -func TestBreakOblValFQN(t *testing.T) { - validFQN := "https://namespace.com/obl/drm/value/watermark" - nsFQN, oblName, valName := BreakOblValFQN(validFQN) - require.Equal(t, "https://namespace.com", nsFQN) - require.Equal(t, "drm", oblName) - require.Equal(t, "watermark", valName) - - invalidFQN := "" - nsFQN, oblName, valName = BreakOblValFQN(invalidFQN) - require.Empty(t, nsFQN) - require.Empty(t, oblName) - require.Empty(t, valName) -} - -func TestBuildOblFQN(t *testing.T) { - nsFQN := "https://namespace.com" - oblName := "drm" - expectedFQN := nsFQN + "/obl/" + oblName - fqn := BuildOblFQN(nsFQN, oblName) - require.Equal(t, expectedFQN, fqn) -} - -func TestBuildOblValFQN(t *testing.T) { - nsFQN := "https://namespace.com" - oblName := "drm" - valName := "watermark" - expectedFQN := nsFQN + "/obl/" + oblName + "/value/" + valName - fqn := BuildOblValFQN(nsFQN, oblName, valName) - require.Equal(t, expectedFQN, fqn) -} diff --git a/lib/identifier/policyidentifier.go b/lib/identifier/policyidentifier.go index 9f16dbcb5..8311e16e7 100644 --- a/lib/identifier/policyidentifier.go +++ b/lib/identifier/policyidentifier.go @@ -59,6 +59,12 @@ func Parse[T FullyQualified](identifier string) (T, error) { return result, err } + case *FullyQualifiedObligation: + parsed, err = parseObligationFqn(identifier) + if err != nil { + return result, err + } + case *FullyQualifiedResourceMappingGroup: parsed, err = parseResourceMappingGroupFqn(identifier) if err != nil {