diff --git a/filesys/fd.go b/filesys/fd.go new file mode 100644 index 0000000..091ddd9 --- /dev/null +++ b/filesys/fd.go @@ -0,0 +1,7 @@ +//go:build darwin || linux + +package filesys + +func FdType(fd int) int { + return fd +} diff --git a/filesys/fd_windows.go b/filesys/fd_windows.go new file mode 100644 index 0000000..a67c1e9 --- /dev/null +++ b/filesys/fd_windows.go @@ -0,0 +1,7 @@ +package filesys + +import "syscall" + +func FdType(fd int) syscall.Handle { + return syscall.Handle(fd) +} diff --git a/filesys/handler.go b/filesys/handler.go new file mode 100644 index 0000000..605ff99 --- /dev/null +++ b/filesys/handler.go @@ -0,0 +1,319 @@ +package filesys + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "log" + "net/http" + "os" + "strings" + "syscall" +) + +// Handler translates json payload data to and from system calls like syscall.Stat +type Handler struct { + debug bool + securityToken string + logger *log.Logger +} + +func NewHandler(securityToken string, logger *log.Logger) *Handler { + return &Handler{ + debug: false, + securityToken: securityToken, + logger: logger, + } +} + +func (fa *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("WBT-Token") != fa.securityToken { + fa.doError("not implemented", "ENOSYS", w, errors.New("missing WBT-token")) + return + } + switch r.URL.Path { + case "/fs/stat": + fa.handle(&Stat{}, w, r) + case "/fs/fstat": + fa.handle(&Fstat{}, w, r) + case "/fs/open": + fa.handle(&Open{}, w, r) + case "/fs/write": + fa.handle(&Write{}, w, r) + case "/fs/close": + fa.handle(&Close{}, w, r) + case "/fs/rename": + fa.handle(&Rename{}, w, r) + case "/fs/readdir": + fa.handle(&Readdir{}, w, r) + case "/fs/lstat": + fa.handle(&Lstat{}, w, r) + case "/fs/read": + fa.handle(&Read{}, w, r) + case "/fs/mkdir": + fa.handle(&Mkdir{}, w, r) + case "/fs/unlink": + fa.handle(&Unlink{}, w, r) + case "/fs/rmdir": + fa.handle(&Rmdir{}, w, r) + default: + fa.doError("not implemented", "ENOSYS", w, + fmt.Errorf("unsupported api path %q", r.URL.Path)) + } +} + +type Responder interface { + WriteResponse(fa *Handler, w http.ResponseWriter) +} + +func (fa *Handler) handle(responder Responder, w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(responder); err != nil { + fa.logger.Printf("ERROR handle : %v\n", err) + w.WriteHeader(http.StatusBadRequest) + return + } + if fa.debug { + fa.logger.Printf("handle %s %+v\n", r.URL.Path, responder) + } + responder.WriteResponse(fa, w) +} + +type ErrorCode struct { + Error string `json:"error"` + Code string `json:"code"` +} + +func (fa *Handler) doError(msg, code string, w http.ResponseWriter, err error) { + if fa.debug { + fa.logger.Printf("doError %s : %s\n", msg, code) + } + if err != nil { + fa.logger.Printf("Error: %v", err) + } + + e := &ErrorCode{Error: msg, Code: code} + + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(e); err != nil { + fa.logger.Printf("Error encoding json: %v", err) + } +} + +func (fa *Handler) okResponse(data any, w http.ResponseWriter) { + var marshal []byte + var err error + if marshal, err = json.Marshal(data); err != nil { + fa.logger.Println("okResponse json error:", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if fa.debug { + fa.logger.Printf("okResponse %s\n", string(marshal)) + } + + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "application/json") + if _, err = w.Write(marshal); err != nil { + fa.logger.Printf("Error writing json: %v", err) + } +} + +func fixPath(path string) string { + return strings.TrimPrefix(path, "/fs/") +} + +type Stat struct { + Path string `json:"path,omitempty"` +} + +type Open struct { + Path string `json:"path"` + Flags int `json:"flags"` + Mode uint32 `json:"mode"` +} + +func (o *Open) WriteResponse(fa *Handler, w http.ResponseWriter) { + fd, err := syscall.Open(fixPath(o.Path), o.Flags, o.Mode) + if fa.handleError(w, err, true) { + return + } + response := map[string]any{"fd": fd} + fa.okResponse(response, w) +} + +type Fstat struct { + Fd int `json:"fd"` +} + +type Write struct { + Fd int `json:"fd"` + Buffer string `json:"buffer"` + Offset int `json:"offset"` + Length int `json:"length"` + Position *int `json:"position,omitempty"` +} + +func (wr *Write) WriteResponse(fa *Handler, w http.ResponseWriter) { + if wr.Offset != 0 { + fa.doError("not implemented", "ENOSYS", w, + fmt.Errorf("write offset %d not supported", wr.Offset)) + return + } + if wr.Position != nil { + _, err := syscall.Seek(FdType(wr.Fd), int64(*wr.Position), 0) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + } + + bytes, err := base64.StdEncoding.DecodeString(wr.Buffer) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + + var written int + written, err = syscall.Write(FdType(wr.Fd), bytes) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + + fa.okResponse(map[string]any{"written": written}, w) +} + +type Close struct { + Fd int `json:"fd"` +} + +func (c *Close) WriteResponse(fa *Handler, w http.ResponseWriter) { + err := syscall.Close(FdType(c.Fd)) + if fa.handleError(w, err, false) { + return + } + fa.okResponse(map[string]any{}, w) +} + +type Rename struct { + From string `json:"from"` + To string `json:"to"` +} + +func (r *Rename) WriteResponse(fa *Handler, w http.ResponseWriter) { + err := syscall.Rename(fixPath(r.From), fixPath(r.To)) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(map[string]any{}, w) +} + +type Readdir struct { + Path string `json:"path"` +} + +func (r *Readdir) WriteResponse(fa *Handler, w http.ResponseWriter) { + entries, err := os.ReadDir(fixPath(r.Path)) + if fa.handleError(w, err, false) { + return + } + stringNames := make([]string, len(entries)) + for i, entry := range entries { + stringNames[i] = entry.Name() + } + fa.okResponse(map[string]any{"entries": stringNames}, w) +} + +type Lstat struct { + Path string `json:"path"` +} + +type Read struct { + Fd int `json:"fd"` + Offset int `json:"offset"` + Length int `json:"length"` + Position *int `json:"position,omitempty"` +} + +func (r *Read) WriteResponse(fa *Handler, w http.ResponseWriter) { + if r.Offset != 0 { + fa.doError("not implemented", "ENOSYS", w, + fmt.Errorf("read offset %d not supported", r.Offset)) + return + } + if r.Position != nil { + _, err := syscall.Seek(FdType(r.Fd), int64(*r.Position), 0) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + } + + buffer := make([]byte, r.Length) + read, err := syscall.Read(FdType(r.Fd), buffer) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + response := map[string]any{ + "read": read, + "buffer": base64.StdEncoding.EncodeToString(buffer[:read]), + } + fa.okResponse(response, w) + +} + +type Mkdir struct { + Path string `json:"path"` + Perm uint32 `json:"perm"` +} + +func (m *Mkdir) WriteResponse(fa *Handler, w http.ResponseWriter) { + err := syscall.Mkdir(fixPath(m.Path), m.Perm) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + fa.okResponse(map[string]any{}, w) +} + +type Unlink struct { + Path string `json:"path"` +} + +func (u *Unlink) WriteResponse(fa *Handler, w http.ResponseWriter) { + err := syscall.Unlink(fixPath(u.Path)) + if err != nil { + fa.doError("not implemented", "ENOSYS", w, err) + return + } + fa.okResponse(map[string]any{}, w) +} + +type Rmdir struct { + Path string `json:"path"` +} + +func (r *Rmdir) WriteResponse(fa *Handler, w http.ResponseWriter) { + err := syscall.Rmdir(fixPath(r.Path)) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(map[string]any{}, w) +} + +func (fa *Handler) handleError(w http.ResponseWriter, err error, noEnt bool) bool { + if err == nil { + return false + } + if noEnt && os.IsNotExist(err) { + // We're not passing the error down for logging here since this is a + // file not found condition, not an actual error condition. + fa.doError(syscall.ENOENT.Error(), "ENOENT", w, nil) + } else { + fa.doError(syscall.ENOSYS.Error(), "ENOSYS", w, err) + } + return true +} diff --git a/filesys/handler_test.go b/filesys/handler_test.go new file mode 100644 index 0000000..85af1c2 --- /dev/null +++ b/filesys/handler_test.go @@ -0,0 +1,507 @@ +package filesys + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "math" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "sync" + "syscall" + "testing" +) + +const TOKEN = "test_token" + +func TestOpen_Missing(t *testing.T) { + help := Helper(t) + o := &Open{Path: help.tempPath("not_found.txt"), Flags: os.O_RDONLY, Mode: 0} + response := &ErrorCode{} + + help.httpBad(help.req("open", o, response)) + help.errorCode(response.Code, "ENOENT") +} + +func TestOpenClose(t *testing.T) { + help := Helper(t) + path := help.createFile("found.txt", "some data") + o := &Open{Path: path, Flags: os.O_RDONLY, Mode: 0} + c := &Close{} + + help.httpOk(help.req("open", o, c)) + help.true(c.Fd != 0, "no file descriptor returned") + + m := help.newMap() + help.httpOk(help.req("close", c, &m)) +} + +func TestStat(t *testing.T) { + help := Helper(t) + foundFile := help.createFile("found.txt", "some data") + payload := &Stat{Path: foundFile} + m := help.newMap() + + help.httpOk(help.req("stat", payload, &m)) + help.checkStatMap(m, foundFile) +} + +func TestStat_Missing(t *testing.T) { + help := Helper(t) + notFoundFile := help.tempPath("not_found.txt") + payload := &Stat{Path: notFoundFile} + errorCode := &ErrorCode{} + + help.httpBad(help.req("stat", payload, &errorCode)) + help.errorCode(errorCode.Code, "ENOENT") +} + +func TestFstat(t *testing.T) { + help := Helper(t) + tempFile := help.createFile("exists", "some data") + fd, closeFd := help.sysOpen(tempFile) + defer closeFd() + + m := help.newMap() + fstat := map[string]any{"fd": fd} + + help.httpOk(help.req("fstat", fstat, &m)) + help.checkStatMap(m, tempFile) + + // bad file descriptor test + fstat = map[string]any{"fd": math.MaxInt64} + help.httpBad(help.req("fstat", fstat, &m)) +} + +func TestLstat(t *testing.T) { + help := Helper(t) + exists := help.createFile("exists.txt", "some data") + m := help.newMap() + payload := &Lstat{Path: exists} + + help.httpOk(help.req("lstat", payload, &m)) + help.checkStatMap(m, exists) + + // test missing file case + errCode := &ErrorCode{} + payload = &Lstat{Path: help.tempPath("missing.txt")} + + help.httpBad(help.req("lstat", payload, errCode)) + help.errorCode(errCode.Code, "ENOENT") +} + +type readDirResult struct { + Entries []string `json:"entries"` +} + +func TestReaddir(t *testing.T) { + help := Helper(t) + + help.createFile("exists.txt", "some data") + r := &readDirResult{} + payload := &Readdir{Path: help.tmpDir} + + help.httpOk(help.req("readdir", payload, r)) + help.true(len(r.Entries) == 1, "incorrect entries length") + + payload = &Readdir{Path: help.tempPath("badDirectory")} + help.httpBad(help.req("readdir", payload, r)) +} + +func TestRename(t *testing.T) { + help := Helper(t) + existsFile := help.createFile("exists.txt", "some data") + missingFile := help.tempPath("missing.txt") + renameTo := help.tempPath("rename.txt") + e := &ErrorCode{} + payload := &Rename{From: existsFile, To: renameTo} + + help.httpOk(help.req("rename", payload, e)) + help.exists(renameTo) + + // test renaming a missing file + e = &ErrorCode{} + payload = &Rename{From: missingFile, To: renameTo} + help.httpBad(help.req("rename", payload, e)) + help.errorCode(e.Code, "ENOENT") +} + +func TestWrite(t *testing.T) { + help := Helper(t) + writtenFile := help.tempPath("written.txt") + openMap := help.newMap() + payload := &Open{Path: writtenFile, Flags: os.O_RDWR | os.O_CREATE | os.O_TRUNC, Mode: 0777} + + help.httpOk(help.req("open", payload, &openMap)) + defer help.deferCloseFd(openMap) + + contents := "some sample file contents" + buffer := base64.StdEncoding.EncodeToString([]byte(contents)) + w := map[string]any{"fd": openMap["fd"], "buffer": buffer, "length": len(contents)} + + writeResult := help.newMap() + help.httpOk(help.req("write", w, &writeResult)) + + written, ok := writeResult["written"].(float64) + help.true(ok, "written value in return missing") + help.true(int(written) == len(contents), "incorrect written length") +} + +func TestWrite_with_position(t *testing.T) { + help := Helper(t) + writtenFile := help.createFile("writeSeek.txt", "1234567890") + openMap := help.newMap() + payload := &Open{Path: writtenFile, Flags: os.O_RDWR, Mode: 0777} + + help.httpOk(help.req("open", payload, &openMap)) + + contents := "ZZZ" + buffer := base64.StdEncoding.EncodeToString([]byte(contents)) + w := map[string]any{"fd": openMap["fd"], "position": 5, "buffer": buffer, "length": len(contents)} + + writeResult := help.newMap() + help.httpOk(help.req("write", w, &writeResult)) + + written, ok := writeResult["written"].(float64) + help.true(ok, "written value in return missing") + help.true(int(written) == len(contents), "incorrect written length") + + help.httpOk(help.req("close", openMap, &ErrorCode{})) + + file, err := os.ReadFile(writtenFile) + help.nilErr(err) + help.true("12345ZZZ90" == string(file), fmt.Sprintf("expected 12345ZZZ90 but got %q", string(file))) +} + +func TestWrite_bad(t *testing.T) { + help := Helper(t) + writtenFile := help.tempPath("written.txt") + openMap := help.newMap() + payload := &Open{Path: writtenFile, Flags: os.O_RDWR | os.O_CREATE | os.O_TRUNC, Mode: 0777} + + help.httpOk(help.req("open", payload, &openMap)) + defer help.deferCloseFd(openMap) + + // failing test cases + help.httpBad(help.req("write", &Write{Offset: 1}, &ErrorCode{})) + help.httpBad(help.req("write", &Write{Buffer: "%%%"}, &ErrorCode{})) + help.httpBad(help.req("write", &Write{Buffer: ""}, &ErrorCode{})) +} + +func TestClose_bad(t *testing.T) { + help := Helper(t) + closeMap := map[string]any{"fd": math.MaxInt64} + + // close with bad file descriptor should error + help.httpBad(help.req("close", closeMap, &ErrorCode{})) +} + +func TestMkdir(t *testing.T) { + help := Helper(t) + payload := &Mkdir{Path: help.tempPath("nested")} + help.httpOk(help.req("mkdir", payload, &ErrorCode{})) + help.exists(payload.Path) + + // mkdir without parent directory should fail + if runtime.GOOS != "windows" { + payload = &Mkdir{Path: help.tempPath("nested/levels")} + help.httpBad(help.req("mkdir", payload, &ErrorCode{})) + } +} + +func TestRmdir(t *testing.T) { + help := Helper(t) + payload := &Rmdir{Path: help.tempPath("nested")} + help.httpOk(help.req("mkdir", payload, &ErrorCode{})) + help.exists(payload.Path) + + help.httpOk(help.req("rmdir", payload, &ErrorCode{})) + _, err := os.Stat(payload.Path) + help.true(os.IsNotExist(err), "directory not removed") + + response := &ErrorCode{} + payload.Path = help.tempPath("missing") + help.httpBad(help.req("rmdir", payload, response)) + +} + +func TestUnlink(t *testing.T) { + help := Helper(t) + + tempFile := help.createFile("delete_me.txt", "to delete") + payload := &Unlink{Path: tempFile} + help.httpOk(help.req("unlink", payload, &ErrorCode{})) + + payload = &Unlink{Path: help.tempPath("missing.txt")} + help.httpBad(help.req("unlink", payload, &ErrorCode{})) +} + +type readResult struct { + Read int `json:"read"` + Buffer string `json:"buffer"` +} + +func TestRead(t *testing.T) { + help := Helper(t) + + content := "some data" + tmpFile := help.createFile("file.txt", content) + m := help.newMap() + help.httpOk(help.req("open", &Open{Path: tmpFile}, &m)) + defer help.deferCloseFd(m) + + readMap := map[string]any{"fd": m["fd"], "offset": 0, "length": len(content)} + resultMap := &readResult{} + help.httpOk(help.req("read", readMap, &resultMap)) + help.true(resultMap.Read == len(content), "read length incorrect") + + decodedRead, errDecode := base64.StdEncoding.DecodeString(resultMap.Buffer) + help.nilErr(errDecode).true(string(decodedRead) == content, "read data did not match") + + readMap = map[string]any{"fd": m["fd"], "offset": 0, "position": 1, "length": len(content) - 1} + help.httpOk(help.req("read", readMap, &resultMap)) + decodedRead, errDecode = base64.StdEncoding.DecodeString(resultMap.Buffer) + help.nilErr(errDecode).true(string(decodedRead) == content[1:], "read data did not match") + + readMap = map[string]any{"fd": m["fd"], "offset": 1, "length": len(content) - 1} + help.httpBad(help.req("read", readMap, &resultMap)) + + readMap = map[string]any{"fd": m["fd"], "offset": 0, "position": -1, "length": 1} + help.httpBad(help.req("read", readMap, &resultMap)) + + // read on bad file descriptor + readMap = map[string]any{"fd": math.MaxInt64, "offset": 0, "length": len(content)} + help.httpBad(help.req("read", readMap, &resultMap)) + +} + +func Test_handle(t *testing.T) { + help := Helper(t) + + header := make(http.Header) + header.Set("WBT-Token", TOKEN) + u, err := url.Parse("http://localhost:12345/fs/open") + help.nilErr(err) + + w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} + request := &http.Request{URL: u, Header: header, Body: io.NopCloser(bytes.NewBufferString(""))} + help.handler.handle(&Open{}, w, request) + help.httpBad(w.Code) +} + +func TestToken(t *testing.T) { + help := Helper(t) + + u, err := url.Parse("http://localhost:12345/fs/open") + help.nilErr(err) + + w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} + request := &http.Request{URL: u, Body: io.NopCloser(bytes.NewBufferString("{}"))} + help.handler.ServeHTTP(w, request) + help.httpBad(w.Code) +} + +func TestServeDefault(t *testing.T) { + help := Helper(t) + u, err := url.Parse("http://localhost:12345/fs/badpath") + help.nilErr(err) + header := make(http.Header) + header.Set("WBT-Token", TOKEN) + w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} + request := &http.Request{URL: u, Header: header, Body: io.NopCloser(bytes.NewBufferString("{}"))} + + help.handler.ServeHTTP(w, request) + help.httpBad(w.Code) +} + +func Test_doError(t *testing.T) { + help := Helper(t) + w := &BrokenResponseRecorder{} + help.handler.doError("msg", "code", w, nil) +} + +func Test_okResponse(t *testing.T) { + help := Helper(t) + w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} + m := help.newMap() + help.handler.okResponse(m, w) + help.true(w.Body.String() == "{}", "body string did not match") + help.true(w.Header().Get("Content-Type") == "application/json", "bad content type header") + + // test case for bad serialization + w = &httptest.ResponseRecorder{Body: &bytes.Buffer{}} + help.handler.okResponse(&badJson{}, w) + help.true(w.Body.String() == "", "body should be empty") + help.true(w.Code == http.StatusInternalServerError, "bad http code") +} + +type badJson struct { + Broken func() `json:"broken"` +} + +// END TESTS + +type BrokenResponseRecorder struct { + httptest.ResponseRecorder +} + +func (rw *BrokenResponseRecorder) Write(buf []byte) (int, error) { + return 0, fmt.Errorf("broken pipe for data %q", string(buf)) +} + +// Helper provides convenience methods for testing the handler. +func Helper(t *testing.T) *helperApi { + help := &helperApi{ + t: t, + m: &sync.Mutex{}, + tmpDir: t.TempDir(), + } + var logger *log.Logger + if os.Getenv("DEBUG_FS_HANDLER") != "" { + logger = log.New(os.Stderr, "[wasmbrowsertest]: ", log.LstdFlags|log.Lshortfile) + } else { + logger = log.New(io.Discard, "", 0) + } + help.handler = NewHandler(TOKEN, logger) + help.handler.debug = true + help.tmpDir = t.TempDir() + return help +} + +type helperApi struct { + t *testing.T + m *sync.Mutex + handler *Handler + tmpDir string +} + +func (h *helperApi) req(path string, payload any, response any) (code int) { + h.t.Helper() + + u, err := url.Parse("http://localhost:12345/fs/" + path) + h.nilErr(err) + header := make(http.Header) + header.Set("WBT-Token", TOKEN) + + body, err := json.Marshal(payload) + h.nilErr(err) + + req := &http.Request{URL: u, Header: header, Body: io.NopCloser(bytes.NewReader(body))} + w := &httptest.ResponseRecorder{Body: &bytes.Buffer{}} + h.handler.ServeHTTP(w, req) + + err = json.Unmarshal(w.Body.Bytes(), response) + h.nilErr(err) + return w.Code +} + +func (h *helperApi) tempPath(path string) string { + h.t.Helper() + return filepath.Join(h.tmpDir, path) +} + +func (h *helperApi) createFile(path string, contents string) string { + h.t.Helper() + filePath := h.tempPath(path) + h.nilErr(os.WriteFile(filePath, []byte(contents), 0644)) + return filePath +} + +func (h *helperApi) nilErr(err error) *helperApi { + h.t.Helper() + if err != nil { + h.t.Fatal(err) + } + return h +} + +func (h *helperApi) exists(path string) { + h.t.Helper() + _, err := os.Stat(path) + h.true(err == nil, fmt.Sprintf("path %s does not exist", path)) +} + +func (h *helperApi) httpOk(code int) *helperApi { + h.t.Helper() + if code != http.StatusOK { + h.t.Fatalf("incorrect http code %d - expected 200", code) + } + return h +} + +func (h *helperApi) httpBad(code int) *helperApi { + h.t.Helper() + if code != http.StatusBadRequest { + h.t.Fatalf("incorrect http code %d - expected 400", code) + } + return h +} + +func (h *helperApi) errorCode(actual, expected string) *helperApi { + h.t.Helper() + if actual != expected { + h.t.Fatalf("incorrect error code %q - expected %q", actual, expected) + } + return h +} + +func (h *helperApi) true(condition bool, message string) *helperApi { + h.t.Helper() + if !condition { + h.t.Fatal(message) + } + return h +} + +func (h *helperApi) newMap() map[string]any { + return map[string]any{} +} + +func (h *helperApi) checkStatMap(m map[string]any, path string) { + h.t.Helper() + + stat, err := os.Stat(path) + h.nilErr(err) + + if size, ok := m["size"].(int64); ok { + if size != stat.Size() { + h.t.Fatal("got incorrect size") + } + } + if mTime, ok := m["mtimeMs"].(int64); ok { + if mTime != stat.ModTime().UnixMilli() { + h.t.Fatal("got incorrect mtimeMs") + } + } + if mode, ok := m["mode"].(int64); ok { + if mode != int64(stat.Mode()) { + h.t.Fatal("got incorrect mode") + } + } +} + +// sysOpen returns a file descriptor for an existing file and a function to close it. +// The return type is any to support both int and windows descriptors. +func (h *helperApi) sysOpen(path string) (result any, deferClose func()) { + h.t.Helper() + fd, err := syscall.Open(path, 0, 0) + h.nilErr(err) + return fd, func() { + errClose := syscall.Close(fd) + if errClose != nil { + h.t.Fatal(errClose) + } + } +} + +func (h *helperApi) deferCloseFd(m map[string]any) { + h.t.Helper() + h.httpOk(h.req("close", m, &ErrorCode{})) +} diff --git a/filesys/stat.go b/filesys/stat.go new file mode 100644 index 0000000..9e90c42 --- /dev/null +++ b/filesys/stat.go @@ -0,0 +1,35 @@ +//go:build darwin || linux + +package filesys + +import ( + "net/http" + "syscall" +) + +func (st *Stat) WriteResponse(fa *Handler, w http.ResponseWriter) { + s := &syscall.Stat_t{} + err := syscall.Stat(fixPath(st.Path), s) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(mapOfStatT(s), w) +} + +func (f *Fstat) WriteResponse(fa *Handler, w http.ResponseWriter) { + s := &syscall.Stat_t{} + err := syscall.Fstat(f.Fd, s) + if fa.handleError(w, err, false) { + return + } + fa.okResponse(mapOfStatT(s), w) +} + +func (ls *Lstat) WriteResponse(fa *Handler, w http.ResponseWriter) { + s := &syscall.Stat_t{} + err := syscall.Lstat(fixPath(ls.Path), s) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(mapOfStatT(s), w) +} diff --git a/filesys/stat_windows.go b/filesys/stat_windows.go new file mode 100644 index 0000000..7751ece --- /dev/null +++ b/filesys/stat_windows.go @@ -0,0 +1,73 @@ +//go:build windows + +package filesys + +import ( + "io/fs" + "net/http" + "os" + "syscall" +) + +func (st *Stat) WriteResponse(fa *Handler, w http.ResponseWriter) { + stat, err := os.Stat(fixPath(st.Path)) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(mapOfFileInfo(stat), w) +} + +func (f *Fstat) WriteResponse(fa *Handler, w http.ResponseWriter) { + fileInfo := &syscall.ByHandleFileInformation{} + err := syscall.GetFileInformationByHandle(FdType(f.Fd), fileInfo) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(mapOfByHandleFileInformation(fileInfo), w) +} + +func (ls *Lstat) WriteResponse(fa *Handler, w http.ResponseWriter) { + stat, err := os.Stat(fixPath(ls.Path)) + if fa.handleError(w, err, true) { + return + } + fa.okResponse(mapOfFileInfo(stat), w) +} + +func mapOfFileInfo(s os.FileInfo) map[string]any { + mode := s.Mode() & fs.ModePerm + if s.IsDir() { + mode |= 1 << 14 + } + return map[string]any{ + "dev": 0, "ino": 0, "mode": mode, + "nlink": 0, "uid": 1000, "gid": 1000, + "rdev": 0, "size": s.Size(), "blksize": 0, + "blocks": 0, "atimeMs": s.ModTime().UnixMilli(), + "mtimeMs": s.ModTime().UnixMilli(), "ctimeMs": s.ModTime().UnixMilli(), + } +} + +func mapOfByHandleFileInformation(s *syscall.ByHandleFileInformation) map[string]any { + size := int64(s.FileSizeHigh)<<32 + int64(s.FileSizeLow) + var mode os.FileMode + if s.FileAttributes&syscall.FILE_ATTRIBUTE_READONLY != 0 { + mode |= 0444 + } else { + mode |= 0666 + } + if s.FileAttributes&syscall.FILE_ATTRIBUTE_DIRECTORY != 0 { + mode |= 1 << 14 + } + + nsToMs := func(ft syscall.Filetime) int64 { + return ft.Nanoseconds() / 1e6 + } + return map[string]any{ + "dev": 0, "ino": 0, "mode": mode, + "nlink": 0, "uid": 1000, "gid": 1000, + "rdev": 0, "size": size, "blksize": 0, + "blocks": 0, "atimeMs": nsToMs(s.LastAccessTime), + "mtimeMs": nsToMs(s.LastWriteTime), "ctimeMs": nsToMs(s.CreationTime), + } +} diff --git a/filesys/statmap_darwin.go b/filesys/statmap_darwin.go new file mode 100644 index 0000000..aa34561 --- /dev/null +++ b/filesys/statmap_darwin.go @@ -0,0 +1,17 @@ +package filesys + +import "syscall" + +func mapOfStatT(s *syscall.Stat_t) map[string]any { + + toMs := func(ts syscall.Timespec) int64 { return ts.Sec*1000 + ts.Nsec/1e6 } + + // https://github.com/golang/go/blob/c19c4c566c63818dfd059b352e52c4710eecf14d/src/syscall/fs_js.go#L165 + return map[string]any{ + "dev": s.Dev, "ino": s.Ino, "mode": s.Mode, + "nlink": s.Nlink, "uid": s.Uid, "gid": s.Gid, + "rdev": s.Rdev, "size": s.Size, "blksize": s.Blksize, + "blocks": s.Blocks, "atimeMs": toMs(s.Atimespec), + "mtimeMs": toMs(s.Mtimespec), "ctimeMs": toMs(s.Ctimespec), + } +} diff --git a/filesys/statmap_linux.go b/filesys/statmap_linux.go new file mode 100644 index 0000000..4cd8489 --- /dev/null +++ b/filesys/statmap_linux.go @@ -0,0 +1,17 @@ +package filesys + +import "syscall" + +func mapOfStatT(s *syscall.Stat_t) map[string]any { + + toMs := func(ts syscall.Timespec) int64 { return ts.Sec*1000 + ts.Nsec/1e6 } + + // https://github.com/golang/go/blob/c19c4c566c63818dfd059b352e52c4710eecf14d/src/syscall/fs_js.go#L165 + return map[string]any{ + "dev": s.Dev, "ino": s.Ino, "mode": s.Mode, + "nlink": s.Nlink, "uid": s.Uid, "gid": s.Gid, + "rdev": s.Rdev, "size": s.Size, "blksize": s.Blksize, + "blocks": s.Blocks, "atimeMs": toMs(s.Atim), + "mtimeMs": toMs(s.Mtim), "ctimeMs": toMs(s.Ctim), + } +} diff --git a/handler.go b/handler.go index 8fc185d..d4b1b7f 100644 --- a/handler.go +++ b/handler.go @@ -1,9 +1,11 @@ package main import ( + "crypto/rand" _ "embed" + "encoding/base64" "html/template" - "io/ioutil" + "io" "log" "net/http" "os" @@ -13,37 +15,47 @@ import ( "strconv" "strings" "time" + + "github.com/agnivade/wasmbrowsertest/filesys" ) //go:embed index.html var indexHTML string type wasmServer struct { - indexTmpl *template.Template - wasmFile string - wasmExecJS []byte - args []string - coverageFile string - envMap map[string]string - logger *log.Logger + indexTmpl *template.Template + wasmFile string + wasmExecJS []byte + args []string + envMap map[string]string + logger *log.Logger + fsHandler *filesys.Handler + securityToken string } func NewWASMServer(wasmFile string, args []string, coverageFile string, l *log.Logger) (http.Handler, error) { var err error srv := &wasmServer{ - wasmFile: wasmFile, - args: args, - coverageFile: coverageFile, - logger: l, - envMap: make(map[string]string), + wasmFile: wasmFile, + args: args, + logger: l, + envMap: make(map[string]string), + } + + // try for some security on an api capable of + // reads and writes to the file system + srv.securityToken, err = generateToken() + if err != nil { + return nil, err } + srv.fsHandler = filesys.NewHandler(srv.securityToken, l) for _, env := range os.Environ() { vars := strings.SplitN(env, "=", 2) srv.envMap[vars[0]] = vars[1] } - buf, err := ioutil.ReadFile(path.Join(runtime.GOROOT(), "misc/wasm/wasm_exec.js")) + buf, err := os.ReadFile(path.Join(runtime.GOROOT(), "misc/wasm/wasm_exec.js")) if err != nil { return nil, err } @@ -62,15 +74,19 @@ func (ws *wasmServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "/", "/index.html": w.Header().Set("Content-Type", "text/html; charset=UTF-8") data := struct { - WASMFile string - Args []string - CoverageFile string - EnvMap map[string]string + WASMFile string + Args []string + EnvMap map[string]string + SecurityToken string + Pid int + Ppid int }{ - WASMFile: filepath.Base(ws.wasmFile), - Args: ws.args, - CoverageFile: ws.coverageFile, - EnvMap: ws.envMap, + WASMFile: filepath.Base(ws.wasmFile), + Args: ws.args, + EnvMap: ws.envMap, + SecurityToken: ws.securityToken, + Pid: os.Getpid(), + Ppid: os.Getppid(), } err := ws.indexTmpl.Execute(w, data) if err != nil { @@ -95,5 +111,17 @@ func (ws *wasmServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { if _, err := w.Write(ws.wasmExecJS); err != nil { ws.logger.Println("unable to write wasm_exec.") } + default: + if strings.HasPrefix(r.URL.Path, "/fs/") { + ws.fsHandler.ServeHTTP(w, r) + } + } +} + +func generateToken() (string, error) { + buf := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, buf); err != nil { + return "", err } + return base64.StdEncoding.EncodeToString(buf), nil } diff --git a/index.html b/index.html index f2d7834..5716adf 100644 --- a/index.html +++ b/index.html @@ -30,52 +30,118 @@ function goExit(code) { exitCode = code; } - function enosys() { - const err = new Error("not implemented"); - err.code = "ENOSYS"; - return err; + const securityToken = "{{.SecurityToken}}"; + const fsPath = "/fs"; + function fsHandler(name, body, onOk, onErr) { + const url = fsPath + "/" + name; + const options = {method: "POST", + body: JSON.stringify(body), headers:{"WBT-Token":securityToken}}; + fetch(url, options).then(res => res.json()).then(payload => { + if (payload.error) { + const err = new Error(payload.error); + err.code = payload.code; + onErr(err); + } else { + onOk(payload); + } + }).catch((fetchError) => { + console.log("fetch error", fetchError) + const err = new Error("bad server response"); + err.code = "ENOSYS"; + onErr(err); + }) + } + function bufferToBase64(buf) { + let binaryString = ""; + let bytes = new Uint8Array(buf); + const len = bytes.length; + for (let i = 0; i < len; i++) { + binaryString += String.fromCharCode(bytes[i]); + } + return btoa(binaryString); + } + function overrideProcess(process) { + // provide non-negative pid so counter file regex matches + // https://github.com/golang/go/blob/9a49b26bdf771ecdfa2d3bc3ee5175eed5321f20/src/internal/coverage/defs.go#L327 + process.pid = {{.Pid}}; + process.ppid = {{.Ppid}}; + process.cwd = () => { return fsPath }; + } + // Prepending /fs/ prevents jsProcess.Call("cwd") for windows drive letter paths. + // https://github.com/golang/go/blob/5a9b6432ec8b9199ce9fce9387e94195138b313f/src/syscall/fs_js.go#L104 + // This prefix is removed by filesys/handler.go fixPath() in each api call. + function fsp(path) { + return fsPath + "/" + path } - let coverageProfileContents = ""; function overrideFS(fs) { - // A typical runtime opens fd's in sequence above the standard descriptors (0-2). - // Choose an arbitrarily high fd for the custom coverage file to avoid conflict with the actual runtime fd's. - const coverFileDescriptor = Number.MAX_SAFE_INTEGER; - const coverFilePath = {{.CoverageFile}}; - // Wraps the default operations with bind() to ensure internal usage of 'this' continues to work. - const defaultOpen = fs.open.bind(fs); + // The fs.constants are read at https://github.com/golang/go/blob/561a5079057e3a660ab638e1ba957a96c4ff3fd1/src/syscall/fs_js.go#L25 + // These values are pulled from https://github.com/golang/go/blob/561a5079057e3a660ab638e1ba957a96c4ff3fd1/src/syscall/syscall_js.go#L126 + fs.constants = { O_WRONLY: 1, O_RDWR: 2, + O_CREAT: 0o0100, O_TRUNC: 0o01000, O_APPEND: 0o02000, O_EXCL: 0o0200 }; fs.open = (path, flags, mode, callback) => { - if (path === coverFilePath) { - callback(null, coverFileDescriptor); - return; - } - defaultOpen(path, flags, mode, callback); + fsHandler("open", {path:fsp(path),flags,mode}, (resp) => callback(null, resp.fd), callback); }; - const defaultClose = fs.close.bind(fs); fs.close = (fd, callback) => { - if (fd === coverFileDescriptor) { - callback(null); - return; - } - defaultClose(fd, callback); + fsHandler("close", {fd}, () => callback(null), callback); }; - if (!globalThis.TextDecoder) { - throw new Error("globalThis.TextDecoder is not available, polyfill required"); - } - const decoder = new TextDecoder("utf-8"); const defaultWrite = fs.write.bind(fs); fs.write = (fd, buf, offset, length, position, callback) => { - if (fd === coverFileDescriptor) { - coverageProfileContents += decoder.decode(buf); - callback(null, buf.length); + // stdin=0, stdout=1, stderr=2 + if (fd < 3) { + defaultWrite(fd, buf, offset, length, position, callback); return; } - defaultWrite(fd, buf, offset, length, position, callback); + const buffer = bufferToBase64(buf) + fsHandler("write", {fd, buffer, offset, length, position}, (resp) => { + callback(null, resp.written); + }, callback); }; + fs.stat = (path, callback) => { + fsHandler("stat", {path:fsp(path)}, (resp) => callback(null, resp), callback); + } + fs.fstat = (fd, callback) => { + fsHandler("fstat", {fd}, (resp) => { + // for https://github.com/golang/go/blob/c19c4c566c63818dfd059b352e52c4710eecf14d/src/syscall/fs_js.go#L93 + resp.isDirectory = () => { + return (resp.mode & (1 << 14)) > 0 + } + callback(null, resp); + }, callback); + } + fs.rename = (from, to, callback) => { + fsHandler("rename", {from:fsp(from),to:fsp(to)}, () => callback(null), callback); + } + fs.readdir = (path, callback) => { + fsHandler("readdir", {path:fsp(path)}, (resp) => callback(null, resp.entries), callback); + } + fs.lstat = (path, callback) => { + fsHandler("lstat", {path:fsp(path)}, (resp) => callback(null, resp), callback); + } + fs.read = (fd, buffer, offset, length, position, callback) => { + fsHandler("read", {fd,offset,length,position}, (resp) => { + const binaryString = atob(resp.buffer); + for (let i = 0; i < binaryString.length; i++) { + buffer[i] = binaryString.charCodeAt(i); + } + callback(null, resp.read); + }, callback); + } + fs.mkdir = (path, perm, callback) => { + fsHandler("mkdir", {path:fsp(path), perm}, () => callback(null), callback); + } + fs.unlink = (path, callback) => { + fsHandler("unlink", {path:fsp(path)}, () => callback(null), callback); + } + fs.rmdir = (path, callback) => { + fsHandler("rmdir", {path:fsp(path)}, () => callback(null), callback); + } + } (async() => { const go = new Go(); - overrideFS(globalThis.fs) + overrideFS(globalThis.fs); + overrideProcess(globalThis.process); go.argv = [{{range $i, $item := .Args}} {{if $i}}, {{end}} "{{$item}}" {{end}}]; // The notFirst variable sets itself to true after first iteration. This is to put commas in between. go.env = { {{ $notFirst := false }} diff --git a/main.go b/main.go index fb1992d..aa590f3 100644 --- a/main.go +++ b/main.go @@ -107,12 +107,10 @@ func run(ctx context.Context, args []string, errOutput io.Writer, flagSet *flag. }) var exitCode int - var coverageProfileContents string tasks := []chromedp.Action{ chromedp.Navigate(url), chromedp.WaitEnabled(`#doneButton`), chromedp.Evaluate(`exitCode;`, &exitCode), - chromedp.Evaluate(`coverageProfileContents;`, &coverageProfileContents), } if *cpuProfile != "" { // Prepend and append profiling tasks @@ -144,11 +142,6 @@ func run(ctx context.Context, args []string, errOutput io.Writer, flagSet *flag. return WriteProfile(profile, outF, funcMap) })) } - if *coverageProfile != "" { - tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error { - return os.WriteFile(*coverageProfile, []byte(coverageProfileContents), 0644) - })) - } err = chromedp.Run(ctx, tasks...) if err != nil {