├── .gitignore ├── examples ├── flask-basic │ ├── requirements.txt │ ├── app.py │ └── autodir.py └── go-basic │ └── main.go ├── Makefile ├── go.mod ├── .goreleaser.yml ├── LICENSE ├── go.sum ├── README.md ├── install.sh ├── PROTOCOL.md ├── main.go ├── httpfs.go └── fuse.go /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .DS_Store 3 | /NOTES 4 | /dist 5 | /local 6 | /httpfs 7 | 8 | -------------------------------------------------------------------------------- /examples/flask-basic/requirements.txt: -------------------------------------------------------------------------------- 1 | blinker==1.7.0 2 | click==8.1.7 3 | Flask==3.0.3 4 | itsdangerous==2.2.0 5 | Jinja2==3.1.3 6 | MarkupSafe==2.1.5 7 | Werkzeug==3.0.2 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: httpfs release 2 | 3 | VERSION=0.2dev 4 | 5 | httpfs: 6 | go build -ldflags="-X 'main.Version=${VERSION}'" . 7 | 8 | release: 9 | VERSION=$(VERSION) goreleaser release --snapshot --clean 10 | ./local/macsign/notarize -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/progrium/httpfs 2 | 3 | go 1.21.1 4 | 5 | require ( 6 | github.com/hanwen/go-fuse/v2 v2.5.1 7 | tractor.dev/toolkit-go v0.0.0-20240417045753-38146a213d9c 8 | ) 9 | 10 | require golang.org/x/sys v0.13.0 // indirect 11 | -------------------------------------------------------------------------------- /examples/flask-basic/app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os, random 4 | from flask import Flask 5 | from autodir import AutoDirMiddleware 6 | 7 | app = Flask(__name__) 8 | AutoDirMiddleware(app) 9 | 10 | @app.get("/greet/hello") 11 | def hello(): 12 | return "Hello, world!\n" 13 | 14 | @app.get("/greet/goodbye") 15 | def goodbye(): 16 | return "Goodbye, world!\n" 17 | 18 | @app.get("/random") 19 | def rnd(): 20 | return ''.join(random.choice('abcdefghijklmnopqrstuvwyz') for _ in range(24))+"\n" 21 | 22 | 23 | if __name__ == "__main__": 24 | port = int(os.getenv('PORT', 5000)) 25 | app.run(debug=True, port=port) 26 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | checksum: 5 | disable: true 6 | snapshot: 7 | name_template: '{{envOrDefault "VERSION" .ShortCommit}}' 8 | builds: 9 | - id: default 10 | goos: 11 | - linux 12 | goarch: 13 | - amd64 14 | - arm64 15 | ldflags: "-X main.Version={{.Version}}" 16 | main: . 17 | - id: mac 18 | goos: 19 | - darwin 20 | goarch: 21 | - amd64 22 | - arm64 23 | ldflags: "-X main.Version={{.Version}}" 24 | main: . 25 | hooks: 26 | post: "codesign --deep --force --verify --verbose --timestamp --options runtime --sign 'Developer ID Application: Jeff Lindsay (4HSU97X8UX)' {{ .Path }}" 27 | archives: 28 | - id: default 29 | builds: 30 | - default 31 | - mac 32 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{.Os}}_{{.Arch}}' 33 | format: zip 34 | wrap_in_directory: false 35 | files: 36 | - none* -------------------------------------------------------------------------------- /examples/go-basic/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | ) 11 | 12 | func main() { 13 | // this could be replaced by some middleware that reflects on routes, 14 | // but you can't enumerate patterns with the built-in ServeMux. most 15 | // other routers like github.com/gorilla/mux let you do this, but here 16 | // is a manual example anyway. 17 | http.Handle("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | w.Header().Add("Content-Type", "application/vnd.httpfs.v1+json") 19 | b, _ := json.Marshal(map[string]any{ 20 | "dir": []any{ 21 | "hello", 22 | }, 23 | }) 24 | w.Write(b) 25 | })) 26 | 27 | http.Handle("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | io.WriteString(w, "Hello world!\n") 29 | })) 30 | 31 | addr := fmt.Sprintf(":%s", os.Getenv("PORT")) 32 | fmt.Println("listening on", addr) 33 | if err := http.ListenAndServe(addr, nil); err != nil { 34 | log.Fatal(err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jeff Lindsay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ= 2 | github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= 3 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= 4 | github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= 5 | github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= 6 | github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= 7 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 8 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 9 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 10 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 11 | tractor.dev/toolkit-go v0.0.0-20240417045753-38146a213d9c h1:AuYnKdc6Konsd+ngLZWuK7+tmixeHTwOlNqXg/0pyJk= 12 | tractor.dev/toolkit-go v0.0.0-20240417045753-38146a213d9c/go.mod h1:vI9Jf9tepHLrUqGQf7XZuRcQySNajWRKBjPD4+Ay72I= 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # httpfs 2 | 3 | Create virtual filesystems using any HTTP framework. 4 | 5 | 6 | ``` 7 | $ tree ~/demo 8 | /Users/progrium/demo 9 | ├── greet 10 | │ ├── goodbye 11 | │ └── hello 12 | └── random 13 | 14 | 2 directories, 3 files 15 | ``` 16 | 17 | The file tree mounted at `~/demo` is powered by this Flask app: 18 | 19 | ```python 20 | import os, random 21 | from flask import Flask 22 | from autodir import AutoDirMiddleware 23 | 24 | app = Flask(__name__) 25 | AutoDirMiddleware(app) 26 | 27 | @app.get("/greet/hello") 28 | def hello(): 29 | return "Hello, world!\n" 30 | 31 | @app.get("/greet/goodbye") 32 | def goodbye(): 33 | return "Goodbye, world!\n" 34 | 35 | @app.get("/random") 36 | def rnd(): 37 | return ''.join(random.choice('abcdefghijklmnopqrstuvwyz') for _ in range(24))+"\n" 38 | ``` 39 | 40 | All it took was one command: 41 | ``` 42 | httpfs -mount ~/demo ./examples/flask-basic/app.py 43 | ``` 44 | 45 | Don't care for Flask? Use any web framework! 46 | 47 | ## Install 48 | 49 | Currently works on Linux and Mac (with [MacFUSE](https://osxfuse.github.io/)). Download from [latest release](https://github.com/progrium/httpfs/releases/latest) or you can run this installer: 50 | 51 | ```sh 52 | bash -c "$(curl -sSL https://raw.githubusercontent.com/progrium/httpfs/main/install.sh)" 53 | ``` 54 | 55 | Alternatively you can install using [Homebrew](https://brew.sh/): 56 | 57 | ```sh 58 | brew tap progrium/homebrew-taps 59 | brew install httpfs 60 | ``` 61 | 62 | ## Build an HTTP filesystem 63 | 64 | Check out the [examples directory](examples) or read the [PROTOCOL.md](PROTOCOL.md) to see how it works. 65 | 66 | ## Roadmap 67 | 68 | Full read-write and more coming soon. See our [roadmap](https://github.com/users/progrium/projects/2/views/1). 69 | 70 | ## License 71 | 72 | MIT 73 | -------------------------------------------------------------------------------- /examples/flask-basic/autodir.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify, Response 2 | 3 | # AutoDirMiddleware reflects on routes and makes handlers for parent 4 | # directories of route paths that serve an httpfs directory 5 | class AutoDirMiddleware: 6 | def __init__(self, app): 7 | self.app = app 8 | self.app.wsgi_app = self.middleware(self.app.wsgi_app) 9 | 10 | def middleware(self, next_app): 11 | def _middleware(environ, start_response): 12 | with self.app.request_context(environ): 13 | full_paths = {str(rule) for rule in self.app.url_map.iter_rules()} 14 | if request.path in full_paths: 15 | return next_app(environ, start_response) 16 | 17 | subpaths = self.get_subpaths(request.path, full_paths) 18 | if subpaths: 19 | response = jsonify({"dir": list(subpaths)}) 20 | response.headers['Content-Type'] = 'application/vnd.httpfs.v1+json' 21 | return response(environ, start_response) 22 | 23 | response = Response("Not found", status=404) 24 | return response(environ, start_response) 25 | 26 | return _middleware 27 | 28 | def get_subpaths(self, path, all_paths): 29 | if path != '/': 30 | path = '/' + path.strip('/') + '/' 31 | subpaths = [] 32 | for p in all_paths: 33 | if p.startswith("/static"): 34 | continue 35 | if p.startswith(path): 36 | rest = p[len(path):].split("/") 37 | if rest[0].startswith("<"): 38 | continue 39 | if len(rest) == 1: 40 | subpaths.append(rest[0]) 41 | else: 42 | subpaths.append(rest[0]+'/') 43 | return set(subpaths) 44 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script installs the latest version into /usr/local/bin or TARGET if specified 4 | # 5 | set -eo pipefail 6 | 7 | required_tools=("curl" "grep" "awk" "unzip") 8 | for tool in "${required_tools[@]}"; do 9 | if ! command -v $tool &>/dev/null; then 10 | echo "Error: $tool is required but not installed. Please install it first." 11 | exit 1 12 | fi 13 | done 14 | 15 | main() { 16 | local username="progrium" 17 | local repo="httpfs" 18 | local executable="httpfs" 19 | local binpath="${TARGET:-/usr/local/bin}" 20 | 21 | # Check if write permission is available 22 | if [[ ! -w "$binpath" ]]; then 23 | echo "Error: No write permission for $binpath. Try running with sudo or choose a different TARGET." 24 | exit 1 25 | fi 26 | 27 | local repoURL="https://github.com/${username}/${repo}" 28 | local releaseURL="$(curl -sI ${repoURL}/releases/latest | grep 'location:' | awk '{print $2}')" 29 | local version="$(basename $releaseURL | cut -c 2- | tr -d '\r')" 30 | 31 | local os="" 32 | local arch="" 33 | 34 | # Detect operating system 35 | if [[ "$OSTYPE" == "linux-gnu"* ]]; then 36 | os="linux" 37 | elif [[ "$OSTYPE" == "darwin"* ]]; then 38 | os="darwin" 39 | elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then 40 | # WSL uses msys or cygwin as OSTYPE 41 | os="windows" 42 | else 43 | echo "Error: Unsupported operating system: $OSTYPE" 44 | exit 1 45 | fi 46 | 47 | # Detect architecture 48 | if [[ "$(uname -m)" == "x86_64" ]]; then 49 | arch="amd64" 50 | elif [[ "$(uname -m)" == "arm64" ]]; then 51 | arch="arm64" 52 | elif [[ "$(uname -m)" == "aarch64" ]]; then 53 | arch="arm64" 54 | else 55 | echo "Error: Unsupported architecture: $(uname -m)" 56 | exit 1 57 | fi 58 | 59 | local filename="${repo}_${version}_${os}_${arch}.zip" 60 | curl -sSLO "${repoURL}/releases/download/v${version}/${filename}" 61 | unzip $filename $executable -d $binpath 62 | rm "./$filename" 63 | 64 | echo "Executable ${executable} ${version} installed to ${binpath}" 65 | } 66 | 67 | main "$@" -------------------------------------------------------------------------------- /PROTOCOL.md: -------------------------------------------------------------------------------- 1 | # httpfs protocol 2 | 3 | This document describes how httpfs uses HTTP to produce filesystem data. 4 | 5 | ## Connection 6 | 7 | httpfs starts your HTTP server from the command argument you pass it and 8 | sets `PORT` with an unused port in its environment for you to listen on. 9 | 10 | ## Methods 11 | 12 | In this read-only iteration, httpfs only uses `GET` and `HEAD` methods. 13 | 14 | `HEAD` is used for getting file metadata and is correlated with `stat` 15 | operations. Since systems often perform `stat` quite a bit in a row, httpfs 16 | caches HEAD requests for 1 second regardless of cache headers. 17 | 18 | `GET` is used for getting file data and is correlated with `open` operations. 19 | Streaming data is not supported. 20 | 21 | ## Status Codes 22 | 23 | httpfs only understands `200` and `404` response status codes and anything else 24 | will result in the `EINVAL` (invalid argument) error code being used. 25 | 26 | ## Response Headers 27 | 28 | httpfs uses the following response headers: 29 | 30 | #### Content-Length 31 | 32 | Used for size metadata. If not present, a size of `0` will be used. 33 | 34 | #### Last-Modified 35 | 36 | Used for modified time (mtime) metadata. If not present, httpfs will use its 37 | start time. 38 | 39 | #### Content-Permissions 40 | 41 | This non-standard header is used for permission metadata. It is expected to be 42 | in octal format prefixed with a `0` (example: `0755`). If not present, httpfs 43 | will use `0644` for files and `0755` for directories. 44 | 45 | #### Content-Disposition 46 | 47 | Used to specify a filename different from the path using the `filename` 48 | attribute of the `attachment` disposition (example: 49 | `attachment; filename="filename.jpg"`). If not present, the filename will be 50 | the basename of the URL path. 51 | 52 | #### Content-Type 53 | 54 | This is ignored for files, but for directories it is expected to be 55 | `application/vnd.httpfs.v1+json`. 56 | 57 | ## Directories 58 | 59 | Directory URLs need to serve JSON data with Content-Type 60 | `application/vnd.httpfs.v1+json`. The response body JSON must be an object with 61 | a `dirs` property containing an array of strings for directory contents. The 62 | strings are expected to be the basename of files and the basename of directories 63 | with a `/` suffix to identify them as directories. Here is an example response 64 | body: 65 | 66 | ```json 67 | { 68 | "dirs": [ 69 | "dirA/", 70 | "dirB/", 71 | "file1.txt", 72 | "file2.txt" 73 | ] 74 | } 75 | ``` 76 | 77 | Since most HTTP frameworks let you specify handlers to full paths/routes, you 78 | are responsible for making sure the parent directory paths are served as 79 | directories, **including the root.** This can be accomplished automatically with 80 | middleware in most cases. However, if you have routes with dynamic/variable path 81 | elements, these cannot be automatically served and you will have to make a 82 | directory handler to enumerate all directory entries. 83 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | "os/exec" 10 | "os/signal" 11 | "strconv" 12 | "syscall" 13 | "time" 14 | ) 15 | 16 | var ( 17 | Version string 18 | 19 | showVersion = flag.Bool("v", false, "show version") 20 | mountpath = flag.String("mount", "", "path to mount filesystem") 21 | mount Mounter 22 | cmd *exec.Cmd 23 | ) 24 | 25 | type Mounter interface { 26 | Prepare() error 27 | Mount() error 28 | Unmount() error 29 | } 30 | 31 | func main() { 32 | flag.Parse() 33 | if *showVersion { 34 | fmt.Println(Version) 35 | return 36 | } 37 | 38 | if len(flag.Args()) < 1 || mountpath == nil { 39 | println("Usage: httpfs -mount=MOUNTPATH EXEC-PATH [EXEC-ARGS...]") 40 | os.Exit(1) 41 | } 42 | 43 | port, err := findPort() 44 | if err != nil { 45 | log.Fatal("unable to find unused port: ", err) 46 | } 47 | os.Setenv("PORT", strconv.Itoa(port)) 48 | 49 | cmd = exec.Command(flag.Arg(0), flag.Args()[1:]...) 50 | cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} 51 | cmd.Stdout = os.Stdout 52 | cmd.Stderr = os.Stderr 53 | 54 | if err := cmd.Start(); err != nil { 55 | log.Fatal("unable to start subprocess: ", err) 56 | } 57 | 58 | if mountpath != nil { 59 | fs := &httpFS{ 60 | baseURL: fmt.Sprintf("http://localhost:%d", port), 61 | } 62 | 63 | // poll until server is up or 5 sec timeout 64 | _, err := fs.Stat(".") 65 | for err != nil { 66 | if time.Since(startTime).Seconds() >= 5 { 67 | break 68 | } 69 | <-time.After(500 * time.Millisecond) 70 | _, err = fs.Stat(".") 71 | } 72 | if err != nil { 73 | tryShutdown() 74 | log.Fatal("unable to stat httpfs: ", err) 75 | } 76 | 77 | mount = &fuseMount{ 78 | fs: fs, 79 | path: *mountpath, 80 | } 81 | if err := mount.Prepare(); err != nil { 82 | tryShutdown() 83 | log.Fatal("mount prep failed: ", err) 84 | } 85 | if err := mount.Mount(); err != nil { 86 | tryUnmount() 87 | tryShutdown() 88 | log.Fatal("unable to mount: ", err) 89 | } 90 | } 91 | 92 | sigChan := make(chan os.Signal, 1) 93 | signal.Notify(sigChan) 94 | go func() { 95 | for sig := range sigChan { 96 | if sig == os.Interrupt { 97 | tryUnmount() 98 | tryShutdown() 99 | return 100 | } 101 | if cmd.Process != nil { 102 | cmd.Process.Signal(sig) 103 | } 104 | } 105 | }() 106 | 107 | if err := cmd.Wait(); err != nil { 108 | tryUnmount() 109 | if exitErr, ok := err.(*exec.ExitError); ok { 110 | os.Exit(exitErr.ExitCode()) 111 | } 112 | log.Fatal("error running subprocess:", err) 113 | } 114 | 115 | tryUnmount() 116 | } 117 | 118 | func tryUnmount() { 119 | if mount == nil { 120 | return 121 | } 122 | if err := mount.Unmount(); err != nil { 123 | log.Println("unable to unmount:", err) 124 | } 125 | } 126 | 127 | func tryShutdown() { 128 | if cmd == nil { 129 | return 130 | } 131 | pgid, err := syscall.Getpgid(cmd.Process.Pid) 132 | if err != nil { 133 | panic(err) 134 | } 135 | syscall.Kill(-pgid, syscall.SIGTERM) 136 | } 137 | 138 | func findPort() (int, error) { 139 | l, err := net.Listen("tcp", "localhost:0") 140 | if err != nil { 141 | return 0, err 142 | } 143 | defer l.Close() 144 | return l.Addr().(*net.TCPAddr).Port, nil 145 | } 146 | -------------------------------------------------------------------------------- /httpfs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "io/fs" 7 | "mime" 8 | "net/http" 9 | "path/filepath" 10 | "strconv" 11 | "strings" 12 | "time" 13 | ) 14 | 15 | var startTime time.Time 16 | 17 | func init() { 18 | startTime = time.Now() 19 | } 20 | 21 | type httpFS_v1 struct { 22 | Dir []string `json:"dir,omitempty"` 23 | } 24 | 25 | type statCache struct { 26 | info 27 | lastTime time.Time 28 | } 29 | 30 | type httpFS struct { 31 | baseURL string 32 | statCache map[string]statCache 33 | } 34 | 35 | func (fsys *httpFS) url(name string) string { 36 | if name == "." { 37 | name = "" 38 | } 39 | return strings.Join([]string{strings.TrimRight(fsys.baseURL, "/"), strings.TrimLeft(name, "/")}, "/") 40 | } 41 | 42 | func (fsys *httpFS) OpenFile(name string, flag int, perm fs.FileMode) (fs.File, error) { 43 | return fsys.Open(name) 44 | } 45 | 46 | func (fsys *httpFS) Open(name string) (fs.File, error) { 47 | resp, err := http.DefaultClient.Get(fsys.url(name)) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if resp.StatusCode == 404 { 52 | return nil, fs.ErrNotExist 53 | } 54 | return &file{ 55 | ReadCloser: resp.Body, 56 | Name: name, 57 | FS: fsys, 58 | }, nil 59 | } 60 | 61 | func (fsys *httpFS) stat(name string) (*info, error) { 62 | if fsys.statCache == nil { 63 | fsys.statCache = make(map[string]statCache) 64 | } 65 | fi, found := fsys.statCache[name] 66 | 67 | if found && time.Since(fi.lastTime).Milliseconds() < 1000 { 68 | return &fi.info, nil 69 | } 70 | 71 | resp, err := http.DefaultClient.Head(fsys.url(name)) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | if resp.StatusCode == 404 { 77 | return nil, fs.ErrNotExist 78 | } 79 | 80 | // defaults 81 | i := &info{ 82 | name: filepath.Base(name), 83 | size: 0, 84 | mode: 0644, 85 | modTime: startTime.Unix(), 86 | isDir: false, 87 | } 88 | 89 | disp := resp.Header.Get("Content-Disposition") 90 | if disp != "" { 91 | _, params, _ := mime.ParseMediaType(disp) 92 | if params != nil { 93 | i.name = params["filename"] 94 | } 95 | } 96 | 97 | length := resp.Header.Get("Content-Length") 98 | if length != "" { 99 | l, err := strconv.Atoi(length) 100 | if err == nil { 101 | i.size = int64(l) 102 | } 103 | } 104 | 105 | modTime := resp.Header.Get("Last-Modified") 106 | if modTime != "" { 107 | t, err := time.Parse(time.RFC1123, modTime) 108 | if err == nil { 109 | i.modTime = int64(t.Unix()) 110 | } 111 | } 112 | 113 | typ := resp.Header.Get("Content-Type") 114 | if typ == "application/vnd.httpfs.v1+json" { 115 | i.isDir = true 116 | i.mode = 0755 117 | } 118 | 119 | mode := resp.Header.Get("Content-Permissions") // custom! 120 | if mode != "" { 121 | m, err := strconv.ParseUint(mode, 8, 32) 122 | if err == nil { 123 | i.mode = uint(m) 124 | } 125 | } 126 | 127 | fsys.statCache[name] = statCache{ 128 | info: *i, 129 | lastTime: time.Now(), 130 | } 131 | return i, nil 132 | } 133 | 134 | func (fsys *httpFS) Stat(name string) (fs.FileInfo, error) { 135 | return fsys.stat(name) 136 | } 137 | 138 | func (fsys *httpFS) ReadDir(name string) ([]fs.DirEntry, error) { 139 | resp, err := http.DefaultClient.Get(fsys.url(name)) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | if resp.StatusCode == 404 { 145 | return nil, fs.ErrNotExist 146 | } 147 | 148 | // todo: check for application/vnd.httpfs.v1+json 149 | 150 | b, err := io.ReadAll(resp.Body) 151 | if err != nil { 152 | return nil, err 153 | } 154 | resp.Body.Close() 155 | 156 | var dirInfo httpFS_v1 157 | if err := json.Unmarshal(b, &dirInfo); err != nil { 158 | return nil, err 159 | } 160 | 161 | var out []fs.DirEntry 162 | for _, sub := range dirInfo.Dir { 163 | info, err := fsys.stat(filepath.Join(name, sub)) 164 | if err != nil { 165 | return nil, err 166 | } 167 | out = append(out, info) 168 | } 169 | return out, nil 170 | } 171 | 172 | type file struct { 173 | io.ReadCloser 174 | Name string 175 | FS *httpFS 176 | } 177 | 178 | func (f *file) Stat() (fs.FileInfo, error) { 179 | return f.FS.Stat(f.Name) 180 | } 181 | 182 | func (f *file) ReadDir(n int) ([]fs.DirEntry, error) { 183 | return f.FS.ReadDir(f.Name) 184 | } 185 | 186 | type info struct { 187 | name string 188 | size int64 189 | mode uint 190 | modTime int64 191 | isDir bool 192 | } 193 | 194 | func (i *info) Name() string { return i.name } 195 | func (i *info) Size() int64 { return i.size } 196 | func (i *info) ModTime() time.Time { return time.Unix(i.modTime, 0) } 197 | func (i *info) IsDir() bool { return i.isDir } 198 | func (i *info) Sys() any { return nil } 199 | func (i *info) Mode() fs.FileMode { 200 | if i.IsDir() { 201 | return fs.FileMode(i.mode) | fs.ModeDir 202 | } 203 | return fs.FileMode(i.mode) 204 | } 205 | 206 | // these allow it to act as DirInfo as well 207 | func (i *info) Info() (fs.FileInfo, error) { 208 | return i, nil 209 | } 210 | func (i *info) Type() fs.FileMode { 211 | return i.Mode() 212 | } 213 | -------------------------------------------------------------------------------- /fuse.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "hash/fnv" 7 | "io" 8 | iofs "io/fs" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "path/filepath" 13 | "syscall" 14 | 15 | "github.com/hanwen/go-fuse/v2/fs" 16 | "github.com/hanwen/go-fuse/v2/fuse" 17 | ) 18 | 19 | type fuseMount struct { 20 | fs iofs.FS 21 | path string 22 | 23 | *fuse.Server 24 | } 25 | 26 | func (m *fuseMount) Prepare() error { 27 | exec.Command("umount", m.path).Run() 28 | 29 | if err := os.MkdirAll(m.path, 0755); err != nil { 30 | return errors.New("unable to mkdir") 31 | } 32 | 33 | return nil 34 | } 35 | 36 | func (m *fuseMount) Mount() (err error) { 37 | opts := &fs.Options{ 38 | UID: uint32(os.Getuid()), 39 | GID: uint32(os.Getgid()), 40 | } 41 | opts.Debug = false 42 | 43 | m.Server, err = fs.Mount(m.path, &node{fs: m.fs}, opts) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | return nil 49 | } 50 | 51 | func (m *fuseMount) Unmount() error { 52 | if m.Server == nil { 53 | exec.Command("umount", m.path).Run() 54 | return nil 55 | } 56 | return m.Server.Unmount() 57 | } 58 | 59 | func fakeIno(s string) uint64 { 60 | h := fnv.New64a() // FNV-1a 64-bit hash 61 | h.Write([]byte(s)) 62 | return h.Sum64() 63 | } 64 | 65 | func applyFileInfo(out *fuse.Attr, fi iofs.FileInfo) { 66 | stat := fi.Sys() 67 | if s, ok := stat.(*syscall.Stat_t); ok { 68 | out.FromStat(s) 69 | return 70 | } 71 | out.Mtime = uint64(fi.ModTime().Unix()) 72 | out.Mtimensec = uint32(fi.ModTime().UnixNano()) 73 | out.Mode = uint32(fi.Mode()) 74 | out.Size = uint64(fi.Size()) 75 | } 76 | 77 | type node struct { 78 | fs.Inode 79 | fs iofs.FS 80 | path string 81 | } 82 | 83 | var _ = (fs.NodeGetattrer)((*node)(nil)) 84 | 85 | func (n *node) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno { 86 | // log.Println("getattr", n.path) 87 | 88 | fi, err := iofs.Stat(n.fs, ".") 89 | if err != nil { 90 | return sysErrno(err) 91 | } 92 | applyFileInfo(&out.Attr, fi) 93 | 94 | return 0 95 | } 96 | 97 | var _ = (fs.NodeReaddirer)((*node)(nil)) 98 | 99 | func (n *node) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) { 100 | // log.Println("readdir", n.path) 101 | 102 | entries, err := iofs.ReadDir(n.fs, ".") 103 | if err != nil { 104 | return nil, sysErrno(err) 105 | } 106 | 107 | var fentries []fuse.DirEntry 108 | for _, entry := range entries { 109 | fentries = append(fentries, fuse.DirEntry{ 110 | Name: entry.Name(), 111 | Mode: uint32(entry.Type()), 112 | Ino: fakeIno(filepath.Join(n.path, entry.Name())), 113 | }) 114 | } 115 | 116 | return fs.NewListDirStream(fentries), 0 117 | } 118 | 119 | var _ = (fs.NodeOpendirer)((*node)(nil)) 120 | 121 | func (r *node) Opendir(ctx context.Context) syscall.Errno { 122 | // log.Println("opendir", r.path) 123 | return 0 124 | } 125 | 126 | var _ = (fs.NodeLookuper)((*node)(nil)) 127 | 128 | func (n *node) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) { 129 | // log.Println("lookup", n.path, name) 130 | 131 | fi, err := iofs.Stat(n.fs, name) 132 | if err != nil { 133 | return nil, sysErrno(err) 134 | } 135 | 136 | applyFileInfo(&out.Attr, fi) 137 | 138 | subfs, err := iofs.Sub(n.fs, name) 139 | if err != nil { 140 | return nil, sysErrno(err) 141 | } 142 | 143 | mode := fuse.S_IFREG 144 | if fi.IsDir() { 145 | mode = fuse.S_IFDIR 146 | } 147 | 148 | return n.Inode.NewPersistentInode(ctx, &node{ 149 | fs: subfs, 150 | path: filepath.Join(n.path, name), 151 | }, fs.StableAttr{ 152 | Mode: uint32(mode), 153 | Ino: fakeIno(filepath.Join(n.path, name)), 154 | }), 0 155 | } 156 | 157 | var _ = (fs.NodeOpener)((*node)(nil)) 158 | 159 | func (n *node) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) { 160 | // log.Println("open", n.path) 161 | 162 | f, err := n.fs.Open(".") // should be OpenFile 163 | if err != nil { 164 | return nil, 0, sysErrno(err) 165 | } 166 | 167 | // buffer entire contents to support ReaderAt style reading, 168 | // required for Direct I/O mode, which is needed to avoid caching 169 | defer f.Close() 170 | data, err := io.ReadAll(f) 171 | if err != nil { 172 | return nil, 0, sysErrno(err) 173 | } 174 | 175 | return &handle{data: data, path: n.path}, fuse.FOPEN_DIRECT_IO, 0 176 | } 177 | 178 | type handle struct { 179 | data []byte 180 | path string 181 | } 182 | 183 | var _ = (fs.FileReader)((*handle)(nil)) 184 | 185 | func (h *handle) Read(ctx context.Context, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) { 186 | // log.Println("read", h.path) 187 | 188 | end := off + int64(len(dest)) 189 | if end > int64(len(h.data)) { 190 | end = int64(len(h.data)) 191 | } 192 | 193 | return fuse.ReadResultData(h.data[off:end]), 0 194 | } 195 | 196 | func sysErrno(err error) syscall.Errno { 197 | log.Println("ERR:", err) 198 | switch err { 199 | case nil: 200 | return syscall.Errno(0) 201 | case os.ErrPermission: 202 | return syscall.EPERM 203 | case os.ErrExist: 204 | return syscall.EEXIST 205 | case os.ErrNotExist: 206 | return syscall.ENOENT 207 | case os.ErrInvalid: 208 | return syscall.EINVAL 209 | } 210 | 211 | switch t := err.(type) { 212 | case syscall.Errno: 213 | return t 214 | case *os.SyscallError: 215 | return t.Err.(syscall.Errno) 216 | case *os.PathError: 217 | return sysErrno(t.Err) 218 | case *os.LinkError: 219 | return sysErrno(t.Err) 220 | } 221 | log.Println("!! unsupported error type:", err) 222 | return syscall.EINVAL 223 | } 224 | --------------------------------------------------------------------------------