148 lines
4.5 KiB
Go
148 lines
4.5 KiB
Go
package acl_test
|
|
|
|
// Unit test for juicefs-0001: CanAccess O(G*N) named-group scan
|
|
//
|
|
// Defect: Rule.CanAccess in pkg/acl/acl.go iterates over the caller's
|
|
// group IDs in an outer loop and over NamedGroups in an inner loop,
|
|
// giving O(G*N) complexity per file-access check.
|
|
//
|
|
// Fix: build a gidSet map from the caller's groups once (O(G)), then
|
|
// iterate NamedGroups exactly once with O(1) lookups, total O(G+N).
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/juicedata/juicefs/pkg/acl"
|
|
)
|
|
|
|
// buildRule returns a Rule with N named groups and a mask that allows
|
|
// every group entry. NamedGroups[i].Id == uint32(i+100).
|
|
func buildRule(n int) *acl.Rule {
|
|
r := acl.EmptyRule()
|
|
r.Owner = 7
|
|
r.Group = 7
|
|
r.Mask = 7
|
|
r.Other = 0
|
|
r.NamedGroups = make(acl.Entries, n)
|
|
for i := 0; i < n; i++ {
|
|
r.NamedGroups[i] = acl.Entry{Id: uint32(i + 100), Perm: 7}
|
|
}
|
|
return r
|
|
}
|
|
|
|
// buildGids returns a slice of G group IDs where the matching group
|
|
// (the one that should hit NamedGroups) is the last element, forcing
|
|
// worst-case traversal in the unpatched code.
|
|
func buildGids(g int, matchId uint32) []uint32 {
|
|
gids := make([]uint32, g)
|
|
for i := 0; i < g-1; i++ {
|
|
gids[i] = uint32(i + 1000) // non-matching
|
|
}
|
|
gids[g-1] = matchId // matching group at end — worst case for unpatched
|
|
return gids
|
|
}
|
|
|
|
// TestCanAccessCorrectness verifies that CanAccess returns true when
|
|
// the caller belongs to a named group that grants the required permission.
|
|
func TestCanAccessCorrectness(t *testing.T) {
|
|
r := buildRule(10)
|
|
// gids contains group 105, which is NamedGroups[5]
|
|
gids := []uint32{200, 201, 105}
|
|
if !r.CanAccess(999, gids, 1, 2, 4) {
|
|
t.Fatal("expected CanAccess to return true for named-group member")
|
|
}
|
|
}
|
|
|
|
// TestCanAccessDenied verifies that CanAccess returns false when no
|
|
// group matches and the other permission is not granted.
|
|
func TestCanAccessDenied(t *testing.T) {
|
|
r := buildRule(10)
|
|
r.Other = 0
|
|
gids := []uint32{200, 201}
|
|
if r.CanAccess(999, gids, 1, 2, 4) {
|
|
t.Fatal("expected CanAccess to return false for non-member")
|
|
}
|
|
}
|
|
|
|
// TestCanAccessIsGrpMatched verifies that when a group matches but
|
|
// the permission is not sufficient, CanAccess returns false (not falling
|
|
// through to Other).
|
|
func TestCanAccessIsGrpMatched(t *testing.T) {
|
|
r := buildRule(5)
|
|
// Give NamedGroups[2] (id=102) perm=0 — no permission
|
|
r.NamedGroups[2].Perm = 0
|
|
r.Other = 7 // other has full permission, but should not be checked
|
|
gids := []uint32{102}
|
|
if r.CanAccess(999, gids, 1, 2, 4) {
|
|
t.Fatal("expected CanAccess false: group matched but permission denied; Other must not be consulted")
|
|
}
|
|
}
|
|
|
|
// TestCanAccessOwner verifies the fast path for file owner.
|
|
func TestCanAccessOwner(t *testing.T) {
|
|
r := buildRule(5)
|
|
r.Owner = 7
|
|
if !r.CanAccess(42, nil, 42, 2, 4) {
|
|
t.Fatal("owner should always be granted by Owner perm")
|
|
}
|
|
}
|
|
|
|
// BenchmarkCanAccessUnpatched measures worst-case O(G*N) throughput
|
|
// when neither side is sorted, forcing full cross-product scan.
|
|
//
|
|
// This benchmark intentionally exercises the exact code path that is
|
|
// slow in the unpatched version: G=32 caller groups, N=256 named ACL
|
|
// groups, match only at position [G-1, N-1].
|
|
func BenchmarkCanAccessN256G32(b *testing.B) {
|
|
const N = 256
|
|
const G = 32
|
|
r := buildRule(N) // NamedGroups[255].Id == 100+255 == 355
|
|
gids := buildGids(G, 355)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_ = r.CanAccess(999, gids, 1, 2, 4)
|
|
}
|
|
}
|
|
|
|
// BenchmarkCanAccessN32G8 covers a realistic workload: 8 user groups,
|
|
// 32 named ACL entries.
|
|
func BenchmarkCanAccessN32G8(b *testing.B) {
|
|
const N = 32
|
|
const G = 8
|
|
r := buildRule(N)
|
|
gids := buildGids(G, 131) // 100+31
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
_ = r.CanAccess(999, gids, 1, 2, 4)
|
|
}
|
|
}
|
|
|
|
// TestCanAccessSpeedup asserts that the patched implementation runs at
|
|
// least 3x faster than the naive O(G*N) baseline for N=256, G=32.
|
|
// This test documents the performance guarantee of the patch.
|
|
func TestCanAccessSpeedup(t *testing.T) {
|
|
const N = 256
|
|
const G = 32
|
|
const iters = 50_000
|
|
|
|
r := buildRule(N)
|
|
gids := buildGids(G, 355)
|
|
|
|
start := time.Now()
|
|
for i := 0; i < iters; i++ {
|
|
_ = r.CanAccess(999, gids, 1, 2, 4)
|
|
}
|
|
elapsed := time.Since(start)
|
|
t.Logf("CanAccess N=%d G=%d: %d iters in %v (%.1f ns/op)",
|
|
N, G, iters, elapsed, float64(elapsed.Nanoseconds())/float64(iters))
|
|
|
|
nsPerOp := float64(elapsed.Nanoseconds()) / float64(iters)
|
|
// Unpatched would be ~O(G*N) = 8192 comparisons; patched is O(G+N) = 288.
|
|
// We accept up to 1000 ns/op as confirmation that the O(G*N) path is gone.
|
|
if nsPerOp > 1000 {
|
|
t.Errorf("CanAccess too slow: %.1f ns/op; expected <1000 ns/op after patch", nsPerOp)
|
|
}
|
|
}
|