4f08e5e7c3
New top-nav category with the Halozati megosztas page: enable/server-name card, household password, shares table (Nev/Mappa/Irasvedett/Felhomentes/Torles), and a create flow (new folder under <storage>/shares or an existing folder via the browse modal). Every customer path goes through sharingResolvePath: absolute -> EvalSymlinks -> containment in a registered live storage root -> deny-listed system subtree check -> is-a-directory. Refusals are UNIFORM so the picker is never a filesystem oracle. Deny-list derived from ProtectedHDDPaths (provably a subset); the drive root is an exact-match denial so user-data folders under it stay shareable. samba infra metadata + i-share icon. Gates green.
130 lines
4.6 KiB
Go
130 lines
4.6 KiB
Go
package web
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// newSharingServer returns a Server with ONE registered storage root laid out like a real drive
|
|
// (appdata/, backups/, media/filmek, shares/) so the guard runs against a realistic tree.
|
|
func newSharingServer(t *testing.T) (*Server, string) {
|
|
t.Helper()
|
|
lg := log.New(io.Discard, "", 0)
|
|
root := t.TempDir()
|
|
drive := filepath.Join(root, "drive")
|
|
for _, d := range []string{"appdata/paperless", "backups/unit", "media/filmek", "shares"} {
|
|
if err := os.MkdirAll(filepath.Join(drive, filepath.FromSlash(d)), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
sett, err := settings.Load(filepath.Join(root, "settings.json"), lg)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "teszt", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return &Server{settings: sett, logger: lg, cfg: &config.Config{}}, drive
|
|
}
|
|
|
|
// Scenario C: the picker/create guard matrix. Every refusal must ALSO be a non-effect.
|
|
func TestSharingResolvePath_GuardMatrix(t *testing.T) {
|
|
s, drive := newSharingServer(t)
|
|
outside := t.TempDir() // a real dir, but under no registered storage root
|
|
|
|
refused := []struct{ name, path string }{
|
|
{"appdata subtree (live app DB)", filepath.Join(drive, "appdata", "paperless")},
|
|
{"appdata root", filepath.Join(drive, "appdata")},
|
|
{"backups subtree", filepath.Join(drive, "backups", "unit")},
|
|
{"the drive root itself", drive},
|
|
{"outside every registered root", outside},
|
|
{"relative path", "nem/abszolut"},
|
|
{"empty", ""},
|
|
{"traversal out of the root", filepath.Join(drive, "..", "escape")},
|
|
{"nonexistent", filepath.Join(drive, "nincs-ilyen")},
|
|
}
|
|
for _, tc := range refused {
|
|
if got, err := s.sharingResolvePath(tc.path); err == nil {
|
|
t.Errorf("%s: must be refused, got %q", tc.name, got)
|
|
}
|
|
}
|
|
|
|
// The guard is not "deny everything": a real user-data folder IS shareable (Scenario B shares
|
|
// media/filmek). Without this the refusal tests above would pass on a broken always-deny guard.
|
|
good := filepath.Join(drive, "media", "filmek")
|
|
if _, err := s.sharingResolvePath(good); err != nil {
|
|
t.Errorf("a user-data folder must be shareable, got %v", err)
|
|
}
|
|
if _, err := s.sharingResolvePath(filepath.Join(drive, "shares")); err != nil {
|
|
t.Errorf("the shares dir must be shareable, got %v", err)
|
|
}
|
|
}
|
|
|
|
// A symlink planted INSIDE a registered root that points OUTSIDE it must not escape the guard —
|
|
// this is why EvalSymlinks runs before the containment assert.
|
|
func TestSharingResolvePath_SymlinkEscapeRefused(t *testing.T) {
|
|
s, drive := newSharingServer(t)
|
|
outside := t.TempDir()
|
|
link := filepath.Join(drive, "media", "escape-link")
|
|
if err := os.Symlink(outside, link); err != nil {
|
|
t.Skipf("symlinks unavailable on this platform/privilege level: %v", err)
|
|
}
|
|
if got, err := s.sharingResolvePath(link); err == nil {
|
|
t.Errorf("a symlink escaping the storage root must be refused, resolved to %q", got)
|
|
}
|
|
}
|
|
|
|
// A decommissioned storage root stops being shareable.
|
|
func TestSharingResolvePath_DecommissionedRootRefused(t *testing.T) {
|
|
s, drive := newSharingServer(t)
|
|
good := filepath.Join(drive, "media", "filmek")
|
|
if _, err := s.sharingResolvePath(good); err != nil {
|
|
t.Fatalf("precondition: %v", err)
|
|
}
|
|
if err := s.settings.SetDecommissioned(drive, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.sharingResolvePath(good); err == nil {
|
|
t.Error("a decommissioned storage root must not be shareable")
|
|
}
|
|
}
|
|
|
|
// Every refusal returns the SAME message — a per-reason message would make the picker an oracle
|
|
// for the existence/contents of paths outside the customer's storage.
|
|
func TestSharingResolvePath_UniformRefusal(t *testing.T) {
|
|
s, drive := newSharingServer(t)
|
|
outside := t.TempDir()
|
|
for _, p := range []string{
|
|
filepath.Join(drive, "appdata"),
|
|
outside,
|
|
filepath.Join(drive, "nincs-ilyen"),
|
|
} {
|
|
_, err := s.sharingResolvePath(p)
|
|
if err == nil {
|
|
t.Fatalf("%s should refuse", p)
|
|
}
|
|
if err.Error() != errNotShareable.Error() {
|
|
t.Errorf("refusal message must be uniform, got %q for %s", err.Error(), p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// pathWithin must be segment-wise: a sibling directory sharing a name PREFIX is not containment.
|
|
func TestPathWithin_SiblingPrefixIsNotContainment(t *testing.T) {
|
|
if pathWithin("/mnt/drive-evil/x", "/mnt/drive") {
|
|
t.Error("/mnt/drive-evil must NOT count as inside /mnt/drive")
|
|
}
|
|
if !pathWithin("/mnt/drive/x", "/mnt/drive") {
|
|
t.Error("/mnt/drive/x must count as inside /mnt/drive")
|
|
}
|
|
if !pathWithin("/mnt/drive", "/mnt/drive") {
|
|
t.Error("a root is within itself")
|
|
}
|
|
}
|