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
8 changes: 4 additions & 4 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ jobs:
steps:

- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: 1.22
go-version: 1.24

- name: Check out code
uses: actions/checkout@v4

- name: Lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v9
with:
version: v1.60
version: v2.12
args: --enable misspell
4 changes: 2 additions & 2 deletions .github/workflows/module.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: [ "1.22", "1.23" ]
go-version: ["1.24", "1.25", "1.26" ]

steps:
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}

Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ jobs:

steps:
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: 1.22
go-version: 1.24

- name: Check out code
uses: actions/checkout@v4
Expand Down
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import (

...

var defaultMask := func(val string) (masked string, err error) {
defaultMask := func(val string) (masked string, err error) {
// TODO implement 'be_nrn' mask behavior here
masked = "**.**.**-***-**"
return
Expand All @@ -97,9 +97,7 @@ For more usage and examples see the [Godoc](http://godoc.org/github.com/ln80/str
- `fullname`

## 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.
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. String slices (`[]string`, `[]*string`) are supported as data fields.

2. Self-Referencing Types are supported, allowing types to include fields of the same type. However, Self-Referencing Values (instances that create a reference loop) are not supported.

3. At the moment, collections of types convertible to `string` or `*string` are not supported.

6 changes: 3 additions & 3 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Here's an example:

Applying the default masking logic:

var profile := Profile{
profile := Profile{
Email: "eric.prosacco@example.com",
Fullname: "Eric Prosacco",
Role: "Teacher",
Expand All @@ -35,7 +35,7 @@ Applying the default masking logic:
// After masking:
//
// Profile{
// Email: "****.********@example.com",
// Email: "*************@example.com",
// Fullname: "*************",
// Role: "Teacher",
// }
Expand All @@ -54,7 +54,7 @@ Applying a custom redact logic:
Role string
}

var profile := Profile{
profile := Profile{
Email: "eric.prosacco@example.com",
Fullname: "Eric Prosacco",
Role: "Teacher",
Expand Down
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
module github.com/ln80/struct-sensitive

go 1.22.0
go 1.24.0

require (
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/sanity-io/litter v1.5.5 // indirect
github.com/mitchellh/copystructure v1.2.0
github.com/sanity-io/litter v1.5.5
)

require github.com/mitchellh/reflectwalk v1.0.2 // indirect
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b h1:XxMZvQZtTXpWMNWK82vdjCLCe7uGMFXdTsJH0v3Hkvw=
github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0 h1:GD+A8+e+wFkqje55/2fOVnZPkoDIu1VooBWfNrnY8Uo=
github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312 h1:UsFdQ3ZmlzS0BqZYGxvYaXvFGUbCmPGy8DM7qWJJiIQ=
github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
23 changes: 15 additions & 8 deletions mask.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,31 +48,38 @@ type Masked[T any] struct {
}

type MaskedCopyConfig struct {
DeepCopy bool // default false
// DeepCopy controls whether the original value is deep-copied before masking.
// Defaults to true. Setting to false is unsafe when the struct contains pointer,
// slice, or map fields, as Reveal() may return partially masked data.
DeepCopy bool
}

// NewMaskedCopy returns a new masked copy of the given value.
// It fails if it can't copy the value or the mask config is invalid.
func NewMaskedCopy[T any](v T, opts ...func(*MaskedCopyConfig)) (*Masked[T], error) {
cfg := MaskedCopyConfig{
DeepCopy: false,
DeepCopy: true,
}
option.Apply(&cfg, opts)

var copy = v
masked := v
if cfg.DeepCopy {
c, err := copystructure.Copy(v)
// Deep-copy the original to prevent shared pointers between original and masked values.
// Without this, masking through shared *string/slice/map fields corrupts the original.
orig, err := copystructure.Copy(v)
if err != nil {
return nil, errors.Join(ErrFailedToMaskCopy, err)
}
copy = c.(T)
if err := Mask(&masked); err != nil {
return nil, errors.Join(ErrFailedToMaskCopy, err)
}
return &Masked[T]{value: masked, original: orig.(T)}, nil
}

if err := Mask(&copy); err != nil {
if err := Mask(&masked); err != nil {
return nil, errors.Join(ErrFailedToMaskCopy, err)
}

return &Masked[T]{value: copy, original: v}, nil
return &Masked[T]{value: masked, original: v}, nil
}

// MaskedCopy returns a masked copy of the given value.
Expand Down
18 changes: 16 additions & 2 deletions mask/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ package mask
import (
"errors"
"strings"
"unicode/utf8"

"github.com/ln80/struct-sensitive/internal/option"
)

type EmailConfig struct {
MaskDomain bool // default false
MaskDomain bool // default false
KeepFirstAndLastChar bool // default false
}

func Email(email string, opts ...func(*Config[EmailConfig])) (string, error) {
Expand All @@ -22,7 +24,19 @@ func Email(email string, opts ...func(*Config[EmailConfig])) (string, error) {
return "", errors.New("invalid email format")
}

local := strings.Repeat(string([]rune{cfg.Symbol}), len(parts[0]))
// Use rune count for correct multi-byte character handling
localRunes := []rune(parts[0])
localRuneCount := utf8.RuneCountInString(parts[0])

var local string
if cfg.Kind.KeepFirstAndLastChar && localRuneCount > 2 {
firstChar := string(localRunes[0])
lastChar := string(localRunes[localRuneCount-1])
middle := strings.Repeat(string([]rune{cfg.Symbol}), localRuneCount-2)
local = firstChar + middle + lastChar
} else {
local = strings.Repeat(string([]rune{cfg.Symbol}), localRuneCount)
}

domain := parts[1]
if cfg.Kind.MaskDomain {
Expand Down
13 changes: 7 additions & 6 deletions mask/fullname.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,30 @@ package mask

import (
"strings"
"unicode/utf8"

"github.com/ln80/struct-sensitive/internal/option"
)

type FullNameConfig struct {
}

// FullName masks a full name in the format "J*** ***** ***** D**".
// FullName masks a full name by revealing only the first character of the first and last words.
func FullName(name string, opts ...func(*Config[FullNameConfig])) (string, error) {
cfg := DefaultConfig(FullNameConfig{})
option.Apply(&cfg, opts)

// Split name into words
words := strings.Fields(name)

// Mask all words except the first and last
var builder strings.Builder
for i, word := range words {
// Use rune count for correct multi-byte character handling
runeCount := utf8.RuneCountInString(word)
if i == 0 || i == len(words)-1 {
builder.WriteRune([]rune(word)[0])
builder.WriteString(strings.Repeat(string(cfg.Symbol), len(word)-1))
} else { // Middle words
builder.WriteString(strings.Repeat(string(cfg.Symbol), len(word)))
builder.WriteString(strings.Repeat(string(cfg.Symbol), runeCount-1))
} else {
builder.WriteString(strings.Repeat(string(cfg.Symbol), runeCount))
}
if i != len(words)-1 {
builder.WriteRune(' ')
Expand Down
2 changes: 1 addition & 1 deletion mask/fullname_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ func TestFullName(t *testing.T) {
},
{
Value: "X Æ A-12 Musk",
Want: "X ** **** M***",
Want: "X * **** M***",
OK: true,
},
})
Expand Down
7 changes: 4 additions & 3 deletions mask/mask.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ func DefaultConfig[T any](t T) Config[T] {
}
}

// Masker presents the function type that masks must satisfy.
// Masker is the function type that masks must satisfy.
type Masker[T any] func(val string, opts ...func(*Config[T])) (string, error)

// defaultMasker is a masker that doesn't support options
Expand All @@ -44,8 +44,9 @@ func Register(kind string, m defaultMasker) {

// Of returns the specific default masker of the given kind.
func Of(kind string) (m defaultMasker, found bool) {
maskMu.Lock()
defer maskMu.Unlock()
// Read-only access: use RLock to allow concurrent mask lookups
maskMu.RLock()
defer maskMu.RUnlock()

m, found = maskRegistry[kind]
return
Expand Down
44 changes: 42 additions & 2 deletions mask_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,12 +113,12 @@ func TestMaskedCopy(t *testing.T) {
Fullname: "Guadalupe Kemmer DDS",
}

copy, err := NewMaskedCopy(profile)
cp, err := NewMaskedCopy(profile)
if err != nil {
t.Fatal("expect err be nil, got", err)
}

maskedCopy := copy.Value()
maskedCopy := cp.Value()

if reflect.DeepEqual(profile, maskedCopy) {
t.Fatalf("expect not be equals %v, %v", profile, maskedCopy)
Expand All @@ -132,3 +132,43 @@ func TestMaskedCopy(t *testing.T) {
t.Fatalf("expect be equals %v, %v", profile, maskedCopy)
}
}

func TestMaskedCopy_PointerFields(t *testing.T) {
original := Profile{
ID: "usr-1",
Email: "email@example.com",
Fullname: "Guadalupe Kemmer DDS",
Phone: ptr("519-491-6780"),
Devices: []Device{
{IPAddr: "169.251.207.194"},
},
}

cp, err := NewMaskedCopy(original)
if err != nil {
t.Fatal("expect err be nil, got", err)
}

revealed := cp.Reveal()

if revealed.Email != "email@example.com" {
t.Fatalf("Reveal().Email: want original, got %q", revealed.Email)
}
if revealed.Fullname != "Guadalupe Kemmer DDS" {
t.Fatalf("Reveal().Fullname: want original, got %q", revealed.Fullname)
}
if *revealed.Phone != "519-491-6780" {
t.Fatalf("Reveal().Phone: want original, got %q", *revealed.Phone)
}
if revealed.Devices[0].IPAddr != "169.251.207.194" {
t.Fatalf("Reveal().Devices[0].IPAddr: want original, got %q", revealed.Devices[0].IPAddr)
}

masked := cp.Value()
if masked.Email == "email@example.com" {
t.Fatal("Value().Email should be masked")
}
if *masked.Phone == "519-491-6780" {
t.Fatal("Value().Phone should be masked")
}
}
2 changes: 1 addition & 1 deletion redact.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ var (
ErrRedactFuncNotFound = errors.New("redact function not found")
)

// RedactConfig presents the configuration required by `sensitive.Redact`.
// RedactConfig holds the configuration for [Redact].
type RedactConfig struct {
// RequireSubjectID force the subjectID resolution from the struct value.
// This config is disabled by default.
Expand Down
Loading
Loading