// Package tenant implements per-user filesystem isolation. Each tenant // is bound to a root directory; all filesystem operations on notes go // through an FS instance that uses os.Root to prevent path traversal, // symlink escapes, and any other access outside the root. package tenant import ( "errors" "fmt" "io" "io/fs" "os" "path" "strings" ) // ErrInvalidPath is returned when a relative path attempts to escape // the tenant root (e.g. via "..", absolute paths, or NUL bytes). var ErrInvalidPath = errors.New("invalid path") // ErrNotFound mirrors fs.ErrNotExist for notes lookups. var ErrNotFound = fs.ErrNotExist // FS is a sandboxed filesystem rooted at a single tenant's data // directory. All methods accept relative paths only; absolute paths, // "..", and any other escape attempts are rejected up front. Beneath // that, os.Root enforces the same boundary at the syscall level so // even a TOCTOU race cannot escape the root. type FS struct { root *os.Root dir string } // Open opens (creating if needed) the per-user directory at dir and // returns an FS that is restricted to it. func Open(dir string) (*FS, error) { if dir == "" { return nil, fmt.Errorf("tenant: empty dir") } if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("tenant: mkdir %s: %w", dir, err) } root, err := os.OpenRoot(dir) if err != nil { return nil, fmt.Errorf("tenant: open root %s: %w", dir, err) } return &FS{root: root, dir: dir}, nil } // Close releases the underlying os.Root handle. func (f *FS) Close() error { return f.root.Close() } // Dir returns the tenant root directory on the host filesystem. func (f *FS) Dir() string { return f.dir } // validate rejects paths we never want to evaluate, even before // handing them to os.Root. This catches obvious garbage with a // stable error type and avoids relying on platform-specific behaviour // of OpenInRoot for edge cases like NUL bytes. func validate(rel string) (string, error) { if rel == "" || rel == "." { return "", ErrInvalidPath } if strings.ContainsRune(rel, 0) { return "", ErrInvalidPath } if path.IsAbs(rel) || strings.HasPrefix(rel, "/") || strings.HasPrefix(rel, `\`) { return "", ErrInvalidPath } clean := path.Clean(rel) if clean == ".." || strings.HasPrefix(clean, "../") || clean == "." { return "", ErrInvalidPath } for _, seg := range strings.Split(clean, "/") { if seg == ".." { return "", ErrInvalidPath } } return clean, nil } // WriteFile writes data to rel atomically (write+rename) with mode 0o600. // Any necessary parent directories are created with mode 0o700. func (f *FS) WriteFile(rel string, data []byte) error { clean, err := validate(rel) if err != nil { return err } if dir := path.Dir(clean); dir != "." { if err := f.mkdirAll(dir); err != nil { return err } } tmp := clean + ".tmp" out, err := f.root.Create(tmp) if err != nil { return wrap(err) } if _, err := out.Write(data); err != nil { _ = out.Close() _ = f.root.Remove(tmp) return err } if err := out.Close(); err != nil { _ = f.root.Remove(tmp) return err } if err := f.root.Rename(tmp, clean); err != nil { _ = f.root.Remove(tmp) return wrap(err) } return nil } // ReadFile returns the contents of rel. func (f *FS) ReadFile(rel string) ([]byte, error) { clean, err := validate(rel) if err != nil { return nil, err } in, err := f.root.Open(clean) if err != nil { return nil, wrap(err) } defer in.Close() return io.ReadAll(in) } // Stat returns FileInfo for rel. func (f *FS) Stat(rel string) (os.FileInfo, error) { clean, err := validate(rel) if err != nil { return nil, err } fi, err := f.root.Stat(clean) if err != nil { return nil, wrap(err) } return fi, nil } // Remove deletes a file. Empty directories must be removed via the // underlying root; we expose only file deletes for the notes API. func (f *FS) Remove(rel string) error { clean, err := validate(rel) if err != nil { return err } return wrap(f.root.Remove(clean)) } // List returns the names of entries directly under rel (use "." for // the root). Symlink targets outside the tenant root cannot exist // here because os.Root refuses to create them, but if a malicious // link is dropped on disk out-of-band, calls that would follow it // will return an error rather than escape. func (f *FS) List(rel string) ([]string, error) { if rel == "." || rel == "" { entries, err := os.ReadDir(f.dir) if err != nil { return nil, err } return entryNames(entries), nil } clean, err := validate(rel) if err != nil { return nil, err } dir, err := f.root.Open(clean) if err != nil { return nil, wrap(err) } defer dir.Close() entries, err := dir.ReadDir(-1) if err != nil { return nil, err } return entryNames(entries), nil } func entryNames(entries []fs.DirEntry) []string { out := make([]string, 0, len(entries)) for _, e := range entries { out = append(out, e.Name()) } return out } // mkdirAll creates rel and all missing parents within the root, mode 0o700. func (f *FS) mkdirAll(rel string) error { parts := strings.Split(rel, "/") cur := "" for _, p := range parts { if p == "" { continue } if cur == "" { cur = p } else { cur = cur + "/" + p } if err := f.root.Mkdir(cur, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { return wrap(err) } } return nil } // wrap normalises errors from os.Root into our error vocabulary so // callers can use errors.Is(err, ErrInvalidPath) / ErrNotFound. func wrap(err error) error { if err == nil { return nil } if errors.Is(err, fs.ErrNotExist) { return ErrNotFound } // os.Root returns errors whose text mentions "outside root" or // "path escapes from parent". Map those to ErrInvalidPath so the // HTTP layer can return 400/403 consistently. msg := err.Error() if strings.Contains(msg, "outside") || strings.Contains(msg, "escape") || strings.Contains(msg, "openat") { return ErrInvalidPath } return err }