diff --git a/README.md b/README.md index ae3490d..ea9623b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ For more usage and examples see the [Godoc](http://godoc.org/github.com/ln80/str ### Predefined masks: - `email` - `ipv4_addr` +- `credit_card` ## Limitations 1. Only fields of types convertible to `string` or `*string` are supported, although nesting structs directly or through collections (slices and maps) is also supported. diff --git a/mask/credit_card.go b/mask/credit_card.go new file mode 100644 index 0000000..c71a7e6 --- /dev/null +++ b/mask/credit_card.go @@ -0,0 +1,87 @@ +package mask + +import ( + "errors" + "strings" + + "github.com/ln80/struct-sensitive/internal/option" +) + +type CreditCardConfig struct { + MaskBankIdentifier bool // default false +} + +func CreditCard(cardNumber string, opts ...func(*Config[CreditCardConfig])) (string, error) { + cfg := DefaultConfig(CreditCardConfig{ + MaskBankIdentifier: false, + }) + option.Apply(&cfg, opts) + + formatCardNumber := func(cardNumber string, groupings []int) string { + totalLength := len(cardNumber) + formattedLength := totalLength + len(groupings) - 1 + + result := make([]rune, formattedLength) + pos := 0 + index := 0 + + for _, group := range groupings { + if pos >= totalLength { + break + } + + if index > 0 { + result[index] = ' ' + index++ + } + + end := pos + group + if end > totalLength { + end = totalLength + } + + for i := pos; i < end; i++ { + result[index] = rune(cardNumber[i]) + index++ + } + + pos += group + } + + return string(result) + } + + cardNumber = strings.ReplaceAll(cardNumber, " ", "") + length := len(cardNumber) + + if length < 15 || length > 16 { + return "", errors.New("unsupported credit card number format") + } + + var groupings []int + if length == 15 { + groupings = []int{4, 6, 5} // Typical for American Express + } else { + groupings = []int{4, 4, 4, 4} // Default for standard cards + } + + if cfg.Kind.MaskBankIdentifier { + maskUntil := length - groupings[len(groupings)-1] + masked := strings.Repeat(string([]rune{cfg.Symbol}), maskUntil) + cardNumber[maskUntil:] + return formatCardNumber(masked, groupings), nil + } + + visibleStart := groupings[0] + visibleEnd := groupings[len(groupings)-1] + if length <= visibleStart+visibleEnd { + return cardNumber, nil + } + masked := cardNumber[:visibleStart] + + strings.Repeat(string([]rune{cfg.Symbol}), length-visibleStart-visibleEnd) + + cardNumber[length-visibleEnd:] + return formatCardNumber(masked, groupings), nil +} + +func init() { + Register("credit_card", DefaultMasker(CreditCard)) +} diff --git a/mask/credit_card_test.go b/mask/credit_card_test.go new file mode 100644 index 0000000..1f70bbb --- /dev/null +++ b/mask/credit_card_test.go @@ -0,0 +1,67 @@ +package mask_test + +import ( + "testing" + + "github.com/ln80/struct-sensitive/mask" + "github.com/ln80/struct-sensitive/masktest" +) + +func TestCreditCard(t *testing.T) { + masktest.Run(t, mask.CreditCard, []masktest.Tc[mask.CreditCardConfig]{ + { + Value: "3012", + OK: false, + }, + { + Value: "4111 3145 4001 1111", + Want: "4111 **** **** 1111", + OK: true, + }, + { + Value: "4111 3145 4001 1111", + Want: "**** **** **** 1111", + OK: true, + Option: func(c *mask.Config[mask.CreditCardConfig]) { + c.Kind.MaskBankIdentifier = true + }, + }, + { + Value: "3714 496353 98431", + Want: "3714 ****** 98431", + OK: true, + }, + { + Value: "371449635398431", + Want: "3714 ****** 98431", + OK: true, + }, + { + Value: "3714 496353 98431", + Want: "**** ****** 98431", + OK: true, + Option: func(c *mask.Config[mask.CreditCardConfig]) { + c.Kind.MaskBankIdentifier = true + }, + }, + { + Value: "3714 496353 98431", + Want: "$$$$ $$$$$$ 98431", + OK: true, + Option: func(c *mask.Config[mask.CreditCardConfig]) { + c.Kind.MaskBankIdentifier = true + c.Symbol = '$' + }, + }, + }) +} + +func BenchmarkCreditCard(b *testing.B) { + for i := 0; i < b.N; i++ { + if _, err := mask.CreditCard("4111 3145 4001 1111", func(mc *mask.Config[mask.CreditCardConfig]) { + mc.Kind.MaskBankIdentifier = true + }); err != nil { + b.Fatal(err) + } + } +}