mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-07 22:19:08 +02:00
Add profile stamping as active migration
This commit is contained in:
@@ -32,6 +32,10 @@ type Profile struct {
|
|||||||
Path string
|
Path string
|
||||||
IsActive bool
|
IsActive bool
|
||||||
Owners []ipcauth.Principal
|
Owners []ipcauth.Principal
|
||||||
|
// LegacyUserDir is the sanitized username of the per-username directory the
|
||||||
|
// profile was found in, empty for the profiles.v1 directory and for the
|
||||||
|
// default profile. It is the only legacy evidence of profile ownership.
|
||||||
|
LegacyUserDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccessibleBy reports whether a kernel-attested caller may address this
|
// AccessibleBy reports whether a kernel-attested caller may address this
|
||||||
@@ -43,9 +47,11 @@ func (p *Profile) AccessibleBy(id ipcauth.Identity) bool {
|
|||||||
if ipcauth.IsPrivilegedCaller(id) {
|
if ipcauth.IsPrivilegedCaller(id) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// TODO: decide on unowned behavior
|
|
||||||
if len(p.Owners) == 0 {
|
if len(p.Owners) == 0 {
|
||||||
return false
|
// A profile in a per-username directory belonged to a use, unowned fails
|
||||||
|
// closed. The account named by the directory reclaims it on their next
|
||||||
|
// lookup or until claimed by a privileged caller.
|
||||||
|
return p.LegacyUserDir == ""
|
||||||
}
|
}
|
||||||
return p.Owners[0].Matches(id)
|
return p.Owners[0].Matches(id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/internal/getent"
|
||||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
)
|
)
|
||||||
@@ -56,11 +59,11 @@ type profileMeta struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint:unused
|
|
||||||
type ownerMeta struct {
|
type ownerMeta struct {
|
||||||
Owners []string
|
Owners []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ownersFieldName is the key on disk.
|
||||||
func (e *ErrAmbiguousHandle) Error() string {
|
func (e *ErrAmbiguousHandle) Error() string {
|
||||||
switch e.Kind {
|
switch e.Kind {
|
||||||
case AmbiguityKindIDPrefix:
|
case AmbiguityKindIDPrefix:
|
||||||
@@ -599,6 +602,8 @@ func (s *ServiceManager) loadAllProfilesForIdentity(userID ipcauth.Identity) ([]
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.claimLegacyProfiles(allProfiles, userID)
|
||||||
|
|
||||||
accessible := make([]Profile, 0, len(allProfiles))
|
accessible := make([]Profile, 0, len(allProfiles))
|
||||||
for _, p := range allProfiles {
|
for _, p := range allProfiles {
|
||||||
if p.AccessibleBy(userID) {
|
if p.AccessibleBy(userID) {
|
||||||
@@ -609,6 +614,106 @@ func (s *ServiceManager) loadAllProfilesForIdentity(userID ipcauth.Identity) ([]
|
|||||||
return accessible, nil
|
return accessible, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
legacyDirMu sync.Mutex
|
||||||
|
legacyDirCache = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
// claimLegacyProfiles stamps the caller on every unowned profile in the
|
||||||
|
// directory their own user name produced before the ownership model.
|
||||||
|
//
|
||||||
|
// Ownership lives in the file now, so the directory name is only a leftover.
|
||||||
|
// Flattening is a separate step we are doing in the future. Moving it would
|
||||||
|
// pull the state file out from under an engine that captured its path at
|
||||||
|
// connect time.
|
||||||
|
func (s *ServiceManager) claimLegacyProfiles(profiles []Profile, id ipcauth.Identity) {
|
||||||
|
// A privileged caller reaches every profile already and an internal load
|
||||||
|
// has no caller, so neither should leave an owner behind.
|
||||||
|
if !id.Known() || ipcauth.IsPrivilegedCaller(id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasUnownedLegacyProfile(profiles) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, ok := legacyDirForIdentity(id)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
principal := ipcauth.OwnerPrincipalForIdentity(id)
|
||||||
|
parsed, ok := ipcauth.ParsePrincipal(principal)
|
||||||
|
if !ok {
|
||||||
|
log.Warnf("not claiming legacy profiles, %q is not a usable owner", principal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range profiles {
|
||||||
|
p := &profiles[i]
|
||||||
|
if len(p.Owners) > 0 || p.LegacyUserDir == "" || p.LegacyUserDir != dir {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := StampOwner(p.Path, id); err != nil {
|
||||||
|
log.Warnf("could not claim legacy profile %s for %s: %v", p.Path, principal, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
p.Owners = []ipcauth.Principal{parsed}
|
||||||
|
log.Infof("claimed legacy profile %s for %s, its directory is named after that account", p.Path, principal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasUnownedLegacyProfile(profiles []Profile) bool {
|
||||||
|
for i := range profiles {
|
||||||
|
if profiles[i].LegacyUserDir != "" && len(profiles[i].Owners) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacyDirForIdentity is a variable so a test can supply an account name
|
||||||
|
// without depending on the host's user database.
|
||||||
|
var legacyDirForIdentity = resolveLegacyDir
|
||||||
|
|
||||||
|
// resolveLegacyDir returns the per-username directory the old layout would have
|
||||||
|
// created for a caller, and whether there is one.
|
||||||
|
//
|
||||||
|
// Successes are cached for the process, failures are not, so a directory
|
||||||
|
// service that is briefly unreachable does not lock its users out until the
|
||||||
|
// daemon restarts.
|
||||||
|
func resolveLegacyDir(id ipcauth.Identity) (string, bool) {
|
||||||
|
key := ipcauth.OwnerPrincipalForIdentity(id)
|
||||||
|
|
||||||
|
legacyDirMu.Lock()
|
||||||
|
cached, hit := legacyDirCache[key]
|
||||||
|
legacyDirMu.Unlock()
|
||||||
|
if hit {
|
||||||
|
return cached, cached != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
lookup := strconv.FormatUint(uint64(id.UID), 10)
|
||||||
|
if id.IsWindows() {
|
||||||
|
lookup = id.SID
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := getent.LookupUserID(lookup)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("cannot resolve %s to an account name, its legacy profiles stay unowned: %v", key, err)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := sanitizeProfileName(u.Username)
|
||||||
|
|
||||||
|
legacyDirMu.Lock()
|
||||||
|
legacyDirCache[key] = dir
|
||||||
|
legacyDirMu.Unlock()
|
||||||
|
|
||||||
|
return dir, dir != ""
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
|
func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
|
||||||
_, activeIsDefault := s.activeProfileID()
|
_, activeIsDefault := s.activeProfileID()
|
||||||
defaultName := readProfileName(DefaultConfigPath)
|
defaultName := readProfileName(DefaultConfigPath)
|
||||||
@@ -643,16 +748,23 @@ func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
|
|||||||
// from before the ID-keyed layout. The first is not necessarily under
|
// from before the ID-keyed layout. The first is not necessarily under
|
||||||
// DefaultConfigPathDir: a ServiceManager can be pointed at a directory of
|
// DefaultConfigPathDir: a ServiceManager can be pointed at a directory of
|
||||||
// its own, which is what the mobile bindings do.
|
// its own, which is what the mobile bindings do.
|
||||||
dirs := []string{s.profilesDirPath()}
|
dirs := []profileDir{{path: s.profilesDirPath()}}
|
||||||
|
|
||||||
configPathDir, err := os.ReadDir(DefaultConfigPathDir)
|
configPathDir, err := os.ReadDir(DefaultConfigPathDir)
|
||||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, fmt.Errorf("read profile directory: %w", err)
|
return nil, fmt.Errorf("read profile directory: %w", err)
|
||||||
}
|
}
|
||||||
for _, entry := range configPathDir {
|
for _, entry := range configPathDir {
|
||||||
if entry.IsDir() {
|
if !entry.IsDir() || entry.Name() == DefaultProfilePathDir {
|
||||||
dirs = append(dirs, filepath.Join(DefaultConfigPathDir, entry.Name()))
|
continue
|
||||||
}
|
}
|
||||||
|
// Every other subdirectory is named after the account that created the
|
||||||
|
// profiles in it. The dot in profiles.v1 is what keeps them apart,
|
||||||
|
// since sanitizeProfileName drops dots.
|
||||||
|
dirs = append(dirs, profileDir{
|
||||||
|
path: filepath.Join(DefaultConfigPathDir, entry.Name()),
|
||||||
|
legacyUser: entry.Name(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileProfiles []Profile
|
var fileProfiles []Profile
|
||||||
@@ -660,10 +772,10 @@ func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
|
|||||||
for _, dir := range dirs {
|
for _, dir := range dirs {
|
||||||
// The profiles directory is usually one of the subdirectories above,
|
// The profiles directory is usually one of the subdirectories above,
|
||||||
// so without this a profile would be listed twice.
|
// so without this a profile would be listed twice.
|
||||||
if scanned[dir] {
|
if scanned[dir.path] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
scanned[dir] = true
|
scanned[dir.path] = true
|
||||||
|
|
||||||
dirProfiles, err := s.getProfilesFromDirectory(dir)
|
dirProfiles, err := s.getProfilesFromDirectory(dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -683,7 +795,15 @@ func (s *ServiceManager) loadAllProfiles() ([]Profile, error) {
|
|||||||
return profiles, nil
|
return profiles, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServiceManager) getProfilesFromDirectory(configDir string) ([]Profile, error) {
|
// profileDir is one directory the loader scans. legacyUser is the sanitized
|
||||||
|
// username it is named after, empty for the directory profiles go to now.
|
||||||
|
type profileDir struct {
|
||||||
|
path string
|
||||||
|
legacyUser string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ServiceManager) getProfilesFromDirectory(dir profileDir) ([]Profile, error) {
|
||||||
|
configDir := dir.path
|
||||||
activeID, _ := s.activeProfileID()
|
activeID, _ := s.activeProfileID()
|
||||||
entries, err := os.ReadDir(configDir)
|
entries, err := os.ReadDir(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -725,11 +845,12 @@ func (s *ServiceManager) getProfilesFromDirectory(configDir string) ([]Profile,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fileProfiles = append(fileProfiles, Profile{
|
fileProfiles = append(fileProfiles, Profile{
|
||||||
ID: stem,
|
ID: stem,
|
||||||
Name: name,
|
Name: name,
|
||||||
Path: path,
|
Path: path,
|
||||||
IsActive: stem == ID(activeID),
|
IsActive: stem == ID(activeID),
|
||||||
Owners: owners,
|
Owners: owners,
|
||||||
|
LegacyUserDir: dir.legacyUser,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return fileProfiles, nil
|
return fileProfiles, nil
|
||||||
@@ -777,12 +898,14 @@ func readProfileOwners(path string) ([]ipcauth.Principal, error) {
|
|||||||
return []ipcauth.Principal{principal}, nil
|
return []ipcauth.Principal{principal}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nolint: unused,unusedfunc
|
// StampOwner records owner as a profile's owner, replacing whoever is recorded
|
||||||
|
// now.
|
||||||
func StampOwner(path string, owner ipcauth.Identity) error {
|
func StampOwner(path string, owner ipcauth.Identity) error {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -790,7 +913,7 @@ func StampOwner(path string, owner ipcauth.Identity) error {
|
|||||||
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(owner)}
|
cfg.Owners = []string{ipcauth.OwnerPrincipalForIdentity(owner)}
|
||||||
|
|
||||||
if err := util.WriteJson(context.Background(), path, cfg); err != nil {
|
if err := util.WriteJson(context.Background(), path, cfg); err != nil {
|
||||||
return fmt.Errorf("failed to write profile owner: %w", err)
|
return fmt.Errorf("write profile owner: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package profilemanager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -289,7 +291,7 @@ func TestListProfiles_PrivilegedResolvesUnfiltered(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListProfiles_UnownedIsPrivilegedOnly(t *testing.T) {
|
func TestListProfiles_UnownedOutsideALegacyDirIsOpen(t *testing.T) {
|
||||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||||
unowned, err := sm.AddProfile("unowned", nil)
|
unowned, err := sm.AddProfile("unowned", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -297,16 +299,257 @@ func TestListProfiles_UnownedIsPrivilegedOnly(t *testing.T) {
|
|||||||
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
got, err := sm.ListProfiles(alice)
|
got, err := sm.ListProfiles(alice)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, profileIDs(got), unowned.ID.String(),
|
assert.Contains(t, profileIDs(got), unowned.ID.String(),
|
||||||
"an unowned profile is not addressable until it is claimed")
|
"a profile that never had an owner stays usable until someone claims it")
|
||||||
|
assert.Contains(t, profileIDs(got), defaultProfileName,
|
||||||
|
"a fresh install has to be usable before anything is claimed")
|
||||||
|
|
||||||
|
nobody, err := sm.ListProfiles(ipcauth.Identity{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, profileIDs(nobody), "an unattested caller reaches nothing")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withLegacyLayout wires up the globals the way withTestSM does, without the
|
||||||
|
// identity, since these tests supply their own.
|
||||||
|
func withLegacyLayout(t *testing.T, fn func(sm *ServiceManager, configDir string)) {
|
||||||
|
t.Helper()
|
||||||
|
withTempConfigDir(t, func(configDir string) {
|
||||||
|
withPatchedGlobals(t, configDir, func() {
|
||||||
|
sm := &ServiceManager{}
|
||||||
|
require.NoError(t, sm.CreateDefaultProfile())
|
||||||
|
fn(sm, configDir)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeLegacyProfile drops a profile into a per-username directory, the layout
|
||||||
|
// every profile used before the ID-keyed one. It writes no name, since a legacy
|
||||||
|
// profile took its display name from the filename.
|
||||||
|
func writeLegacyProfile(t *testing.T, configDir, dirName, id string, fields map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(configDir, dirName)
|
||||||
|
require.NoError(t, os.MkdirAll(dir, 0700))
|
||||||
|
|
||||||
|
doc := map[string]any{}
|
||||||
|
for k, v := range fields {
|
||||||
|
doc[k] = v
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, id+".json")
|
||||||
|
require.NoError(t, util.WriteJson(context.Background(), path, doc))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubLegacyDir replaces the account lookup, so the claim path can be exercised
|
||||||
|
// without an account existing on the machine running the test.
|
||||||
|
func stubLegacyDir(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
orig := legacyDirForIdentity
|
||||||
|
legacyDirForIdentity = func(ipcauth.Identity) (string, bool) { return dir, dir != "" }
|
||||||
|
t.Cleanup(func() { legacyDirForIdentity = orig })
|
||||||
|
}
|
||||||
|
|
||||||
|
func readOwners(t *testing.T, path string) []string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
var meta ownerMeta
|
||||||
|
require.NoError(t, json.Unmarshal(data, &meta))
|
||||||
|
return meta.Owners
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListProfiles_UnownedLegacyProfileIsPrivilegedOnly(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
|
||||||
|
stubLegacyDir(t, "bob")
|
||||||
|
|
||||||
|
bob := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
|
got, err := sm.ListProfiles(bob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, profileIDs(got), "work",
|
||||||
|
"a profile sitting in someone else's directory is not free to take")
|
||||||
|
assert.Empty(t, readOwners(t, path), "and it is not claimed on the way past")
|
||||||
|
|
||||||
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
|
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
|
||||||
got, err = sm.ListProfiles(root)
|
got, err = sm.ListProfiles(root)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, profileIDs(got), unowned.ID.String())
|
assert.Contains(t, profileIDs(got), "work",
|
||||||
|
"root still reaches it, which is how it gets reassigned")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListProfiles_ClaimsLegacyProfileForItsOwnAccount(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
|
||||||
|
stubLegacyDir(t, "alice")
|
||||||
|
|
||||||
|
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
|
got, err := sm.ListProfiles(alice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, profileIDs(got), "work",
|
||||||
|
"the claim lands before the listing is filtered, so the gap closes in one call")
|
||||||
|
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
|
||||||
|
|
||||||
|
// The claim is on disk now, so it is the owner check and not the
|
||||||
|
// directory name that keeps the next caller out.
|
||||||
|
stubLegacyDir(t, "alice")
|
||||||
|
other := ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})
|
||||||
|
got, err = sm.ListProfiles(other)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotContains(t, profileIDs(got), "work")
|
||||||
|
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path),
|
||||||
|
"a second caller does not overwrite a stamped owner")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyProfile_LeavesTheProfileWhereItIs(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
path := writeLegacyProfile(t, configDir, "alice", "work", nil)
|
||||||
|
for _, suffix := range []string{".state.json", prefsFileSuffix} {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(configDir, "alice", "work"+suffix), []byte("{}"), 0600))
|
||||||
|
}
|
||||||
|
stubLegacyDir(t, "alice")
|
||||||
|
|
||||||
|
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
|
got, err := sm.ListProfiles(alice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
claimed := ownedProfile(t, got, "uid:4242")
|
||||||
|
assert.Equal(t, ID("work"), claimed.ID, "claiming does not re-key the profile")
|
||||||
|
assert.Equal(t, path, claimed.Path)
|
||||||
|
assert.Equal(t, "alice", claimed.LegacyUserDir,
|
||||||
|
"the directory is a leftover now, not something the claim rewrites")
|
||||||
|
|
||||||
|
// The engine captures its state file path at connect time and writes
|
||||||
|
// back to whatever it captured, so nothing here may move that file.
|
||||||
|
assert.FileExists(t, filepath.Join(configDir, "alice", "work.state.json"))
|
||||||
|
assert.FileExists(t, filepath.Join(configDir, "alice", "work"+prefsFileSuffix))
|
||||||
|
assert.NoFileExists(t, filepath.Join(configDir, DefaultProfilePathDir, "work.json"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyProfile_NamesakeInAnotherDirectoryIsUntouched(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
writeLegacyProfile(t, configDir, "alice", "work", nil)
|
||||||
|
bobs := writeLegacyProfile(t, configDir, "bob", "work", nil)
|
||||||
|
stubLegacyDir(t, "alice")
|
||||||
|
|
||||||
|
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
|
got, err := sm.ListProfiles(alice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
claimed := ownedProfile(t, got, "uid:4242")
|
||||||
|
assert.Equal(t, filepath.Join(configDir, "alice", "work.json"), claimed.Path,
|
||||||
|
"a legacy ID is a display name two accounts can hold, so the path is what tells them apart")
|
||||||
|
assert.Empty(t, readOwners(t, bobs), "bob's namesake is not claimed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ownedProfile(t *testing.T, profiles []Profile, principal string) Profile {
|
||||||
|
t.Helper()
|
||||||
|
var found []Profile
|
||||||
|
for _, p := range profiles {
|
||||||
|
if len(p.Owners) == 1 && p.Owners[0].String() == principal {
|
||||||
|
found = append(found, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Len(t, found, 1, "exactly one profile is owned by %s", principal)
|
||||||
|
return found[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyProfile_SkipsOneAlreadyOwnedInTheSameDirectory(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
// One unowned profile keeps the claim running over the directory, so
|
||||||
|
// the owned one beside it is reached and has to be left alone.
|
||||||
|
taken := writeLegacyProfile(t, configDir, "alice", "taken", map[string]any{
|
||||||
|
"Owners": []string{"uid:9999"},
|
||||||
|
})
|
||||||
|
free := writeLegacyProfile(t, configDir, "alice", "free", nil)
|
||||||
|
stubLegacyDir(t, "alice")
|
||||||
|
|
||||||
|
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
|
_, err := sm.ListProfiles(alice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"uid:9999"}, readOwners(t, taken),
|
||||||
|
"a profile that already has an owner is not restamped")
|
||||||
|
assert.Equal(t, []string{"uid:4242"}, readOwners(t, free))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListProfiles_PrivilegedCallerDoesNotClaim(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
path := writeLegacyProfile(t, configDir, "root", "work", nil)
|
||||||
|
stubLegacyDir(t, "root")
|
||||||
|
|
||||||
|
root := ipcauth.KnownForTest(ipcauth.Identity{UID: 0})
|
||||||
|
got, err := sm.ListProfiles(root)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, profileIDs(got), "work")
|
||||||
|
assert.Empty(t, readOwners(t, path),
|
||||||
|
"root reaches every profile anyway, so a listing must not stamp one")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStampOwner(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(_ *ServiceManager, configDir string) {
|
||||||
|
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
|
||||||
|
"DisableAutoConnect": true,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, StampOwner(path, ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})))
|
||||||
|
assert.Equal(t, []string{"uid:4242"}, readOwners(t, path))
|
||||||
|
|
||||||
|
var fields map[string]any
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, json.Unmarshal(data, &fields))
|
||||||
|
assert.Equal(t, true, fields["DisableAutoConnect"],
|
||||||
|
"the rest of the config survives the stamp")
|
||||||
|
|
||||||
|
require.NoError(t, StampOwner(path, ipcauth.KnownForTest(ipcauth.Identity{UID: 5252})))
|
||||||
|
assert.Equal(t, []string{"uid:5252"}, readOwners(t, path),
|
||||||
|
"stamping replaces whoever is recorded, the caller decides whether to")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimLegacyProfile_LeavesAnOwnerItCannotParse(t *testing.T) {
|
||||||
|
withLegacyLayout(t, func(sm *ServiceManager, configDir string) {
|
||||||
|
// A principal kind this build does not know, which is what a client
|
||||||
|
// newer than this one leaves behind after a downgrade. The loader
|
||||||
|
// drops such a profile rather than reporting it as unowned, which is
|
||||||
|
// what keeps the claim from treating it as free to take.
|
||||||
|
path := writeLegacyProfile(t, configDir, "alice", "work", map[string]any{
|
||||||
|
"Owners": []string{"group:devs"},
|
||||||
|
})
|
||||||
|
stubLegacyDir(t, "alice")
|
||||||
|
|
||||||
|
alice := ipcauth.KnownForTest(ipcauth.Identity{UID: 4242})
|
||||||
|
got, err := sm.ListProfiles(alice)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.NotContains(t, profileIDs(got), "work")
|
||||||
|
assert.Equal(t, []string{"group:devs"}, readOwners(t, path),
|
||||||
|
"the profile is not taken over, it waits for an explicit claim")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveLegacyDir_SanitizesTheSameStringTheOldLayoutDid(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("the windows path resolves a SID rather than a numeric uid")
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := user.Current()
|
||||||
|
require.NoError(t, err)
|
||||||
|
uid, err := strconv.ParseUint(u.Uid, 10, 32)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, ok := resolveLegacyDir(ipcauth.KnownForTest(ipcauth.Identity{UID: uint32(uid)}))
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, sanitizeProfileName(u.Username), got,
|
||||||
|
"the directory has to come from the account name, sanitized the way the old layout did")
|
||||||
|
}
|
||||||
|
|
||||||
func TestListProfiles_UnreadableOwnersAreSkipped(t *testing.T) {
|
func TestListProfiles_UnreadableOwnersAreSkipped(t *testing.T) {
|
||||||
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
withTestSM(t, func(sm *ServiceManager, _ ipcauth.Identity) {
|
||||||
configDir, err := sm.getConfigDir()
|
configDir, err := sm.getConfigDir()
|
||||||
|
|||||||
Reference in New Issue
Block a user