diff --git a/pkg/doctor/files_unix.go b/pkg/doctor/files_unix.go
index 3f0e9a785..c21a3a025 100644
--- a/pkg/doctor/files_unix.go
+++ b/pkg/doctor/files_unix.go
@@ -25,6 +25,7 @@ import (
"strconv"
"syscall"
+ "code.vikunja.io/api/pkg/utils"
"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{
Name: "Ownership match",
Passed: false,
- Error: fmt.Sprintf(
- "directory owned by uid %d but Vikunja runs as uid %d",
- uid, currentUID,
- ),
+ Error: errMsg,
})
- } else if currentUID != 0 && !isGroupMember(int(gid)) {
+ case currentUID != 0 && !isGroupMember(int(gid)):
results = append(results, CheckResult{
Name: "Ownership match",
Passed: false,
@@ -121,6 +131,21 @@ func checkDirectoryOwnership(info os.FileInfo) []CheckResult {
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
diff --git a/pkg/doctor/system.go b/pkg/doctor/system.go
index 50f720ccf..b7d487e95 100644
--- a/pkg/doctor/system.go
+++ b/pkg/doctor/system.go
@@ -32,6 +32,7 @@ func CheckSystem() CheckGroup {
checkOS(),
checkUser(),
checkWorkingDirectory(),
+ checkUserNamespace(),
}
return CheckGroup{
diff --git a/pkg/doctor/userns_linux.go b/pkg/doctor/userns_linux.go
new file mode 100644
index 000000000..dbcecfa87
--- /dev/null
+++ b/pkg/doctor/userns_linux.go
@@ -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 .
+
+//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."},
+ }
+}
diff --git a/pkg/doctor/userns_other.go b/pkg/doctor/userns_other.go
new file mode 100644
index 000000000..28edf32a3
--- /dev/null
+++ b/pkg/doctor/userns_other.go
@@ -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 .
+
+//go:build !linux
+
+package doctor
+
+func checkUserNamespace() CheckResult {
+ return CheckResult{
+ Name: "User namespace",
+ Passed: true,
+ Value: "not applicable (Linux only)",
+ }
+}
diff --git a/pkg/files/diagnostics_unix.go b/pkg/files/diagnostics_unix.go
new file mode 100644
index 000000000..b7ae353fa
--- /dev/null
+++ b/pkg/files/diagnostics_unix.go
@@ -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 .
+
+//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
+}
diff --git a/pkg/files/diagnostics_windows.go b/pkg/files/diagnostics_windows.go
new file mode 100644
index 000000000..28def15f0
--- /dev/null
+++ b/pkg/files/diagnostics_windows.go
@@ -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 .
+
+//go:build windows
+
+package files
+
+func storageDiagnosticInfo(_ string) string {
+ return ""
+}
diff --git a/pkg/files/filehandling.go b/pkg/files/filehandling.go
index fa5f9b9ab..e47858f6d 100644
--- a/pkg/files/filehandling.go
+++ b/pkg/files/filehandling.go
@@ -196,6 +196,11 @@ func FileStat(file *File) (os.FileInfo, error) {
func ValidateFileStorage() error {
basePath := config.FilesBasePath.GetString()
+ diag := storageDiagnosticInfo(basePath)
+ if diag != "" {
+ diag = "\n" + diag
+ }
+
// For local filesystem, ensure the base directory exists
if config.FilesType.GetString() == "local" {
// Check if directory exists
@@ -203,13 +208,13 @@ func ValidateFileStorage() error {
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
// 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
err = afs.MkdirAll(basePath, 0755)
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() {
// Path exists but is not a directory
@@ -222,7 +227,7 @@ func ValidateFileStorage() error {
err := writeToStorage(path, bytes.NewReader([]byte{}), 0)
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)
diff --git a/pkg/utils/userns_linux.go b/pkg/utils/userns_linux.go
new file mode 100644
index 000000000..b4cf69c91
--- /dev/null
+++ b/pkg/utils/userns_linux.go
@@ -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 .
+
+//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)
+}
diff --git a/pkg/utils/userns_linux_test.go b/pkg/utils/userns_linux_test.go
new file mode 100644
index 000000000..8a8c4836a
--- /dev/null
+++ b/pkg/utils/userns_linux_test.go
@@ -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 .
+
+//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)
+}
diff --git a/pkg/utils/userns_other.go b/pkg/utils/userns_other.go
new file mode 100644
index 000000000..ce868dbf2
--- /dev/null
+++ b/pkg/utils/userns_other.go
@@ -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 .
+
+//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 "" }