refactor: flatten repository structure to root and archive research notes
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func TestFolderDownloadModes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "parent", "child"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(root, "parent", "a.txt"),
|
||||
filepath.Join(root, "parent", "child", "b.txt"),
|
||||
} {
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
mk := func(mode string) *Server {
|
||||
return &Server{
|
||||
Store: storage.Local{Root: root},
|
||||
Auth: &auth.Manager{
|
||||
Accounts: map[string]*auth.Account{},
|
||||
Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete},
|
||||
},
|
||||
FolderDownload: mode,
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
mode, path string
|
||||
want int
|
||||
}{
|
||||
{"disable", "parent", http.StatusForbidden},
|
||||
{"leaf", "parent", http.StatusForbidden},
|
||||
{"leaf", "parent/child", http.StatusOK},
|
||||
{"enable", "parent", http.StatusOK},
|
||||
} {
|
||||
t.Run(tc.mode+"-"+tc.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/"+tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
mk(tc.mode).Handler().ServeHTTP(w, req)
|
||||
if w.Code != tc.want {
|
||||
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.want, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderDownloadZipContainsOnlyReadableFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "parent", "child"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "parent", "a.txt"), []byte("A"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "parent", "child", "b.txt"), []byte("B"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}, FolderDownload: "enable"}
|
||||
r := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/parent", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", w.Code)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("invalid zip: %v", err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, f := range zr.File {
|
||||
body, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := io.ReadAll(body)
|
||||
body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[f.Name] = string(b)
|
||||
}
|
||||
want := map[string]string{"a.txt": "A", "child/b.txt": "B"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("zip entries=%#v want=%#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type healthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
var Version = "dev"
|
||||
|
||||
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(healthResponse{Status: "ok", Version: Version})
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func TestCheckDeleteTreeRejectsDeniedDescendant(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "public", "private"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "public", "private", "secret.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := storage.Local{Root: root}
|
||||
account := &auth.Account{Mask: auth.Delete, Rules: []auth.PathRule{{Dir: "public/private", Mask: auth.None}}}
|
||||
if err := checkDeleteTree(store, account, "public"); err == nil {
|
||||
t.Fatal("expected denied descendant to block recursive deletion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsafePath(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"../secret": true,
|
||||
"a/../secret": true,
|
||||
`..\\secret`: true,
|
||||
"%2e%2e/secret": false,
|
||||
"secret": false,
|
||||
"a//b": false,
|
||||
"": false,
|
||||
}
|
||||
for p, want := range cases {
|
||||
if got := unsafePath(p); got != want {
|
||||
t.Fatalf("unsafePath(%q)=%v, want %v", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func permissionTestServer(t *testing.T, mask auth.PermMask) (*Server, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "file.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "dir", "nested.txt"), []byte("nested"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{
|
||||
Store: storage.Local{Root: root},
|
||||
Auth: &auth.Manager{
|
||||
Accounts: map[string]*auth.Account{},
|
||||
Guest: &auth.Account{Mask: mask},
|
||||
},
|
||||
RemoveMode: 3,
|
||||
ImagePreview: true,
|
||||
}
|
||||
return s, root
|
||||
}
|
||||
|
||||
func doRequest(h http.Handler, method, target string, body io.Reader, contentType string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, target, body)
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
func multipartField(t *testing.T, field, value string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
if err := mw.WriteField(field, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &b, mw.FormDataContentType()
|
||||
}
|
||||
|
||||
func multipartUpload(t *testing.T, name, data string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := io.WriteString(fw, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.WriteField("folder", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &b, mw.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestHTTPPermissionMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mask auth.PermMask
|
||||
read int
|
||||
write int
|
||||
del int
|
||||
}{
|
||||
{"none", auth.None, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||
{"read", auth.Read, http.StatusOK, http.StatusForbidden, http.StatusForbidden},
|
||||
{"read-write", auth.Read | auth.Write, http.StatusOK, http.StatusCreated, http.StatusForbidden},
|
||||
{"read-write-delete", auth.Read | auth.Write | auth.Delete, http.StatusOK, http.StatusCreated, http.StatusNoContent},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, root := permissionTestServer(t, tc.mask)
|
||||
h := s.Handler()
|
||||
|
||||
files := doRequest(h, http.MethodGet, "/chfs/files?filepath=", nil, "")
|
||||
if files.Code != http.StatusOK {
|
||||
t.Fatalf("files: got %d", files.Code)
|
||||
}
|
||||
|
||||
body, ct := multipartUpload(t, "uploaded.txt", "upload")
|
||||
upload := doRequest(h, http.MethodPost, "/chfs/upload", body, ct)
|
||||
if upload.Code != tc.write {
|
||||
t.Errorf("upload: got %d want %d", upload.Code, tc.write)
|
||||
}
|
||||
_, _ = os.Stat(filepath.Join(root, "uploaded.txt"))
|
||||
|
||||
body, ct = multipartField(t, "filepath", "newdir")
|
||||
newdir := doRequest(h, http.MethodPost, "/chfs/newdir", body, ct)
|
||||
if newdir.Code != tc.write {
|
||||
t.Errorf("newdir: got %d want %d", newdir.Code, tc.write)
|
||||
}
|
||||
|
||||
body, ct = multipartField(t, "old", "file.txt")
|
||||
body2, _ := multipartField(t, "new", "renamed.txt")
|
||||
var renameBody bytes.Buffer
|
||||
renameBody.Write(body.Bytes())
|
||||
renameBody.Write(body2.Bytes())
|
||||
// Rebuild the combined multipart request because each helper owns its boundary.
|
||||
var rb bytes.Buffer
|
||||
mw := multipart.NewWriter(&rb)
|
||||
_ = mw.WriteField("old", "file.txt")
|
||||
_ = mw.WriteField("new", "renamed.txt")
|
||||
_ = mw.Close()
|
||||
_ = body
|
||||
_ = ct
|
||||
rename := doRequest(h, http.MethodPost, "/chfs/rename", &rb, mw.FormDataContentType())
|
||||
if rename.Code != tc.write {
|
||||
t.Errorf("rename: got %d want %d", rename.Code, tc.write)
|
||||
}
|
||||
|
||||
deleteBody, deleteCT := multipartField(t, "filepath", "dir/nested.txt")
|
||||
del := doRequest(h, http.MethodDelete, "/chfs/rmfiles", deleteBody, deleteCT)
|
||||
if del.Code != tc.del {
|
||||
t.Errorf("delete: got %d want %d", del.Code, tc.del)
|
||||
}
|
||||
|
||||
zip := doRequest(h, http.MethodGet, "/chfs/downloaddir/dir", nil, "")
|
||||
if zip.Code != tc.read {
|
||||
t.Errorf("zip: got %d want %d", zip.Code, tc.read)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPBatchDeleteAndTransferConflict(t *testing.T) {
|
||||
s, root := permissionTestServer(t, auth.Read|auth.Write|auth.Delete)
|
||||
h := s.Handler()
|
||||
if err := os.WriteFile(filepath.Join(root, "second.txt"), []byte("second"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
_ = mw.WriteField("filepath", "file.txt")
|
||||
_ = mw.WriteField("filepath", "second.txt")
|
||||
_ = mw.Close()
|
||||
rr := doRequest(h, http.MethodDelete, "/chfs/rmfiles", &b, mw.FormDataContentType())
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("batch delete: got %d", rr.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "file.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("file.txt still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "second.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("second.txt still exists: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := url.Values{"source": {"source.txt"}, "destination": {"target.txt"}}
|
||||
copyReq := doRequest(h, http.MethodPost, "/chfs/copy", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
||||
if copyReq.Code != http.StatusBadRequest {
|
||||
t.Fatalf("copy conflict: got %d", copyReq.Code)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "target.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "target" {
|
||||
t.Fatalf("target overwritten: %q", data)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func TestRootAndAPIDoc(t *testing.T) {
|
||||
s := &Server{Store: storage.Local{Root: t.TempDir()}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, HTMLTitle: "My Files", HTMLNotice: "`<strong>Hello</strong>`"}
|
||||
h := s.Handler()
|
||||
r := httptest.NewRecorder()
|
||||
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if r.Code != 200 || !strings.Contains(r.Body.String(), "<title>My Files</title>") || !strings.Contains(r.Body.String(), "<strong>Hello</strong>") {
|
||||
t.Fatalf("root: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
r = httptest.NewRecorder()
|
||||
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
|
||||
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
|
||||
t.Fatalf("api doc: %d %s", r.Code, r.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootEscapesPlainNotice(t *testing.T) {
|
||||
s := &Server{Store: storage.Local{Root: t.TempDir()}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, HTMLNotice: `<script>alert(1)</script>`}
|
||||
r := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||
if strings.Contains(r.Body.String(), `<script>alert(1)</script>`) {
|
||||
t.Fatal("plain notice was not escaped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
s := &Server{}
|
||||
old := Version
|
||||
Version = "test"
|
||||
defer func() { Version = old }()
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
|
||||
t.Fatalf("cache-control=%q", got)
|
||||
}
|
||||
if !strings.Contains(rr.Body.String(), `"status":"ok"`) || !strings.Contains(rr.Body.String(), `"version":"test"`) {
|
||||
t.Fatalf("unexpected body=%s", rr.Body.String())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func TestSharedRoutesMatchCHFS(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "hello.txt"), []byte("hello"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/chfs/shared?file=hello.txt", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "shared/?file=hello.txt" {
|
||||
t.Fatalf("shared redirect: status=%d location=%q", w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
|
||||
r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil)
|
||||
w = httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment;filename="hello.txt"` {
|
||||
t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition"))
|
||||
}
|
||||
body, _ := io.ReadAll(w.Result().Body)
|
||||
if string(body) != "hello" {
|
||||
t.Fatalf("shared body=%q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "pack")
|
||||
if err := os.Mkdir(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("abc"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}}
|
||||
|
||||
r := httptest.NewRequest(http.MethodGet, "/chfs/downloaddir/pack", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("archive status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := w.Header().Get("Content-Type"); got != "application/zip" {
|
||||
t.Fatalf("archive content type=%q", got)
|
||||
}
|
||||
if got := w.Header().Get("Content-Disposition"); got != "" {
|
||||
t.Fatalf("CHFS archive must not emit Content-Disposition, got %q", got)
|
||||
}
|
||||
zr, err := zip.NewReader(bytesReaderAt{r: w.Body.Bytes()}, int64(w.Body.Len()))
|
||||
if err != nil {
|
||||
t.Fatalf("open archive: %v", err)
|
||||
}
|
||||
if len(zr.File) != 1 || zr.File[0].Name != "a.txt" {
|
||||
t.Fatalf("archive entries=%v", archiveNames(zr.File))
|
||||
}
|
||||
if zr.File[0].UncompressedSize64 != 3 {
|
||||
t.Fatalf("archive size=%d", zr.File[0].UncompressedSize64)
|
||||
}
|
||||
}
|
||||
|
||||
type bytesReaderAt struct{ r []byte }
|
||||
|
||||
func (b bytesReaderAt) ReadAt(p []byte, off int64) (int, error) {
|
||||
if off >= int64(len(b.r)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(p, b.r[off:])
|
||||
if n < len(p) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
func archiveNames(files []*zip.File) []string {
|
||||
n := make([]string, 0, len(files))
|
||||
for _, f := range files {
|
||||
n = append(n, f.Name)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func uploadCompatServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "dir"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}}, RemoveMode: 3}, root
|
||||
}
|
||||
|
||||
func makeUploadRequest(t *testing.T, method, target string, fieldName, fieldValue, fileName, content string) (*httptest.ResponseRecorder, *http.Request) {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
if fieldName != "" {
|
||||
if err := mw.WriteField(fieldName, fieldValue); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if fileName != "" {
|
||||
fw, err := mw.CreateFormFile("file", fileName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(method, target, &b)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return httptest.NewRecorder(), req
|
||||
}
|
||||
|
||||
func TestUploadNormalizesClientFilenamesInsideTargetFolder(t *testing.T) {
|
||||
s, root := uploadCompatServer(t)
|
||||
for _, tc := range []struct{ input, output string }{{"../escape1.txt", "escape1.txt"}, {"/absolute3.txt", "absolute3.txt"}} {
|
||||
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload?folder=dir", "", "", tc.input, "x")
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Errorf("upload %q: status=%d", tc.input, rr.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, tc.output)); !os.IsNotExist(err) {
|
||||
t.Fatalf("filename escaped target folder: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "dir", tc.output)); err != nil {
|
||||
t.Fatalf("normalized upload %s missing: %v", tc.output, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpload2UsesQueryFolderAndRejectsTraversal(t *testing.T) {
|
||||
s, root := uploadCompatServer(t)
|
||||
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder=dir", "", "", "nested.txt", "hello")
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("upload2 status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "dir", "nested.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "hello" {
|
||||
t.Fatalf("content=%q", got)
|
||||
}
|
||||
for _, folder := range []string{"../", "..%2F", "..\\\\"} {
|
||||
rr, req = makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder="+folder, "", "", "bad.txt", "x")
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("upload2 folder %q: status=%d", folder, rr.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestUploadRejectsDuplicateWithoutOverwriting(t *testing.T) {
|
||||
s, root := uploadCompatServer(t)
|
||||
target := filepath.Join(root, "dir", "same.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload", "folder", "dir", "same.txt", "replacement")
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", rr.Code)
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "original" {
|
||||
t.Fatalf("duplicate upload overwrote file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpload2RejectsDuplicateWithoutOverwriting(t *testing.T) {
|
||||
s, root := uploadCompatServer(t)
|
||||
target := filepath.Join(root, "dir", "same.txt")
|
||||
if err := os.WriteFile(target, []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr, req := makeUploadRequest(t, http.MethodPost, "/chfs/upload2?folder=dir", "", "", "same.txt", "replacement")
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d", rr.Code)
|
||||
}
|
||||
got, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "original" {
|
||||
t.Fatalf("duplicate upload2 overwrote file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpload2BatchWritesAllFiles(t *testing.T) {
|
||||
s, root := uploadCompatServer(t)
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
for _, name := range []string{"first.txt", "second.txt"} {
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = fw.Write([]byte(name))
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/chfs/upload2?folder=dir", &b)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
rr := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
for _, name := range []string{"first.txt", "second.txt"} {
|
||||
if _, err := os.Stat(filepath.Join(root, "dir", name)); err != nil {
|
||||
t.Fatalf("%s missing: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func TestUserManagementAndPrivateHome(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
usersFile := filepath.Join(t.TempDir(), "users.json")
|
||||
mgr := &auth.Manager{
|
||||
Accounts: map[string]*auth.Account{
|
||||
"admin": {Username: "admin", Password: "secret", Admin: true, Mask: auth.Read | auth.Write | auth.Delete},
|
||||
},
|
||||
Guest: &auth.Account{Mask: auth.None},
|
||||
}
|
||||
s := &Server{Store: storage.Local{Root: root}, Auth: mgr, UserStore: &auth.UserStore{Path: usersFile}, UserHome: "users"}
|
||||
h := s.Handler()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=admin&pwd=secret"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("admin login: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
cookie := rr.Result().Cookies()[0]
|
||||
|
||||
body := []byte(`{"username":"alice","password":"alice-pass"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/chfs/users", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("create user: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "users", "alice")); err != nil {
|
||||
t.Fatalf("home not created: %v", err)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("dynamic user login: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
token, err := auth.NewToken("alice", 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "currentMask") || !strings.Contains(rr.Body.String(), "\"currentMask\":\"\"") {
|
||||
t.Fatalf("private root access: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=users/alice", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"RWD\"") {
|
||||
t.Fatalf("private home access: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Admin can disable a managed user, which immediately invalidates token-based access.
|
||||
updateBody, _ := json.Marshal(map[string]any{"username": "alice", "enabled": false})
|
||||
req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("disable user: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass"))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code == http.StatusCreated {
|
||||
t.Fatal("disabled user was allowed to login")
|
||||
}
|
||||
|
||||
// Re-enable and grant read-only access to a shared directory.
|
||||
rules := []map[string]any{
|
||||
{"dir": "public", "mask": "r"},
|
||||
{"dir": "public/private", "mask": "r", "deny": true, "childrenOnly": true},
|
||||
}
|
||||
updateBody, _ = json.Marshal(map[string]any{"username": "alice", "enabled": true, "rules": rules})
|
||||
req = httptest.NewRequest(http.MethodPut, "/chfs/users", bytes.NewReader(updateBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("update rules: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "public"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "public", "readme.txt"), []byte("ok"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := mgr.Accounts["alice"].MaskFor("users/alice"); got != auth.Read|auth.Write|auth.Delete {
|
||||
t.Fatalf("home permission was not preserved: %v", got)
|
||||
}
|
||||
if got := mgr.Accounts["alice"].MaskFor("public/private/file.txt"); got != auth.None {
|
||||
t.Fatalf("deny rule was not applied: %v", got)
|
||||
}
|
||||
token, err = auth.NewToken("alice", 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=public", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"R\"") {
|
||||
t.Fatalf("shared read rule: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/roots", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "我的空间") || !strings.Contains(rr.Body.String(), "public") {
|
||||
t.Fatalf("accessible roots: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/qrcode?url=https%3A%2F%2Fexample.com", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "image/png" || len(rr.Body.Bytes()) < 100 {
|
||||
t.Fatalf("qrcode: %d %q %d", rr.Code, rr.Header().Get("Content-Type"), len(rr.Body.Bytes()))
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(root, "public", "second.txt"), []byte("two"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodGet, "/chfs/downloadzip?path=public%2Freadme.txt&path=public%2Fsecond.txt", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "JWT", Value: token})
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK || rr.Header().Get("Content-Type") != "application/zip" || len(rr.Body.Bytes()) < 50 {
|
||||
t.Fatalf("selected zip: %d %q %d", rr.Code, rr.Header().Get("Content-Type"), len(rr.Body.Bytes()))
|
||||
}
|
||||
var saved []map[string]any
|
||||
data, err := os.ReadFile(usersFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &saved); err != nil || len(saved) != 1 {
|
||||
t.Fatalf("user persistence: %v %#v", err, saved)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,115 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
)
|
||||
|
||||
func webDAVMatrixServer(t *testing.T, mask auth.PermMask) (*Server, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"src-copy.txt", "src-move.txt", "src-delete.txt"} {
|
||||
if err := os.WriteFile(filepath.Join(root, name), []byte("src"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return &Server{
|
||||
Store: storage.Local{Root: root},
|
||||
Auth: &auth.Manager{
|
||||
Accounts: map[string]*auth.Account{},
|
||||
Guest: &auth.Account{Mask: mask},
|
||||
},
|
||||
RemoveMode: 3,
|
||||
WebDAVRoot: root,
|
||||
}, root
|
||||
}
|
||||
|
||||
func webDAVRequest(h http.Handler, method, target string, body []byte, headers map[string]string) int {
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
return rr.Code
|
||||
}
|
||||
|
||||
func TestWebDAVPermissionMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mask auth.PermMask
|
||||
read int
|
||||
write int
|
||||
del int
|
||||
copy int
|
||||
move int
|
||||
}{
|
||||
{"none", auth.None, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||
{"read", auth.Read, http.StatusMultiStatus, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden, http.StatusForbidden},
|
||||
{"read-write", auth.Read | auth.Write, http.StatusMultiStatus, http.StatusCreated, http.StatusForbidden, http.StatusCreated, http.StatusCreated},
|
||||
{"read-write-delete", auth.Read | auth.Write | auth.Delete, http.StatusMultiStatus, http.StatusCreated, http.StatusNoContent, http.StatusCreated, http.StatusCreated},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s, root := webDAVMatrixServer(t, tc.mask)
|
||||
h := s.Handler()
|
||||
|
||||
propf := webDAVRequest(h, "PROPFIND", "/webdav/", nil, map[string]string{"Depth": "1"})
|
||||
if propf != tc.read {
|
||||
t.Errorf("PROPFIND: got %d want %d", propf, tc.read)
|
||||
}
|
||||
get := webDAVRequest(h, http.MethodGet, "/webdav/src-copy.txt", nil, nil)
|
||||
if get != func() int {
|
||||
if tc.mask.HasRead() {
|
||||
return http.StatusOK
|
||||
}
|
||||
return http.StatusForbidden
|
||||
}() {
|
||||
want := http.StatusForbidden
|
||||
if tc.mask.HasRead() {
|
||||
want = http.StatusOK
|
||||
}
|
||||
t.Errorf("GET: got %d want %d", get, want)
|
||||
}
|
||||
|
||||
put := webDAVRequest(h, http.MethodPut, "/webdav/new.txt", []byte("new"), nil)
|
||||
if put != tc.write {
|
||||
t.Errorf("PUT: got %d want %d", put, tc.write)
|
||||
}
|
||||
|
||||
mkcol := webDAVRequest(h, "MKCOL", "/webdav/newdir", nil, nil)
|
||||
if mkcol != tc.write {
|
||||
t.Errorf("MKCOL: got %d want %d", mkcol, tc.write)
|
||||
}
|
||||
|
||||
del := webDAVRequest(h, http.MethodDelete, "/webdav/src-delete.txt", nil, nil)
|
||||
if del != tc.del {
|
||||
t.Errorf("DELETE: got %d want %d", del, tc.del)
|
||||
}
|
||||
|
||||
copy := webDAVRequest(h, "COPY", "/webdav/src-copy.txt", nil, map[string]string{"Destination": "/webdav/copied.txt"})
|
||||
if copy != tc.copy {
|
||||
t.Errorf("COPY: got %d want %d", copy, tc.copy)
|
||||
}
|
||||
|
||||
move := webDAVRequest(h, "MOVE", "/webdav/src-move.txt", nil, map[string]string{"Destination": "/webdav/moved.txt"})
|
||||
if move != tc.move {
|
||||
t.Errorf("MOVE: got %d want %d", move, tc.move)
|
||||
}
|
||||
|
||||
if tc.write == http.StatusCreated {
|
||||
if _, err := os.Stat(filepath.Join(root, "new.txt")); tc.name != "none" && err != nil {
|
||||
t.Errorf("PUT file missing: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
"chfs-ng/internal/storage"
|
||||
"github.com/emersion/go-webdav"
|
||||
)
|
||||
|
||||
func TestWebDAVAuthFSCheckTree(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "test", "private"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "test", "public.txt"), []byte("public"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "test", "private", "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
account := &auth.Account{Mask: auth.Read | auth.Write | auth.Delete, Rules: []auth.PathRule{
|
||||
{Dir: "test", Mask: auth.Read | auth.Write | auth.Delete},
|
||||
{Dir: "test/private", Mask: auth.None},
|
||||
}}
|
||||
fs := &webdavAuthFS{inner: &webdavStorageFS{store: storage.Local{Root: root}}, account: account}
|
||||
|
||||
if err := fs.checkTree(context.Background(), "/test", auth.Delete); err == nil {
|
||||
t.Fatal("expected recursive delete permission check to reject inaccessible descendant")
|
||||
}
|
||||
if err := fs.checkTree(context.Background(), "/test/public.txt", auth.Delete); err != nil {
|
||||
t.Fatalf("public file should be deletable: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVAuthFSOverwriteRequiresDelete(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "src.txt"), []byte("src"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("dst"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := &auth.Account{Mask: auth.Write}
|
||||
fs := &webdavAuthFS{inner: &webdavStorageFS{store: storage.Local{Root: root}}, account: account}
|
||||
if err := fs.authorizeOverwrite(context.Background(), "/dst.txt"); err == nil {
|
||||
t.Fatal("expected overwrite authorization to require delete permission")
|
||||
}
|
||||
account.Mask = auth.Delete
|
||||
if err := fs.authorizeOverwrite(context.Background(), "/dst.txt"); err != nil {
|
||||
t.Fatalf("delete permission should authorize overwrite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameWebDAVDestinationHost(t *testing.T) {
|
||||
req := httptest.NewRequest("MOVE", "/webdav/src.txt", nil)
|
||||
req.Host = "example.test"
|
||||
req.Header.Set("Destination", "http://other.test/webdav/dst.txt")
|
||||
if sameWebDAVDestinationHost(req) {
|
||||
t.Fatal("cross-host destination should be rejected")
|
||||
}
|
||||
req.Header.Set("Destination", "http://example.test/webdav/dst.txt")
|
||||
if !sameWebDAVDestinationHost(req) {
|
||||
t.Fatal("same-host absolute destination should be accepted")
|
||||
}
|
||||
req.Header.Set("Destination", "/webdav/dst.txt")
|
||||
if !sameWebDAVDestinationHost(req) {
|
||||
t.Fatal("relative destination should be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVStorageFSRejectsDestinationInsideSource(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "src", "child"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "src", "file.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs := &webdavStorageFS{store: storage.Local{Root: root}}
|
||||
if _, err := fs.Copy(context.Background(), "/src", "/src/child/copy", &webdav.CopyOptions{}); err == nil {
|
||||
t.Fatal("copying a directory into its descendant should fail")
|
||||
}
|
||||
if _, err := fs.Move(context.Background(), "/src", "/src/child/moved", &webdav.MoveOptions{}); err == nil {
|
||||
t.Fatal("moving a directory into its descendant should fail")
|
||||
}
|
||||
if _, err := fs.Copy(context.Background(), "/src", "/src", &webdav.CopyOptions{}); err == nil {
|
||||
t.Fatal("copying a resource onto itself should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVAuthFSMoveMissingSourcePrecedence(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "existing.txt"), []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
account := &auth.Account{Mask: auth.Read | auth.Write | auth.Delete}
|
||||
fs := &webdavAuthFS{inner: &webdavStorageFS{store: storage.Local{Root: root}}, account: account}
|
||||
|
||||
_, err := fs.Move(context.Background(), "/missing.txt", "/new.txt", &webdav.MoveOptions{})
|
||||
if err == nil || err.Error() != "403 Forbidden: source not found" {
|
||||
t.Fatalf("missing source to free destination: got %v", err)
|
||||
}
|
||||
|
||||
_, err = fs.Move(context.Background(), "/missing.txt", "/existing.txt", &webdav.MoveOptions{})
|
||||
if err == nil || err.Error() != "412 Precondition Failed: destination exists" {
|
||||
t.Fatalf("missing source to occupied destination: got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVStorageFSCopyPreservesExistingDestinationOnSourceReadFailure(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := storage.Local{Root: root}
|
||||
if err := os.WriteFile(filepath.Join(root, "src.txt"), []byte("replacement"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs := &webdavStorageFS{store: store}
|
||||
if _, err := fs.Copy(context.Background(), "/src.txt", "/dst.txt", &webdav.CopyOptions{}); err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "dst.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "replacement" {
|
||||
t.Fatalf("destination content=%q", got)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary copy files leaked: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
type failingReader struct{}
|
||||
|
||||
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("source read failed") }
|
||||
|
||||
type readCloser struct{ io.Reader }
|
||||
|
||||
func (r readCloser) Close() error { return nil }
|
||||
|
||||
func TestWebDAVStorageFSCopyFailureDoesNotCorruptDestination(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := storage.Local{Root: root}
|
||||
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyFileToDestination(store, failingReader{}, "dst.txt"); err == nil {
|
||||
t.Fatal("expected source read failure")
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "dst.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "original" {
|
||||
t.Fatalf("destination was corrupted: %q", got)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary copy files leaked: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVStorageFSCreateFailureDoesNotCorruptDestination(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
store := storage.Local{Root: root}
|
||||
if err := os.WriteFile(filepath.Join(root, "dst.txt"), []byte("original"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs := &webdavStorageFS{store: store}
|
||||
_, _, err := fs.Create(context.Background(), "/dst.txt", readCloser{Reader: failingReader{}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected PUT body failure")
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "dst.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "original" {
|
||||
t.Fatalf("destination was corrupted: %q", got)
|
||||
}
|
||||
matches, err := filepath.Glob(filepath.Join(root, "dst.txt.chfs-copy-*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("temporary PUT files leaked: %v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebDAVStorageFSCopyAndMove(t *testing.T) {
|
||||
root1 := t.TempDir()
|
||||
root2 := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root1, "a.txt"), []byte("one"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := storage.NewMulti([]string{root1, root2})
|
||||
fs := &webdavStorageFS{store: m}
|
||||
if _, err := fs.Copy(context.Background(), "/"+m.Names[0]+"/a.txt", "/"+m.Names[0]+"/b.txt", &webdav.CopyOptions{}); err != nil {
|
||||
t.Fatalf("copy: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root1, "b.txt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fs.Move(context.Background(), "/"+m.Names[0]+"/b.txt", "/"+m.Names[1]+"/b.txt", &webdav.MoveOptions{}); err == nil {
|
||||
t.Fatal("expected cross-root move rejection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
type PermMask uint8
|
||||
|
||||
const (
|
||||
None PermMask = 0
|
||||
Read PermMask = 1 << iota
|
||||
Write
|
||||
Delete
|
||||
)
|
||||
|
||||
func (p PermMask) HasRead() bool { return p&Read != 0 }
|
||||
func (p PermMask) HasWrite() bool { return p&Write != 0 }
|
||||
func (p PermMask) HasDelete() bool { return p&Delete != 0 }
|
||||
func ParseMask(s string) PermMask {
|
||||
var p PermMask
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch r {
|
||||
case 'r':
|
||||
p |= Read
|
||||
case 'w':
|
||||
p |= Read | Write
|
||||
case 'd':
|
||||
p |= Read | Write | Delete
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type PathRule struct {
|
||||
Dir string
|
||||
Mask PermMask
|
||||
Deny bool
|
||||
ChildrenOnly bool
|
||||
}
|
||||
type Account struct {
|
||||
Username, Password string
|
||||
PasswordHash string
|
||||
Mask PermMask
|
||||
Rules []PathRule
|
||||
Home string
|
||||
Admin bool
|
||||
Enabled bool
|
||||
}
|
||||
type Manager struct {
|
||||
Accounts map[string]*Account
|
||||
Guest *Account
|
||||
}
|
||||
|
||||
func ParseRules(values []string) (*Manager, error) {
|
||||
m := &Manager{Accounts: map[string]*Account{}}
|
||||
for _, value := range values {
|
||||
for _, item := range strings.Split(value, "|") {
|
||||
if item == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(item, ":")
|
||||
if len(fields) < 3 || len(fields)%2 == 0 {
|
||||
continue
|
||||
}
|
||||
a := &Account{Username: fields[0], Password: fields[1], Mask: ParseMask(fields[2])}
|
||||
for i := 3; i+1 < len(fields); i += 2 {
|
||||
a.Rules = append(a.Rules, PathRule{Dir: strings.Trim(fields[i], "/"), Mask: ParseMask(fields[i+1])})
|
||||
}
|
||||
if a.Username == "" {
|
||||
m.Guest = a
|
||||
} else {
|
||||
m.Accounts[a.Username] = a
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.Guest == nil {
|
||||
m.Guest = &Account{Mask: Read | Write | Delete}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Authenticate(user, pwd string) *Account {
|
||||
if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) {
|
||||
return a
|
||||
}
|
||||
if user == "" && pwd == "" {
|
||||
return m.Guest
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func PasswordHash(password string) string {
|
||||
h := sha256.Sum256([]byte(password))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func (a *Account) MaskFor(path string) PermMask {
|
||||
return a.maskFor(path, false)
|
||||
}
|
||||
|
||||
// MaskForEntry returns the permission shown for a directory/file while its
|
||||
// parent directory is being listed. CHFS section rules rule.r/rule.w/rule.d
|
||||
// affect the contents of the named directory, but the directory entry itself
|
||||
// keeps the inherited permission until it is opened.
|
||||
func (a *Account) MaskForEntry(path string) PermMask {
|
||||
return a.maskFor(path, true)
|
||||
}
|
||||
|
||||
func (a *Account) maskFor(path string, entry bool) PermMask {
|
||||
path = strings.Trim(path, "/")
|
||||
best := -1
|
||||
mask := a.Mask
|
||||
for _, r := range a.Rules {
|
||||
d := strings.Trim(r.Dir, "/")
|
||||
if !matchPath(d, path) {
|
||||
continue
|
||||
}
|
||||
if entry && r.ChildrenOnly && matchPathExact(d, path) {
|
||||
continue
|
||||
}
|
||||
if len(d) >= best {
|
||||
best = len(d)
|
||||
if r.Deny {
|
||||
mask = None
|
||||
} else {
|
||||
mask = r.Mask
|
||||
}
|
||||
}
|
||||
}
|
||||
return mask
|
||||
}
|
||||
func matchPath(pattern, path string) bool {
|
||||
if pattern == "" {
|
||||
return path == ""
|
||||
}
|
||||
pp := strings.Split(pattern, "/")
|
||||
ps := strings.Split(path, "/")
|
||||
if len(ps) < len(pp) {
|
||||
return false
|
||||
}
|
||||
for i, p := range pp {
|
||||
if !matchSegment(p, ps[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchSegment(pattern, value string) bool {
|
||||
p := []rune(pattern)
|
||||
v := []rune(value)
|
||||
dp := make([][]bool, len(p)+1)
|
||||
for i := range dp {
|
||||
dp[i] = make([]bool, len(v)+1)
|
||||
}
|
||||
dp[0][0] = true
|
||||
for i := 1; i <= len(p); i++ {
|
||||
if p[i-1] == '*' {
|
||||
dp[i][0] = dp[i-1][0]
|
||||
}
|
||||
}
|
||||
for i := 1; i <= len(p); i++ {
|
||||
for j := 1; j <= len(v); j++ {
|
||||
switch p[i-1] {
|
||||
case '*':
|
||||
dp[i][j] = dp[i-1][j] || dp[i][j-1]
|
||||
case '?':
|
||||
dp[i][j] = dp[i-1][j-1]
|
||||
default:
|
||||
dp[i][j] = dp[i-1][j-1] && p[i-1] == v[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return dp[len(p)][len(v)]
|
||||
}
|
||||
|
||||
func matchPathExact(pattern, path string) bool {
|
||||
pp := strings.Split(strings.Trim(pattern, "/"), "/")
|
||||
ps := strings.Split(strings.Trim(path, "/"), "/")
|
||||
if len(pp) != len(ps) {
|
||||
return false
|
||||
}
|
||||
return matchPath(pattern, path)
|
||||
}
|
||||
|
||||
func MatchPath(pattern, path string) bool { return matchPath(pattern, path) }
|
||||
|
||||
const jwtSecret = "_pengyichen2015_"
|
||||
|
||||
type jwtClaims struct {
|
||||
Acc string `json:"acc"`
|
||||
jwt.StandardClaims
|
||||
}
|
||||
|
||||
func NewToken(username string, _ int) (string, error) {
|
||||
// CHFS v4 keeps the JWT itself valid for one year. session.timeout is
|
||||
// retained as a compatibility configuration field, but it does not alter
|
||||
// the JWT exp claim in the original implementation.
|
||||
claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix()}}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(jwtSecret))
|
||||
}
|
||||
|
||||
func GetAccFromToken(raw string) (string, error) {
|
||||
if raw == "" {
|
||||
return "", fmt.Errorf("empty token")
|
||||
}
|
||||
var claims jwtClaims
|
||||
token, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||
if t.Method != jwt.SigningMethodHS256 {
|
||||
return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg())
|
||||
}
|
||||
return []byte(jwtSecret), nil
|
||||
})
|
||||
if err != nil || token == nil || !token.Valid {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("invalid token")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if claims.Acc == "" {
|
||||
return "", fmt.Errorf("token missing account")
|
||||
}
|
||||
return claims.Acc, nil
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMatchPath(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern, path string
|
||||
want bool
|
||||
}{
|
||||
{"test", "test", true},
|
||||
{"test", "test/sub", true},
|
||||
{"test", "testing", false},
|
||||
{"test*", "test2", true},
|
||||
{"test*", "test/sub", true},
|
||||
{"te?t", "test", true},
|
||||
{"te?t", "teat", true},
|
||||
{"te?t", "te", false},
|
||||
{"test/sub", "test/sub/file", true},
|
||||
{"test/sub", "test/other/file", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := MatchPath(tc.pattern, tc.path); got != tc.want {
|
||||
t.Errorf("MatchPath(%q,%q)=%v want %v", tc.pattern, tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaskForLongestRule(t *testing.T) {
|
||||
a := &Account{Mask: Read, Rules: []PathRule{
|
||||
{Dir: "team", Mask: Write},
|
||||
{Dir: "team/private", Mask: None},
|
||||
}}
|
||||
if got := a.MaskFor("team/file"); got != Write {
|
||||
t.Fatalf("team/file mask=%d", got)
|
||||
}
|
||||
if got := a.MaskFor("team/private/file"); got != None {
|
||||
t.Fatalf("team/private/file mask=%d", got)
|
||||
}
|
||||
if got := a.MaskFor("other/file"); got != Read {
|
||||
t.Fatalf("other/file mask=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRulesGuestDefaultAndOverride(t *testing.T) {
|
||||
m, err := ParseRules(nil)
|
||||
if err != nil || m.Guest.Mask != (Read|Write|Delete) {
|
||||
t.Fatalf("unexpected guest default: %#v err=%v", m.Guest, err)
|
||||
}
|
||||
m, err = ParseRules([]string{"::r|demo:pw:rw"})
|
||||
if err != nil || m.Guest.Mask != Read || m.Accounts["demo"].Mask != (Read|Write) {
|
||||
t.Fatalf("unexpected parsed masks: %#v %#v err=%v", m.Guest, m.Accounts["demo"], err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleSemantics(t *testing.T) {
|
||||
a := &Account{Mask: Delete, Rules: []PathRule{
|
||||
{Dir: "private", Mask: None, Deny: true},
|
||||
{Dir: "readonly", Mask: ParseMask("r"), ChildrenOnly: true},
|
||||
{Dir: "writable", Mask: ParseMask("w"), ChildrenOnly: true},
|
||||
{Dir: "full", Mask: ParseMask("d"), ChildrenOnly: true},
|
||||
}}
|
||||
if got := a.MaskFor("private"); got != None {
|
||||
t.Fatalf("private=%v", got)
|
||||
}
|
||||
if got := a.MaskFor("private/file"); got != None {
|
||||
t.Fatalf("private/file=%v", got)
|
||||
}
|
||||
if got := a.MaskForEntry("readonly"); got != Delete {
|
||||
t.Fatalf("readonly entry=%v", got)
|
||||
}
|
||||
if got := a.MaskFor("readonly/file"); got != Read {
|
||||
t.Fatalf("readonly/file=%v", got)
|
||||
}
|
||||
if got := a.MaskForEntry("writable"); got != Delete {
|
||||
t.Fatalf("writable entry=%v", got)
|
||||
}
|
||||
if got := a.MaskFor("writable/file"); got != ParseMask("w") {
|
||||
t.Fatalf("writable/file=%v", got)
|
||||
}
|
||||
if got := a.MaskForEntry("full"); got != Delete {
|
||||
t.Fatalf("full entry=%v", got)
|
||||
}
|
||||
if got := a.MaskFor("full/file"); got != (Read | Write | Delete) {
|
||||
t.Fatalf("full/file=%v", got)
|
||||
}
|
||||
if got := a.MaskForEntry("readonly"); got != Delete {
|
||||
t.Fatalf("readonly entry mask=%v", got)
|
||||
}
|
||||
if got := a.MaskForEntry("private"); got != None {
|
||||
t.Fatalf("private entry mask=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleSamePathPriority(t *testing.T) {
|
||||
a := &Account{Mask: Delete, Rules: []PathRule{
|
||||
{Dir: "secret", Mask: None, Deny: true},
|
||||
{Dir: "secret", Mask: ParseMask("r"), ChildrenOnly: true},
|
||||
{Dir: "secret", Mask: ParseMask("w"), ChildrenOnly: true},
|
||||
{Dir: "secret", Mask: ParseMask("d"), ChildrenOnly: true},
|
||||
}}
|
||||
if got := a.MaskFor("secret/file"); got != ParseMask("d") {
|
||||
t.Fatalf("same-path rule priority=%v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWildcardSectionRules(t *testing.T) {
|
||||
a := &Account{Mask: Read, Rules: []PathRule{{Dir: "project-*", Mask: Write, ChildrenOnly: true}}}
|
||||
if got := a.MaskFor("project-one/file"); got != Write {
|
||||
t.Fatalf("project-one/file=%v", got)
|
||||
}
|
||||
if got := a.MaskFor("project-two/file"); got != Write {
|
||||
t.Fatalf("project-two/file=%v", got)
|
||||
}
|
||||
if got := a.MaskFor("project/file"); got != Read {
|
||||
t.Fatalf("project/file=%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package auth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPathRuleSemantics(t *testing.T) {
|
||||
a := &Account{Mask: ParseMask("r")}
|
||||
a.Rules = []PathRule{
|
||||
{Dir: "private", Deny: true},
|
||||
{Dir: "share", Mask: ParseMask("w"), ChildrenOnly: true},
|
||||
{Dir: "drop", Mask: ParseMask("d"), ChildrenOnly: true},
|
||||
{Dir: "pub*", Mask: ParseMask("r")},
|
||||
{Dir: "team/?", Mask: ParseMask("w")},
|
||||
}
|
||||
cases := []struct{ path, want string }{
|
||||
{"", "R"},
|
||||
{"private", ""},
|
||||
{"private/x.txt", ""},
|
||||
{"share", "RW"},
|
||||
{"share/x.txt", "RW"},
|
||||
{"drop", "RWD"},
|
||||
{"drop/x.txt", "RWD"},
|
||||
{"public/x.txt", "R"},
|
||||
{"public/sub/x.txt", "R"},
|
||||
{"team/a", "RW"},
|
||||
{"team/ab", "R"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := permStringForTest(a.MaskFor(tc.path)); got != tc.want {
|
||||
t.Errorf("MaskFor(%q)=%q want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
entryCases := []struct{ path, want string }{
|
||||
{"private", ""},
|
||||
{"share", "R"},
|
||||
{"drop", "R"},
|
||||
{"public", "R"},
|
||||
{"team/a", "RW"},
|
||||
}
|
||||
for _, tc := range entryCases {
|
||||
if got := permStringForTest(a.MaskForEntry(tc.path)); got != tc.want {
|
||||
t.Errorf("MaskForEntry(%q)=%q want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoreSpecificRuleWins(t *testing.T) {
|
||||
a := &Account{Mask: ParseMask("r")}
|
||||
a.Rules = []PathRule{
|
||||
{Dir: "share", Mask: ParseMask("w"), ChildrenOnly: true},
|
||||
{Dir: "share/private", Deny: true},
|
||||
{Dir: "share/public*", Mask: ParseMask("r")},
|
||||
}
|
||||
cases := []struct{ path, want string }{
|
||||
{"share", "RW"},
|
||||
{"share/file.txt", "RW"},
|
||||
{"share/private", ""},
|
||||
{"share/private/file.txt", ""},
|
||||
{"share/public", "R"},
|
||||
{"share/public/file.txt", "R"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := permStringForTest(a.MaskFor(tc.path)); got != tc.want {
|
||||
t.Errorf("MaskFor(%q)=%q want %q", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func permStringForTest(p PermMask) string {
|
||||
if p.HasDelete() {
|
||||
return "RWD"
|
||||
}
|
||||
if p.HasWrite() {
|
||||
return "RW"
|
||||
}
|
||||
if p.HasRead() {
|
||||
return "R"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type UserStore struct {
|
||||
Path string
|
||||
Mu sync.Mutex
|
||||
}
|
||||
|
||||
type userRecord struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"passwordHash"`
|
||||
Home string `json:"home"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Rules []userRule `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
type userRule struct {
|
||||
Dir string `json:"dir"`
|
||||
Mask string `json:"mask"`
|
||||
Deny bool `json:"deny,omitempty"`
|
||||
ChildrenOnly bool `json:"childrenOnly,omitempty"`
|
||||
}
|
||||
|
||||
var validUsername = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$")
|
||||
|
||||
func ValidateUsername(username string) error {
|
||||
if !validUsername.MatchString(username) {
|
||||
return fmt.Errorf("invalid username")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsafeUserPath(p string) bool {
|
||||
p = strings.ReplaceAll(p, "\\", "/")
|
||||
return p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") || strings.HasPrefix(p, "/")
|
||||
}
|
||||
|
||||
func (u *UserStore) Load(m *Manager, homeRoot string) error {
|
||||
if u.Path == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(u.Path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var records []userRecord
|
||||
if err := json.Unmarshal(data, &records); err != nil {
|
||||
return fmt.Errorf("invalid user store: %w", err)
|
||||
}
|
||||
for _, rec := range records {
|
||||
if err := ValidateUsername(rec.Username); err != nil || rec.Password == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := m.Accounts[rec.Username]; exists {
|
||||
continue
|
||||
}
|
||||
home := filepath.ToSlash(strings.Trim(strings.TrimSpace(rec.Home), "/"))
|
||||
if home == "" {
|
||||
home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username))
|
||||
}
|
||||
enabled := true
|
||||
if rec.Enabled != nil {
|
||||
enabled = *rec.Enabled
|
||||
}
|
||||
a := &Account{Username: rec.Username, PasswordHash: rec.Password, Mask: None, Home: home, Enabled: enabled}
|
||||
if len(rec.Rules) == 0 {
|
||||
a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete})
|
||||
} else {
|
||||
for _, rr := range rec.Rules {
|
||||
dir := strings.Trim(strings.ReplaceAll(rr.Dir, "\\", "/"), "/")
|
||||
if dir == "" || unsafeUserPath(dir) {
|
||||
continue
|
||||
}
|
||||
a.Rules = append(a.Rules, PathRule{Dir: dir, Mask: ParseMask(rr.Mask), Deny: rr.Deny, ChildrenOnly: rr.ChildrenOnly})
|
||||
}
|
||||
}
|
||||
m.Accounts[rec.Username] = a
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *UserStore) Save(m *Manager) error {
|
||||
if u.Path == "" {
|
||||
return nil
|
||||
}
|
||||
u.Mu.Lock()
|
||||
defer u.Mu.Unlock()
|
||||
records := make([]userRecord, 0)
|
||||
for _, a := range m.Accounts {
|
||||
if a.PasswordHash == "" || a.Home == "" || a.Admin {
|
||||
continue
|
||||
}
|
||||
rec := userRecord{Username: a.Username, Password: a.PasswordHash, Home: a.Home, Enabled: &a.Enabled}
|
||||
for _, rr := range a.Rules {
|
||||
rec.Rules = append(rec.Rules, userRule{Dir: rr.Dir, Mask: maskString(rr.Mask), Deny: rr.Deny, ChildrenOnly: rr.ChildrenOnly})
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
data, err := json.MarshalIndent(records, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(u.Path); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp := u.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, u.Path)
|
||||
}
|
||||
|
||||
func (u *UserStore) Add(m *Manager, username, password, home string) (*Account, error) {
|
||||
if err := ValidateUsername(username); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return nil, fmt.Errorf("password required")
|
||||
}
|
||||
if _, ok := m.Accounts[username]; ok {
|
||||
return nil, fmt.Errorf("user already exists")
|
||||
}
|
||||
home = strings.Trim(home, "/")
|
||||
a := &Account{Username: username, PasswordHash: PasswordHash(password), Mask: None, Home: home, Enabled: true}
|
||||
a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete})
|
||||
m.Accounts[username] = a
|
||||
return a, u.Save(m)
|
||||
}
|
||||
|
||||
func maskString(p PermMask) string {
|
||||
var b strings.Builder
|
||||
if p.HasRead() {
|
||||
b.WriteByte('r')
|
||||
}
|
||||
if p.HasWrite() {
|
||||
b.WriteByte('w')
|
||||
}
|
||||
if p.HasDelete() {
|
||||
b.WriteByte('d')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (u *UserStore) Update(m *Manager, username, password string, enabled *bool, rules []PathRule) error {
|
||||
a, ok := m.Accounts[username]
|
||||
if !ok || a.PasswordHash == "" || a.Admin {
|
||||
return fmt.Errorf("managed user not found")
|
||||
}
|
||||
if strings.TrimSpace(password) != "" {
|
||||
a.PasswordHash = PasswordHash(password)
|
||||
a.Password = ""
|
||||
}
|
||||
if enabled != nil {
|
||||
a.Enabled = *enabled
|
||||
}
|
||||
if rules != nil {
|
||||
// A managed user's home is always their private working area. Keep the
|
||||
// base RWD rule even when an administrator replaces the extra rules.
|
||||
// Additional rules can grant or deny access elsewhere.
|
||||
next := make([]PathRule, 0, len(rules)+1)
|
||||
next = append(next, PathRule{Dir: a.Home, Mask: Read | Write | Delete})
|
||||
for _, rule := range rules {
|
||||
if strings.Trim(rule.Dir, "/") == strings.Trim(a.Home, "/") {
|
||||
continue
|
||||
}
|
||||
next = append(next, rule)
|
||||
}
|
||||
a.Rules = next
|
||||
}
|
||||
return u.Save(m)
|
||||
}
|
||||
|
||||
func (u *UserStore) UpdatePassword(m *Manager, username, password string) error {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return fmt.Errorf("password required")
|
||||
}
|
||||
a, ok := m.Accounts[username]
|
||||
if !ok || a.PasswordHash == "" {
|
||||
return fmt.Errorf("managed user not found")
|
||||
}
|
||||
a.PasswordHash = PasswordHash(password)
|
||||
a.Password = ""
|
||||
return u.Save(m)
|
||||
}
|
||||
|
||||
func (u *UserStore) Delete(m *Manager, username string) error {
|
||||
a, ok := m.Accounts[username]
|
||||
if !ok || a.Admin || a.PasswordHash == "" {
|
||||
return fmt.Errorf("managed user not found")
|
||||
}
|
||||
delete(m.Accounts, username)
|
||||
return u.Save(m)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type AccountConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
Role string
|
||||
Default string
|
||||
None []string
|
||||
Read []string
|
||||
Write []string
|
||||
Delete []string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Path []string
|
||||
Port int
|
||||
Allow string
|
||||
Rules []string
|
||||
Log string
|
||||
FileRemove int
|
||||
ImagePreview bool
|
||||
FolderDownload string
|
||||
SessionTimeout int
|
||||
HTMLTitle string
|
||||
HTMLNotice string
|
||||
UserFile string
|
||||
UserHome string
|
||||
SSLCert string
|
||||
SSLKey string
|
||||
Accounts []AccountConfig
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users"}
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
c := Default()
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
var section *AccountConfig
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
name := strings.TrimSpace(line[1 : len(line)-1])
|
||||
if name != "" {
|
||||
c.Accounts = append(c.Accounts, AccountConfig{Username: name})
|
||||
section = &c.Accounts[len(c.Accounts)-1]
|
||||
} else {
|
||||
section = nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
i := strings.IndexByte(line, '=')
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
key, val := strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:])
|
||||
val = strings.Trim(val, "\\\"")
|
||||
if section != nil {
|
||||
switch strings.ToLower(key) {
|
||||
case "password":
|
||||
section.Password = val
|
||||
case "role":
|
||||
section.Role = strings.ToLower(val)
|
||||
case "rule.default":
|
||||
section.Default = val
|
||||
case "rule.none":
|
||||
section.None = appendNonEmpty(section.None, val)
|
||||
case "rule.r":
|
||||
section.Read = appendNonEmpty(section.Read, val)
|
||||
case "rule.w":
|
||||
section.Write = appendNonEmpty(section.Write, val)
|
||||
case "rule.d":
|
||||
section.Delete = appendNonEmpty(section.Delete, val)
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(key) {
|
||||
case "path":
|
||||
c.Path = splitPath(val)
|
||||
case "port":
|
||||
if n, e := strconv.Atoi(val); e == nil && n > 0 && n < 65536 {
|
||||
c.Port = n
|
||||
}
|
||||
case "allow":
|
||||
c.Allow = val
|
||||
case "rule":
|
||||
if val != "" {
|
||||
c.Rules = append(c.Rules, val)
|
||||
}
|
||||
case "log":
|
||||
c.Log = val
|
||||
case "file.remove":
|
||||
if n, e := strconv.Atoi(val); e == nil {
|
||||
c.FileRemove = n
|
||||
}
|
||||
case "image.preview":
|
||||
c.ImagePreview = parseBool(val, c.ImagePreview)
|
||||
case "folder.download":
|
||||
c.FolderDownload = strings.ToLower(val)
|
||||
case "folder.leaf.download":
|
||||
if parseBool(val, false) {
|
||||
c.FolderDownload = "leaf"
|
||||
}
|
||||
case "session.timeout":
|
||||
if n, e := strconv.Atoi(val); e == nil && n > 0 {
|
||||
c.SessionTimeout = n
|
||||
}
|
||||
case "html.title":
|
||||
c.HTMLTitle = val
|
||||
case "html.notice":
|
||||
c.HTMLNotice = val
|
||||
case "user.file":
|
||||
c.UserFile = val
|
||||
case "user.home":
|
||||
c.UserHome = strings.Trim(val, "/")
|
||||
case "ssl.cert":
|
||||
c.SSLCert = val
|
||||
case "ssl.key":
|
||||
c.SSLKey = val
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return c, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func splitPath(v string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(v, "|") {
|
||||
p = strings.TrimSpace(strings.Trim(p, "\\\""))
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{"."}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseBool(v string, d bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||
case "true", "1", "yes", "on":
|
||||
return true
|
||||
case "false", "0", "no", "off":
|
||||
return false
|
||||
}
|
||||
return d
|
||||
}
|
||||
func (c Config) Validate() error {
|
||||
if len(c.Path) == 0 {
|
||||
return fmt.Errorf("path is empty")
|
||||
}
|
||||
if c.Port < 1 || c.Port > 65535 {
|
||||
return fmt.Errorf("invalid port")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendNonEmpty(dst []string, value string) []string {
|
||||
for _, item := range strings.Split(value, "|") {
|
||||
item = strings.TrimSpace(item)
|
||||
if item != "" {
|
||||
dst = append(dst, item)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadAccountSections(t *testing.T) {
|
||||
d := t.TempDir()
|
||||
p := filepath.Join(d, "chfs.ini")
|
||||
content := `path=/srv/share
|
||||
port=8080
|
||||
[demo]
|
||||
password=abc
|
||||
rule.default=r
|
||||
rule.none=secret|private
|
||||
rule.r=docs
|
||||
rule.r=public
|
||||
rule.w=upload
|
||||
rule.d=admin
|
||||
[guest]
|
||||
password=
|
||||
rule.default=
|
||||
rule.none=hidden
|
||||
`
|
||||
if err := os.WriteFile(p, []byte(content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Accounts) != 2 {
|
||||
t.Fatalf("accounts=%d", len(c.Accounts))
|
||||
}
|
||||
if c.Accounts[0].Username != "demo" || c.Accounts[0].Default != "r" {
|
||||
t.Fatalf("demo=%#v", c.Accounts[0])
|
||||
}
|
||||
if len(c.Accounts[0].Read) != 2 || c.Accounts[0].Read[0] != "docs" || c.Accounts[0].Read[1] != "public" {
|
||||
t.Fatalf("read=%#v", c.Accounts[0].Read)
|
||||
}
|
||||
if len(c.Accounts[0].Write) != 1 || len(c.Accounts[0].Delete) != 1 || len(c.Accounts[0].None) != 2 {
|
||||
t.Fatalf("rules=%#v", c.Accounts[0])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package opslog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
dir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(dir string) *Logger {
|
||||
if dir == "" {
|
||||
return nil
|
||||
}
|
||||
return &Logger{dir: dir}
|
||||
}
|
||||
|
||||
func (l *Logger) Write(ip, user, action string) error {
|
||||
if l == nil || l.dir == "" {
|
||||
return nil
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if err := os.MkdirAll(l.dir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(l.dir, "ops.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = fmt.Fprintf(f, "%s - %s - user(%s) %s\n", time.Now().Format("2006-01-02 15:04:05"), ip, user, action)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Multi struct {
|
||||
Roots []Local
|
||||
Names []string
|
||||
}
|
||||
|
||||
func NewMulti(paths []string) Multi {
|
||||
m := Multi{}
|
||||
for _, p := range paths {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
continue
|
||||
}
|
||||
abs, _ := filepath.Abs(p)
|
||||
name := filepath.Base(filepath.Clean(abs))
|
||||
if name == "." || name == string(filepath.Separator) || name == "" {
|
||||
name = "root"
|
||||
}
|
||||
base := name
|
||||
for i := 2; containsString(m.Names, name); i++ {
|
||||
name = base + "-" + strconv.Itoa(i)
|
||||
}
|
||||
m.Roots = append(m.Roots, Local{Root: abs})
|
||||
m.Names = append(m.Names, name)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func containsString(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Multi) isVirtualRoot(p string) bool { return strings.Trim(p, "/") == "" }
|
||||
|
||||
func (m Multi) FindRoot(p string) (string, string, bool) {
|
||||
p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/")
|
||||
if p == "" {
|
||||
return "", "", false
|
||||
}
|
||||
parts := strings.SplitN(p, "/", 2)
|
||||
for _, n := range m.Names {
|
||||
if parts[0] != n {
|
||||
continue
|
||||
}
|
||||
rel := ""
|
||||
if len(parts) == 2 {
|
||||
rel = parts[1]
|
||||
}
|
||||
return n, rel, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func (m Multi) find(p string) (Local, string, bool) {
|
||||
p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/")
|
||||
if p == "" {
|
||||
return Local{}, "", false
|
||||
}
|
||||
parts := strings.SplitN(p, "/", 2)
|
||||
for i, n := range m.Names {
|
||||
if parts[0] != n {
|
||||
continue
|
||||
}
|
||||
rel := ""
|
||||
if len(parts) == 2 {
|
||||
rel = parts[1]
|
||||
}
|
||||
return m.Roots[i], rel, true
|
||||
}
|
||||
return Local{}, "", false
|
||||
}
|
||||
|
||||
func (m Multi) Stat(p string) (os.FileInfo, error) {
|
||||
if m.isVirtualRoot(p) {
|
||||
return virtualDirInfo{name: "."}, nil
|
||||
}
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return r.Stat(rel)
|
||||
}
|
||||
func (m Multi) Open(p string) (*os.File, error) {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return r.Open(rel)
|
||||
}
|
||||
func (m Multi) Create(p string) (*os.File, error) {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return r.Create(rel)
|
||||
}
|
||||
|
||||
func (m Multi) CreateExclusive(p string) (*os.File, error) {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return r.CreateExclusive(rel)
|
||||
}
|
||||
func (m Multi) Remove(p string) error {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok || rel == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return r.Remove(rel)
|
||||
}
|
||||
|
||||
func (m Multi) Trash(p string) error {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok || rel == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return r.Trash(rel)
|
||||
}
|
||||
|
||||
func (m Multi) MoveToTrash(p, trashRoot string) error {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok || rel == "" {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return r.MoveToTrash(rel, filepath.Join(trashRoot, m.RootName(r)))
|
||||
}
|
||||
|
||||
func (m Multi) RootName(r Local) string {
|
||||
for i := range m.Roots {
|
||||
if m.Roots[i].Root == r.Root {
|
||||
return m.Names[i]
|
||||
}
|
||||
}
|
||||
return "root"
|
||||
}
|
||||
func (m Multi) Copy(a, b string) error {
|
||||
ra, aa, oka := m.find(a)
|
||||
rb, bb, okb := m.find(b)
|
||||
if !oka || !okb || aa == "" || bb == "" || ra.Root != rb.Root {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return ra.Copy(aa, bb)
|
||||
}
|
||||
func (m Multi) Rename(a, b string) error {
|
||||
ra, aa, oka := m.find(a)
|
||||
rb, bb, okb := m.find(b)
|
||||
if !oka || !okb || aa == "" || bb == "" || ra.Root != rb.Root {
|
||||
return os.ErrInvalid
|
||||
}
|
||||
return ra.Rename(aa, bb)
|
||||
}
|
||||
func (m Multi) MkdirAll(p string) error {
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return r.MkdirAll(rel)
|
||||
}
|
||||
func (m Multi) List(p string) ([]os.FileInfo, error) {
|
||||
p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/")
|
||||
if p == "" {
|
||||
out := make([]os.FileInfo, 0, len(m.Names))
|
||||
for _, n := range m.Names {
|
||||
out = append(out, virtualDirInfo{name: n})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
r, rel, ok := m.find(p)
|
||||
if !ok {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return r.List(rel)
|
||||
}
|
||||
|
||||
type virtualDirInfo struct{ name string }
|
||||
|
||||
func (v virtualDirInfo) Name() string { return v.name }
|
||||
func (v virtualDirInfo) Size() int64 { return 0 }
|
||||
func (v virtualDirInfo) Mode() os.FileMode { return os.ModeDir | 0555 }
|
||||
func (v virtualDirInfo) ModTime() (t time.Time) { return time.Time{} }
|
||||
func (v virtualDirInfo) IsDir() bool { return true }
|
||||
func (v virtualDirInfo) Sys() any { return nil }
|
||||
@@ -0,0 +1,89 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMultiUsesUniqueVirtualRootNames(t *testing.T) {
|
||||
a := t.TempDir()
|
||||
b := t.TempDir()
|
||||
m := NewMulti([]string{a, b})
|
||||
if len(m.Names) != 2 || m.Names[0] == m.Names[1] {
|
||||
t.Fatalf("names=%v", m.Names)
|
||||
}
|
||||
if _, _, ok := m.FindRoot(m.Names[0]); !ok {
|
||||
t.Fatal("first root not addressable")
|
||||
}
|
||||
if _, _, ok := m.FindRoot(m.Names[1]); !ok {
|
||||
t.Fatal("second root not addressable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiRenameCannotCrossRoots(t *testing.T) {
|
||||
a := t.TempDir()
|
||||
b := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(a, "src.txt"), []byte("src"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := NewMulti([]string{a, b})
|
||||
err := m.Rename(m.Names[0]+"/src.txt", m.Names[1]+"/dst.txt")
|
||||
if err == nil {
|
||||
t.Fatal("expected cross-root rename to fail")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(a, "src.txt")); err != nil {
|
||||
t.Fatalf("source disappeared: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(b, "dst.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("destination unexpectedly created: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiRemoveCannotRemoveVirtualRoot(t *testing.T) {
|
||||
m := NewMulti([]string{t.TempDir()})
|
||||
if err := m.Remove(""); err == nil {
|
||||
t.Fatal("expected virtual root removal to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalCreateExclusiveRejectsExistingFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
st := Local{Root: root}
|
||||
if err := os.WriteFile(filepath.Join(root, "x.txt"), []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := st.CreateExclusive("x.txt")
|
||||
if !os.IsExist(err) || f != nil {
|
||||
t.Fatalf("expected EEXIST, file=%v err=%v", f, err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(root, "x.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "old" {
|
||||
t.Fatalf("existing file changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalCreateExclusiveCreatesMissingFile(t *testing.T) {
|
||||
st := Local{Root: t.TempDir()}
|
||||
f, err := st.CreateExclusive("nested/x.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString("new"); err != nil {
|
||||
f.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(st.Root, "nested", "x.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "new" {
|
||||
t.Fatalf("unexpected content: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/Bios-Marcel/wastebasket"
|
||||
)
|
||||
|
||||
type Trashable interface {
|
||||
Trash(path string) error
|
||||
MoveToTrash(path, trashRoot string) error
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
Stat(path string) (os.FileInfo, error)
|
||||
Open(path string) (*os.File, error)
|
||||
Create(path string) (*os.File, error)
|
||||
Remove(path string) error
|
||||
Rename(oldPath, newPath string) error
|
||||
Copy(oldPath, newPath string) error
|
||||
MkdirAll(path string) error
|
||||
List(path string) ([]os.FileInfo, error)
|
||||
}
|
||||
|
||||
type ExclusiveCreator interface {
|
||||
CreateExclusive(path string) (*os.File, error)
|
||||
}
|
||||
|
||||
type Local struct{ Root string }
|
||||
|
||||
func (s Local) real(path string) (string, error) {
|
||||
root, err := filepath.Abs(s.Root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
root, err = filepath.EvalSymlinks(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := strings.TrimPrefix(filepath.Clean("/"+path), string(filepath.Separator))
|
||||
full := filepath.Join(root, p)
|
||||
rel, err := filepath.Rel(root, full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("path escapes storage root")
|
||||
}
|
||||
check := full
|
||||
for {
|
||||
if _, err = os.Lstat(check); !os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
next := filepath.Dir(check)
|
||||
if next == check {
|
||||
break
|
||||
}
|
||||
check = next
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(check)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolvedRel, err := filepath.Rel(root, resolved)
|
||||
if err != nil || resolvedRel == ".." || strings.HasPrefix(resolvedRel, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("path escapes storage root")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
func (s Local) Stat(path string) (os.FileInfo, error) {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return os.Stat(p)
|
||||
}
|
||||
func (s Local) Open(path string) (*os.File, error) {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return os.Open(p)
|
||||
}
|
||||
func (s Local) Create(path string) (*os.File, error) {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return os.Create(p)
|
||||
}
|
||||
|
||||
func (s Local) CreateExclusive(path string) (*os.File, error) {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644)
|
||||
}
|
||||
func (s Local) Remove(path string) error {
|
||||
p, e := s.real(path)
|
||||
if e == nil {
|
||||
return os.RemoveAll(p)
|
||||
}
|
||||
// A final symlink that points outside the root is still safe to delete:
|
||||
// remove the link itself, but never follow it to its target. Validate its
|
||||
// parent through real() so nested symlink escapes remain rejected.
|
||||
clean := strings.TrimPrefix(filepath.Clean("/"+path), string(filepath.Separator))
|
||||
if clean == "" {
|
||||
return e
|
||||
}
|
||||
parent := filepath.ToSlash(filepath.Dir(clean))
|
||||
if parent == "." {
|
||||
parent = ""
|
||||
}
|
||||
if _, parentErr := s.real(parent); parentErr != nil {
|
||||
return e
|
||||
}
|
||||
root, rootErr := filepath.Abs(s.Root)
|
||||
if rootErr != nil {
|
||||
return e
|
||||
}
|
||||
root, rootErr = filepath.EvalSymlinks(root)
|
||||
if rootErr != nil {
|
||||
return e
|
||||
}
|
||||
candidate := filepath.Join(root, filepath.FromSlash(clean))
|
||||
if info, lstatErr := os.Lstat(candidate); lstatErr == nil && info.Mode()&os.ModeSymlink != 0 {
|
||||
return os.Remove(candidate)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func (s Local) Trash(path string) error {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return wastebasket.Trash(p)
|
||||
}
|
||||
|
||||
func (s Local) MoveToTrash(path, trashRoot string) error {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
root, e := filepath.Abs(s.Root)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
rel, e := filepath.Rel(root, p)
|
||||
if e != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errors.New("path escapes storage root")
|
||||
}
|
||||
if e = os.MkdirAll(trashRoot, 0755); e != nil {
|
||||
return e
|
||||
}
|
||||
// Preserve the original relative path under the CHFS trash root so
|
||||
// identically named files from different directories cannot collide.
|
||||
target := filepath.Join(trashRoot, rel)
|
||||
if e = os.MkdirAll(filepath.Dir(target), 0755); e != nil {
|
||||
return e
|
||||
}
|
||||
for i := 1; ; i++ {
|
||||
if _, e = os.Lstat(target); os.IsNotExist(e) {
|
||||
break
|
||||
}
|
||||
target = filepath.Join(filepath.Dir(target), fmt.Sprintf("%s.%d", filepath.Base(p), i))
|
||||
}
|
||||
if e = movePath(p, target); e != nil {
|
||||
return e
|
||||
}
|
||||
// Cleanup is maintenance work; a successful move must not be reported as
|
||||
// a failed delete merely because an unrelated stale trash entry is busy.
|
||||
_ = cleanupTrash(trashRoot, 30*24*time.Hour)
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupTrash(root string, maxAge time.Duration) error {
|
||||
now := time.Now()
|
||||
return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == root || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if now.Sub(info.ModTime()) > maxAge {
|
||||
return os.Remove(path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func movePath(src, dst string) error {
|
||||
if err := os.Rename(src, dst); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, syscall.EXDEV) {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Lstat(dst); err == nil {
|
||||
return fmt.Errorf("destination already exists: %s", dst)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err := copyPath(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.RemoveAll(src)
|
||||
}
|
||||
|
||||
func copyPath(src, dst string) error {
|
||||
info, err := os.Lstat(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
target, err := os.Readlink(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(target, dst)
|
||||
}
|
||||
if info.IsDir() {
|
||||
if err := os.MkdirAll(dst, info.Mode().Perm()); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := copyPath(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.Chtimes(dst, info.ModTime(), info.ModTime())
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, copyErr := io.Copy(out, in)
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(dst)
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(dst)
|
||||
return closeErr
|
||||
}
|
||||
return os.Chtimes(dst, info.ModTime(), info.ModTime())
|
||||
}
|
||||
|
||||
func (s Local) Copy(a, b string) error {
|
||||
pa, e := s.real(a)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
pb, e := s.real(b)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if _, e := os.Lstat(pb); e == nil {
|
||||
return os.ErrExist
|
||||
} else if !os.IsNotExist(e) {
|
||||
return e
|
||||
}
|
||||
return copyPath(pa, pb)
|
||||
}
|
||||
|
||||
func (s Local) Rename(a, b string) error {
|
||||
pa, e := s.real(a)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
pb, e := s.real(b)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return movePath(pa, pb)
|
||||
}
|
||||
func (s Local) MkdirAll(path string) error {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
return os.MkdirAll(p, 0755)
|
||||
}
|
||||
func (s Local) List(path string) ([]os.FileInfo, error) {
|
||||
p, e := s.real(path)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
f, e := os.Open(p)
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
defer f.Close()
|
||||
items, err := f.Readdir(-1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Name() < items[j].Name() })
|
||||
return items, nil
|
||||
}
|
||||
|
||||
var _ fs.FS
|
||||
@@ -0,0 +1,101 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLocalRemoveSymlinkDoesNotDeleteTarget(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := filepath.Join(outside, "secret.txt")
|
||||
if err := os.WriteFile(target, []byte("keep"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(root, "link")
|
||||
if err := os.Symlink(target, link); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
store := Local{Root: root}
|
||||
if err := store.Remove("link"); err != nil {
|
||||
t.Fatalf("remove symlink: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("symlink removal must not delete target: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(link); !os.IsNotExist(err) {
|
||||
t.Fatalf("symlink should be removed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalRejectsSymlinkEscapeAndAllowsNestedCreate(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(outside, filepath.Join(root, "escape")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := Local{Root: root}
|
||||
if _, err := s.Open("escape/secret.txt"); err == nil {
|
||||
t.Fatal("expected symlink escape to be rejected")
|
||||
}
|
||||
f, err := s.Create("a/b/c.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("nested create failed: %v", err)
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
|
||||
func TestMoveToTrashPreservesRelativePathAndCleansOldEntries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
trash := t.TempDir()
|
||||
s := Local{Root: root}
|
||||
if err := os.MkdirAll(filepath.Join(root, "one", "two"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "one", "two", "same.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(trash, "old"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := filepath.Join(trash, "old", "stale.txt")
|
||||
if err := os.WriteFile(old, []byte("old"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldTime := time.Now().Add(-31 * 24 * time.Hour)
|
||||
if err := os.Chtimes(old, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MoveToTrash("one/two/same.txt", trash); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(trash, "one", "two", "same.txt")); err != nil {
|
||||
t.Fatalf("trash path not preserved: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(old); !os.IsNotExist(err) {
|
||||
t.Fatalf("stale trash entry was not cleaned: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyPathPreservesSymlink(t *testing.T) {
|
||||
src := filepath.Join(t.TempDir(), "link")
|
||||
dst := filepath.Join(t.TempDir(), "link")
|
||||
if err := os.Symlink("target", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyPath(src, dst); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.Readlink(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "target" {
|
||||
t.Fatalf("got symlink target %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user