mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 14:44:05 +00:00
feat(doctor): add user namespace detection and improved storage diagnostics (#2180)
This PR adds support for detecting and handling Linux user namespaces (commonly used in rootless Docker containers) and improves error diagnostics when file storage validation fails. Docs PR: https://github.com/go-vikunja/website/pull/289 --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/utils"
|
||||||
"golang.org/x/sys/unix"
|
"golang.org/x/sys/unix"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,16 +104,25 @@ func checkDirectoryOwnership(info os.FileInfo) []CheckResult {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentUID != 0 && currentUID != int(uid) {
|
nsActive := utils.IsUserNamespaceActive()
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case currentUID != 0 && currentUID != int(uid):
|
||||||
|
errMsg := fmt.Sprintf(
|
||||||
|
"directory owned by uid %d but Vikunja runs as uid %d",
|
||||||
|
uid, currentUID,
|
||||||
|
)
|
||||||
|
if nsActive {
|
||||||
|
if hostUID, ok := utils.MapToHostUID(int64(currentUID)); ok {
|
||||||
|
errMsg += fmt.Sprintf(" (user namespace active, host uid=%d)", hostUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
results = append(results, CheckResult{
|
results = append(results, CheckResult{
|
||||||
Name: "Ownership match",
|
Name: "Ownership match",
|
||||||
Passed: false,
|
Passed: false,
|
||||||
Error: fmt.Sprintf(
|
Error: errMsg,
|
||||||
"directory owned by uid %d but Vikunja runs as uid %d",
|
|
||||||
uid, currentUID,
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
} else if currentUID != 0 && !isGroupMember(int(gid)) {
|
case currentUID != 0 && !isGroupMember(int(gid)):
|
||||||
results = append(results, CheckResult{
|
results = append(results, CheckResult{
|
||||||
Name: "Ownership match",
|
Name: "Ownership match",
|
||||||
Passed: false,
|
Passed: false,
|
||||||
@@ -121,6 +131,21 @@ func checkDirectoryOwnership(info os.FileInfo) []CheckResult {
|
|||||||
gid,
|
gid,
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
case currentUID != 0 && nsActive:
|
||||||
|
matchResult := CheckResult{
|
||||||
|
Name: "Ownership match",
|
||||||
|
Passed: true,
|
||||||
|
Value: fmt.Sprintf("uid %d matches", currentUID),
|
||||||
|
}
|
||||||
|
if hostUID, ok := utils.MapToHostUID(int64(currentUID)); ok {
|
||||||
|
matchResult.Lines = []string{
|
||||||
|
fmt.Sprintf("WARNING: user namespace active — uid %d maps to host uid %d,", currentUID, hostUID),
|
||||||
|
"which may differ from the actual host directory owner.",
|
||||||
|
"If writes fail, ensure the host directory is owned by the mapped host uid,",
|
||||||
|
"or run the container with --user 0:0.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results = append(results, matchResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func CheckSystem() CheckGroup {
|
|||||||
checkOS(),
|
checkOS(),
|
||||||
checkUser(),
|
checkUser(),
|
||||||
checkWorkingDirectory(),
|
checkWorkingDirectory(),
|
||||||
|
checkUserNamespace(),
|
||||||
}
|
}
|
||||||
|
|
||||||
return CheckGroup{
|
return CheckGroup{
|
||||||
|
|||||||
40
pkg/doctor/userns_linux.go
Normal file
40
pkg/doctor/userns_linux.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"code.vikunja.io/api/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkUserNamespace() CheckResult {
|
||||||
|
if !utils.IsUserNamespaceActive() {
|
||||||
|
return CheckResult{
|
||||||
|
Name: "User namespace",
|
||||||
|
Passed: true,
|
||||||
|
Value: "not active",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CheckResult{
|
||||||
|
Name: "User namespace",
|
||||||
|
Passed: true,
|
||||||
|
Value: "active (" + utils.UIDMappingSummary() + ")",
|
||||||
|
Lines: []string{"UIDs inside this container are remapped. See directory ownership check for details."},
|
||||||
|
}
|
||||||
|
}
|
||||||
27
pkg/doctor/userns_other.go
Normal file
27
pkg/doctor/userns_other.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package doctor
|
||||||
|
|
||||||
|
func checkUserNamespace() CheckResult {
|
||||||
|
return CheckResult{
|
||||||
|
Name: "User namespace",
|
||||||
|
Passed: true,
|
||||||
|
Value: "not applicable (Linux only)",
|
||||||
|
}
|
||||||
|
}
|
||||||
69
pkg/files/diagnostics_unix.go
Normal file
69
pkg/files/diagnostics_unix.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"code.vikunja.io/api/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storageDiagnosticInfo gathers process/directory identity and user namespace
|
||||||
|
// status. It is best-effort: any failure is silently omitted.
|
||||||
|
func storageDiagnosticInfo(basePath string) string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
uid := os.Getuid()
|
||||||
|
gid := os.Getgid()
|
||||||
|
parts = append(parts, fmt.Sprintf("process uid=%d gid=%d", uid, gid))
|
||||||
|
|
||||||
|
info, err := os.Stat(basePath)
|
||||||
|
if err == nil {
|
||||||
|
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
|
||||||
|
parts = append(parts, fmt.Sprintf("dir owner uid=%d gid=%d", stat.Uid, stat.Gid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if utils.IsUserNamespaceActive() {
|
||||||
|
summary := utils.UIDMappingSummary()
|
||||||
|
parts = append(parts, fmt.Sprintf("user namespace ACTIVE (%s)", summary))
|
||||||
|
|
||||||
|
if hostUID, ok := utils.MapToHostUID(int64(uid)); ok {
|
||||||
|
parts = append(parts, fmt.Sprintf("process host uid=%d", hostUID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := "[" + strings.Join(parts, ", ") + "]"
|
||||||
|
|
||||||
|
if utils.IsUserNamespaceActive() {
|
||||||
|
hostUID, ok := utils.MapToHostUID(int64(uid))
|
||||||
|
if ok {
|
||||||
|
result += fmt.Sprintf(
|
||||||
|
"\n Hint: A user namespace is active (common in rootless Docker). "+
|
||||||
|
"The process appears as uid %d inside the container but maps to uid %d on the host. "+
|
||||||
|
"Ensure the host directory is owned by uid %d, or run the container with --user 0:0.",
|
||||||
|
uid, hostUID, hostUID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
23
pkg/files/diagnostics_windows.go
Normal file
23
pkg/files/diagnostics_windows.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package files
|
||||||
|
|
||||||
|
func storageDiagnosticInfo(_ string) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -196,6 +196,11 @@ func FileStat(file *File) (os.FileInfo, error) {
|
|||||||
func ValidateFileStorage() error {
|
func ValidateFileStorage() error {
|
||||||
basePath := config.FilesBasePath.GetString()
|
basePath := config.FilesBasePath.GetString()
|
||||||
|
|
||||||
|
diag := storageDiagnosticInfo(basePath)
|
||||||
|
if diag != "" {
|
||||||
|
diag = "\n" + diag
|
||||||
|
}
|
||||||
|
|
||||||
// For local filesystem, ensure the base directory exists
|
// For local filesystem, ensure the base directory exists
|
||||||
if config.FilesType.GetString() == "local" {
|
if config.FilesType.GetString() == "local" {
|
||||||
// Check if directory exists
|
// Check if directory exists
|
||||||
@@ -203,13 +208,13 @@ func ValidateFileStorage() error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
if !errors.Is(err, os.ErrNotExist) {
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
// Error other than "file doesn't exist"
|
// Error other than "file doesn't exist"
|
||||||
return fmt.Errorf("failed to access file storage directory at %s: %w", basePath, err)
|
return fmt.Errorf("failed to access file storage directory at %s: %w%s", basePath, err, diag)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Directory doesn't exist, try to create it
|
// Directory doesn't exist, try to create it
|
||||||
err = afs.MkdirAll(basePath, 0755)
|
err = afs.MkdirAll(basePath, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create file storage directory at %s: %w", basePath, err)
|
return fmt.Errorf("failed to create file storage directory at %s: %w%s", basePath, err, diag)
|
||||||
}
|
}
|
||||||
} else if !info.IsDir() {
|
} else if !info.IsDir() {
|
||||||
// Path exists but is not a directory
|
// Path exists but is not a directory
|
||||||
@@ -222,7 +227,7 @@ func ValidateFileStorage() error {
|
|||||||
|
|
||||||
err := writeToStorage(path, bytes.NewReader([]byte{}), 0)
|
err := writeToStorage(path, bytes.NewReader([]byte{}), 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create test file at %s: %w", path, err)
|
return fmt.Errorf("failed to create test file at %s: %w%s", path, err, diag)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = afs.Remove(path)
|
err = afs.Remove(path)
|
||||||
|
|||||||
156
pkg/utils/userns_linux.go
Normal file
156
pkg/utils/userns_linux.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UIDMapEntry represents a single line from /proc/self/uid_map.
|
||||||
|
// Fields use int64 to avoid overflow on 32-bit architectures where the
|
||||||
|
// trivial mapping count (4294967295) exceeds math.MaxInt32.
|
||||||
|
type UIDMapEntry struct {
|
||||||
|
InsideUID int64
|
||||||
|
OutsideUID int64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
uidMapOnce sync.Once
|
||||||
|
uidMapEntries []UIDMapEntry
|
||||||
|
uidMapErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadUIDMap() {
|
||||||
|
data, err := os.ReadFile("/proc/self/uid_map")
|
||||||
|
if err != nil {
|
||||||
|
uidMapErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uidMapEntries, uidMapErr = parseUIDMap(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUIDMap(content string) ([]UIDMapEntry, error) {
|
||||||
|
var entries []UIDMapEntry
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(content), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) != 3 {
|
||||||
|
return nil, fmt.Errorf("unexpected uid_map line: %q", line)
|
||||||
|
}
|
||||||
|
inside, err := strconv.ParseInt(fields[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing inside uid %q: %w", fields[0], err)
|
||||||
|
}
|
||||||
|
outside, err := strconv.ParseInt(fields[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing outside uid %q: %w", fields[1], err)
|
||||||
|
}
|
||||||
|
count, err := strconv.ParseInt(fields[2], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing count %q: %w", fields[2], err)
|
||||||
|
}
|
||||||
|
entries = append(entries, UIDMapEntry{
|
||||||
|
InsideUID: inside,
|
||||||
|
OutsideUID: outside,
|
||||||
|
Count: count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTrivialMapping(entries []UIDMapEntry) bool {
|
||||||
|
if len(entries) != 1 {
|
||||||
|
return len(entries) == 0
|
||||||
|
}
|
||||||
|
e := entries[0]
|
||||||
|
return e.InsideUID == 0 && e.OutsideUID == 0 && e.Count == 4294967295
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapContainerUID(entries []UIDMapEntry, containerUID int64) (hostUID int64, ok bool) {
|
||||||
|
for _, e := range entries {
|
||||||
|
if containerUID >= e.InsideUID && containerUID < e.InsideUID+e.Count {
|
||||||
|
return e.OutsideUID + (containerUID - e.InsideUID), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func uidMappingSummaryFromEntries(entries []UIDMapEntry) string {
|
||||||
|
var parts []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Count == 1 {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d→%d", e.InsideUID, e.OutsideUID))
|
||||||
|
} else {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d-%d→%d-%d",
|
||||||
|
e.InsideUID, e.InsideUID+e.Count-1,
|
||||||
|
e.OutsideUID, e.OutsideUID+e.Count-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserNamespaceActive returns true if the process is running inside a
|
||||||
|
// Linux user namespace with non-trivial UID remapping (e.g., rootless Docker).
|
||||||
|
func IsUserNamespaceActive() bool {
|
||||||
|
uidMapOnce.Do(loadUIDMap)
|
||||||
|
if uidMapErr != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !isTrivialMapping(uidMapEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUIDMapping returns a copy of the parsed uid_map entries.
|
||||||
|
// The returned slice is safe to modify without affecting cached state.
|
||||||
|
func GetUIDMapping() ([]UIDMapEntry, error) {
|
||||||
|
uidMapOnce.Do(loadUIDMap)
|
||||||
|
if uidMapEntries == nil {
|
||||||
|
return nil, uidMapErr
|
||||||
|
}
|
||||||
|
out := make([]UIDMapEntry, len(uidMapEntries))
|
||||||
|
copy(out, uidMapEntries)
|
||||||
|
return out, uidMapErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// MapToHostUID maps a container UID to the corresponding host UID.
|
||||||
|
// Returns mapped=false if no mapping covers that UID.
|
||||||
|
func MapToHostUID(containerUID int64) (hostUID int64, mapped bool) {
|
||||||
|
uidMapOnce.Do(loadUIDMap)
|
||||||
|
if uidMapErr != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return mapContainerUID(uidMapEntries, containerUID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UIDMappingSummary returns a human-readable summary of the UID mapping,
|
||||||
|
// e.g., "0→1001, 1-65536→101001-166536".
|
||||||
|
func UIDMappingSummary() string {
|
||||||
|
uidMapOnce.Do(loadUIDMap)
|
||||||
|
if uidMapErr != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return uidMappingSummaryFromEntries(uidMapEntries)
|
||||||
|
}
|
||||||
111
pkg/utils/userns_linux_test.go
Normal file
111
pkg/utils/userns_linux_test.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseUIDMap_Trivial(t *testing.T) {
|
||||||
|
input := " 0 0 4294967295\n"
|
||||||
|
entries, err := parseUIDMap(input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, entries, 1)
|
||||||
|
assert.Equal(t, UIDMapEntry{InsideUID: 0, OutsideUID: 0, Count: 4294967295}, entries[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUIDMap_RootlessDocker(t *testing.T) {
|
||||||
|
input := " 0 1001 1\n 1 101001 65536\n"
|
||||||
|
entries, err := parseUIDMap(input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, entries, 2)
|
||||||
|
assert.Equal(t, UIDMapEntry{InsideUID: 0, OutsideUID: 1001, Count: 1}, entries[0])
|
||||||
|
assert.Equal(t, UIDMapEntry{InsideUID: 1, OutsideUID: 101001, Count: 65536}, entries[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUIDMap_LargeCount(t *testing.T) {
|
||||||
|
// Verify that the full 32-bit range parses correctly (would overflow int on 32-bit arch).
|
||||||
|
input := " 0 0 4294967295\n"
|
||||||
|
entries, err := parseUIDMap(input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(4294967295), entries[0].Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseUIDMap_Empty(t *testing.T) {
|
||||||
|
entries, err := parseUIDMap("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTrivialMapping(t *testing.T) {
|
||||||
|
trivial := []UIDMapEntry{{InsideUID: 0, OutsideUID: 0, Count: 4294967295}}
|
||||||
|
assert.True(t, isTrivialMapping(trivial))
|
||||||
|
|
||||||
|
rootless := []UIDMapEntry{
|
||||||
|
{InsideUID: 0, OutsideUID: 1001, Count: 1},
|
||||||
|
{InsideUID: 1, OutsideUID: 101001, Count: 65536},
|
||||||
|
}
|
||||||
|
assert.False(t, isTrivialMapping(rootless))
|
||||||
|
|
||||||
|
assert.True(t, isTrivialMapping(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapToHostUID(t *testing.T) {
|
||||||
|
entries := []UIDMapEntry{
|
||||||
|
{InsideUID: 0, OutsideUID: 1001, Count: 1},
|
||||||
|
{InsideUID: 1, OutsideUID: 101001, Count: 65536},
|
||||||
|
}
|
||||||
|
|
||||||
|
hostUID, ok := mapContainerUID(entries, 0)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, int64(1001), hostUID)
|
||||||
|
|
||||||
|
hostUID, ok = mapContainerUID(entries, 1)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, int64(101001), hostUID)
|
||||||
|
|
||||||
|
hostUID, ok = mapContainerUID(entries, 1000)
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, int64(102000), hostUID)
|
||||||
|
|
||||||
|
_, ok = mapContainerUID(entries, 70000)
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIDMappingSummaryString(t *testing.T) {
|
||||||
|
entries := []UIDMapEntry{
|
||||||
|
{InsideUID: 0, OutsideUID: 1001, Count: 1},
|
||||||
|
{InsideUID: 1, OutsideUID: 101001, Count: 65536},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := uidMappingSummaryFromEntries(entries)
|
||||||
|
assert.Equal(t, "0→1001, 1-65536→101001-166536", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIDMappingSummaryString_Single(t *testing.T) {
|
||||||
|
entries := []UIDMapEntry{
|
||||||
|
{InsideUID: 0, OutsideUID: 1001, Count: 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
summary := uidMappingSummaryFromEntries(entries)
|
||||||
|
assert.Equal(t, "0→1001", summary)
|
||||||
|
}
|
||||||
40
pkg/utils/userns_other.go
Normal file
40
pkg/utils/userns_other.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// Vikunja is a to-do list application to facilitate your life.
|
||||||
|
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||||
|
//
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package utils
|
||||||
|
|
||||||
|
// UIDMapEntry represents a single line from /proc/self/uid_map.
|
||||||
|
// On non-Linux platforms this type exists for API compatibility but is never populated.
|
||||||
|
// Fields use int64 to avoid overflow on 32-bit architectures.
|
||||||
|
type UIDMapEntry struct {
|
||||||
|
InsideUID int64
|
||||||
|
OutsideUID int64
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUserNamespaceActive always returns false on non-Linux platforms.
|
||||||
|
func IsUserNamespaceActive() bool { return false }
|
||||||
|
|
||||||
|
// GetUIDMapping returns nil on non-Linux platforms.
|
||||||
|
func GetUIDMapping() ([]UIDMapEntry, error) { return nil, nil }
|
||||||
|
|
||||||
|
// MapToHostUID always returns mapped=false on non-Linux platforms.
|
||||||
|
func MapToHostUID(_ int64) (int64, bool) { return 0, false }
|
||||||
|
|
||||||
|
// UIDMappingSummary returns empty string on non-Linux platforms.
|
||||||
|
func UIDMappingSummary() string { return "" }
|
||||||
Reference in New Issue
Block a user