diff --git a/.golangci.yml b/.golangci.yml index d7760dc..8d56cb2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -19,7 +19,7 @@ linters: - asciicheck - bidichk - bodyclose - # - containedctx + - containedctx - durationcheck - errcheck - nakedret diff --git a/cmd/tstor/main.go b/cmd/tstor/main.go index f36f761..b430181 100644 --- a/cmd/tstor/main.go +++ b/cmd/tstor/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log/slog" "net" nethttp "net/http" @@ -14,6 +13,8 @@ import ( "syscall" "time" + wnfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "git.kmsign.ru/royalcat/tstor/pkg/rlog" "git.kmsign.ru/royalcat/tstor/src/config" "git.kmsign.ru/royalcat/tstor/src/delivery" "git.kmsign.ru/royalcat/tstor/src/host" @@ -23,7 +24,6 @@ import ( "git.kmsign.ru/royalcat/tstor/src/host/vfs" "git.kmsign.ru/royalcat/tstor/src/telemetry" "github.com/urfave/cli/v2" - wnfs "github.com/willscott/go-nfs" _ "git.kmsign.ru/royalcat/tstor/pkg/rlog" "git.kmsign.ru/royalcat/tstor/src/export/fuse" @@ -79,7 +79,7 @@ func run(configPath string) error { defer client.Shutdown(ctx) } - log := slog.Default().With("component", "run") + log := rlog.ComponentLog("run") // TODO make optional err = syscall.Setpriority(syscall.PRIO_PGRP, 0, 19) @@ -137,7 +137,7 @@ func run(configPath string) error { return fmt.Errorf("error creating data folder: %w", err) } sfs := host.NewTorrentStorage(conf.SourceDir, ts) - sfs = vfs.WrapLogFS(sfs, slog.Default()) + sfs = vfs.WrapLogFS(sfs) // TODO make separate function // { diff --git a/go.mod b/go.mod index e39f509..6cbec8e 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/bodgit/sevenzip v1.4.5 github.com/dgraph-io/badger/v4 v4.2.0 github.com/dgraph-io/ristretto v0.1.1 + github.com/dustin/go-humanize v1.0.0 github.com/gin-contrib/pprof v1.4.0 github.com/gin-gonic/gin v1.9.1 github.com/go-git/go-billy/v5 v5.5.0 @@ -27,8 +28,9 @@ require ( github.com/knadh/koanf/providers/structs v0.1.0 github.com/knadh/koanf/v2 v2.0.1 github.com/nwaples/rardecode/v2 v2.0.0-beta.2 + github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 github.com/ravilushqa/otelgqlgen v0.15.0 - github.com/royalcat/kv v0.0.0-20240318203654-181645f85b10 + github.com/royalcat/kv v0.0.0-20240327213417-8cf5696b2389 github.com/rs/zerolog v1.32.0 github.com/samber/slog-multi v1.0.2 github.com/samber/slog-zerolog v1.0.0 @@ -36,7 +38,8 @@ require ( github.com/stretchr/testify v1.8.4 github.com/urfave/cli/v2 v2.27.0 github.com/vektah/gqlparser/v2 v2.5.11 - github.com/willscott/go-nfs v0.0.2 + github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 + github.com/willscott/memphis v0.0.0-20210922141505-529d4987ab7e go.opentelemetry.io/otel v1.24.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 go.opentelemetry.io/otel/exporters/prometheus v0.46.0 @@ -46,6 +49,7 @@ require ( go.uber.org/multierr v1.11.0 golang.org/x/exp v0.0.0-20231226003508-02704c960a9b golang.org/x/net v0.19.0 + golang.org/x/sys v0.17.0 ) require ( @@ -79,8 +83,8 @@ require ( github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/cyphar/filepath-securejoin v0.2.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/dustin/go-humanize v1.0.0 // indirect github.com/edsrzf/mmap-go v1.1.0 // indirect github.com/fatih/structs v1.1.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect @@ -141,11 +145,13 @@ require ( github.com/pion/webrtc/v3 v3.1.42 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/polydawn/go-timeless-api v0.0.0-20220821201550-b93919e12c56 // indirect + github.com/polydawn/refmt v0.0.0-20201211092308-30ac6d18308e // indirect + github.com/polydawn/rio v0.0.0-20220823181337-7c31ad9831a4 // indirect github.com/prometheus/client_golang v1.18.0 // indirect github.com/prometheus/client_model v0.6.0 // indirect github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - github.com/rasky/go-xdr v0.0.0-20170124162913-1a41d1a06c93 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -156,7 +162,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect github.com/ulikunitz/xz v0.5.11 // indirect - github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 // indirect + github.com/warpfork/go-errcat v0.0.0-20180917083543-335044ffc86e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.etcd.io/bbolt v1.3.6 // indirect go.opencensus.io v0.24.0 // indirect @@ -169,7 +175,6 @@ require ( golang.org/x/crypto v0.17.0 // indirect golang.org/x/mod v0.14.0 // indirect golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.17.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.16.0 // indirect diff --git a/go.sum b/go.sum index f56ac68..2cf862e 100644 --- a/go.sum +++ b/go.sum @@ -159,6 +159,8 @@ github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSV github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= +github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -207,6 +209,7 @@ github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= +github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -310,6 +313,7 @@ github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5m github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -342,6 +346,7 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -405,6 +410,7 @@ github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nwaples/rardecode/v2 v2.0.0-beta.2 h1:e3mzJFJs4k83GXBEiTaQ5HgSc/kOK8q0rDaRO0MPaOk= github.com/nwaples/rardecode/v2 v2.0.0-beta.2/go.mod h1:yntwv/HfMc/Hbvtq9I19D1n58te3h6KsqCf3GxyfBGY= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -418,6 +424,8 @@ github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1Cpa github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= @@ -476,6 +484,15 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/polydawn/go-timeless-api v0.0.0-20201121022836-7399661094a6/go.mod h1:z2fMUifgtqrZiNLgzF4ZR8pX+YFLCmAp1jJTSTvyDMM= +github.com/polydawn/go-timeless-api v0.0.0-20220821201550-b93919e12c56 h1:LQ103HjiN76aqIxnQNgdZ+7NveuKd45+Q+TYGJVVsyw= +github.com/polydawn/go-timeless-api v0.0.0-20220821201550-b93919e12c56/go.mod h1:OAK6p/pJUakz6jQ+HlSw16gVMnuohxqJFGoypUYyr4w= +github.com/polydawn/refmt v0.0.0-20190807091052-3d65705ee9f1/go.mod h1:uIp+gprXxxrWSjjklXD+mN4wed/tMfjMMmN/9+JsA9o= +github.com/polydawn/refmt v0.0.0-20201211092308-30ac6d18308e h1:ZOcivgkkFRnjfoTcGsDq3UQYiBmekwLA+qg0OjyB/ls= +github.com/polydawn/refmt v0.0.0-20201211092308-30ac6d18308e/go.mod h1:uIp+gprXxxrWSjjklXD+mN4wed/tMfjMMmN/9+JsA9o= +github.com/polydawn/rio v0.0.0-20201122020833-6192319df581/go.mod h1:mwZtAu36D3fSNzVLN1we6PFdRU4VeE+RXLTZiOiQlJ0= +github.com/polydawn/rio v0.0.0-20220823181337-7c31ad9831a4 h1:SNhgcsCNGEqz7Tp46YHEvcjF1s5x+ZGWcVzFoghkuMA= +github.com/polydawn/rio v0.0.0-20220823181337-7c31ad9831a4/go.mod h1:fZ8OGW5CVjZHyQeNs8QH3X3tUxrPcx1jxHSl2z6Xv00= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= @@ -514,8 +531,8 @@ github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTE github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= -github.com/royalcat/kv v0.0.0-20240318203654-181645f85b10 h1:8vwpCzvVqzNzkYRH9kA3GV5fkWs+8s0jdxtGvswL/MU= -github.com/royalcat/kv v0.0.0-20240318203654-181645f85b10/go.mod h1:Ff0Z/r1H3ojacpEe8SashMKJx6YCIhWrYtpdV8Y/k3A= +github.com/royalcat/kv v0.0.0-20240327213417-8cf5696b2389 h1:7XbHzr1TOaxs5Y/i9GtTEOOSTzfQ4ESYqF38DVfPkFY= +github.com/royalcat/kv v0.0.0-20240327213417-8cf5696b2389/go.mod h1:Ff0Z/r1H3ojacpEe8SashMKJx6YCIhWrYtpdV8Y/k3A= github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417 h1:Lt9DzQALzHoDwMBGJ6v8ObDPR0dzr2a6sXTB1Fq7IHs= github.com/rs/dnscache v0.0.0-20211102005908-e0241e321417/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -541,8 +558,13 @@ github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPx github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs= +github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg= +github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM= github.com/sosodev/duration v1.2.0 h1:pqK/FLSjsAADWY74SyWDCjOcd5l7H8GSnnOGEB9A1Us= github.com/sosodev/duration v1.2.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= @@ -582,12 +604,16 @@ github.com/urfave/cli/v2 v2.27.0 h1:uNs1K8JwTFL84X68j5Fjny6hfANh9nTlJ6dRtZAFAHY= github.com/urfave/cli/v2 v2.27.0/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= github.com/vektah/gqlparser/v2 v2.5.11 h1:JJxLtXIoN7+3x6MBdtIP59TP1RANnY7pXOaDnADQSf8= github.com/vektah/gqlparser/v2 v2.5.11/go.mod h1:1rCcfwB2ekJofmluGWXMSEnPMZgbxzwj6FaZ/4OT8Cc= +github.com/warpfork/go-errcat v0.0.0-20180917083543-335044ffc86e h1:FIB2fi7XJGHIdf5rWNsfFQqatIKxutT45G+wNuMQNgs= +github.com/warpfork/go-errcat v0.0.0-20180917083543-335044ffc86e/go.mod h1:/qe02xr3jvTUz8u/PV0FHGpP8t96OQNP7U9BJMwMLEw= +github.com/warpfork/go-wish v0.0.0-20200122115046-b9ea61034e4a h1:G++j5e0OC488te356JvdhaM8YS6nMsjLAYF7JxCv07w= +github.com/warpfork/go-wish v0.0.0-20200122115046-b9ea61034e4a/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= -github.com/willscott/go-nfs v0.0.2 h1:BaBp1CpGDMooCT6bCgX6h6ZwgPcTMST4yToYZ9byee0= -github.com/willscott/go-nfs v0.0.2/go.mod h1:SvullWeHxr/924WQNbUaZqtluBt2vuZ61g6yAV+xj7w= github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00 h1:U0DnHRZFzoIV1oFEZczg5XyPut9yxk9jjtax/9Bxr/o= github.com/willscott/go-nfs-client v0.0.0-20240104095149-b44639837b00/go.mod h1:Tq++Lr/FgiS3X48q5FETemXiSLGuYMQT2sPjYNPJSwA= +github.com/willscott/memphis v0.0.0-20210922141505-529d4987ab7e h1:1eHCP4w7tMmpfFBdrd5ff+vYU9THtrtA1yM9f0TLlJw= +github.com/willscott/memphis v0.0.0-20210922141505-529d4987ab7e/go.mod h1:59vHBW4EpjiL5oiqgCrBp1Tc9JXRzKCNMEOaGmNfSHo= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -753,6 +779,7 @@ golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -807,6 +834,7 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -899,6 +927,7 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= diff --git a/graphql/mutation.graphql b/graphql/mutation.graphql index 80830ed..0f45036 100644 --- a/graphql/mutation.graphql +++ b/graphql/mutation.graphql @@ -1,20 +1,25 @@ type Mutation { - validateTorrents(filter: TorrentFilter!): Boolean! - cleanupTorrents(files: Boolean, dryRun: Boolean!): Int! - downloadTorrent(infohash: String!, file: String): DownloadTorrentResponse + validateTorrents(filter: TorrentFilter!): Boolean! + cleanupTorrents(files: Boolean, dryRun: Boolean!): CleanupResponse! + downloadTorrent(infohash: String!, file: String): DownloadTorrentResponse + dedupeStorage: Int! } - input TorrentFilter @oneOf { - everything: Boolean - infohash: String - # pathGlob: String! + everything: Boolean + infohash: String + # pathGlob: String! } type DownloadTorrentResponse { - task: Task + task: Task +} + +type CleanupResponse { + count: Int! + list: [String!]! } type Task { - id: ID! -} \ No newline at end of file + id: ID! +} diff --git a/graphql/query.graphql b/graphql/query.graphql index aab94cb..8161a9f 100644 --- a/graphql/query.graphql +++ b/graphql/query.graphql @@ -1,6 +1,6 @@ type Query { torrents(filter: TorrentsFilter, pagination: Pagination): [Torrent!]! - fsListDir(path: String!): [DirEntry!]! + fsListDir(path: String!): ListDirResponse! } input TorrentsFilter { @@ -11,6 +11,11 @@ input TorrentsFilter { peersCount: IntFilter } +type ListDirResponse { + root: DirEntry! + entries: [DirEntry!]! +} + input Pagination { offset: Int! limit: Int! diff --git a/pkg/ctxbilly/change.go b/pkg/ctxbilly/change.go new file mode 100644 index 0000000..e9a5105 --- /dev/null +++ b/pkg/ctxbilly/change.go @@ -0,0 +1,27 @@ +package ctxbilly + +import ( + "context" + "os" + "time" +) + +// Change abstract the FileInfo change related operations in a storage-agnostic +// interface as an extension to the Basic interface +type Change interface { + // Chmod changes the mode of the named file to mode. If the file is a + // symbolic link, it changes the mode of the link's target. + Chmod(ctx context.Context, name string, mode os.FileMode) error + // Lchown changes the numeric uid and gid of the named file. If the file is + // a symbolic link, it changes the uid and gid of the link itself. + Lchown(ctx context.Context, name string, uid, gid int) error + // Chown changes the numeric uid and gid of the named file. If the file is a + // symbolic link, it changes the uid and gid of the link's target. + Chown(ctx context.Context, name string, uid, gid int) error + // Chtimes changes the access and modification times of the named file, + // similar to the Unix utime() or utimes() functions. + // + // The underlying filesystem may truncate or round the values to a less + // precise time unit. + Chtimes(ctx context.Context, name string, atime time.Time, mtime time.Time) error +} diff --git a/pkg/ctxbilly/fs.go b/pkg/ctxbilly/fs.go new file mode 100644 index 0000000..e14835b --- /dev/null +++ b/pkg/ctxbilly/fs.go @@ -0,0 +1,92 @@ +package ctxbilly + +import ( + "context" + "io" + "os" + + "git.kmsign.ru/royalcat/tstor/pkg/ctxio" +) + +type Filesystem interface { + // Create creates the named file with mode 0666 (before umask), truncating + // it if it already exists. If successful, methods on the returned File can + // be used for I/O; the associated file descriptor has mode O_RDWR. + Create(ctx context.Context, filename string) (File, error) + // Open opens the named file for reading. If successful, methods on the + // returned file can be used for reading; the associated file descriptor has + // mode O_RDONLY. + Open(ctx context.Context, filename string) (File, error) + // OpenFile is the generalized open call; most users will use Open or Create + // instead. It opens the named file with specified flag (O_RDONLY etc.) and + // perm, (0666 etc.) if applicable. If successful, methods on the returned + // File can be used for I/O. + OpenFile(ctx context.Context, filename string, flag int, perm os.FileMode) (File, error) + // Stat returns a FileInfo describing the named file. + Stat(ctx context.Context, filename string) (os.FileInfo, error) + // Rename renames (moves) oldpath to newpath. If newpath already exists and + // is not a directory, Rename replaces it. OS-specific restrictions may + // apply when oldpath and newpath are in different directories. + Rename(ctx context.Context, oldpath, newpath string) error + // Remove removes the named file or directory. + Remove(ctx context.Context, filename string) error + // Join joins any number of path elements into a single path, adding a + // Separator if necessary. Join calls filepath.Clean on the result; in + // particular, all empty strings are ignored. On Windows, the result is a + // UNC path if and only if the first path element is a UNC path. + Join(elem ...string) string + + // TempFile creates a new temporary file in the directory dir with a name + // beginning with prefix, opens the file for reading and writing, and + // returns the resulting *os.File. If dir is the empty string, TempFile + // uses the default directory for temporary files (see os.TempDir). + // Multiple programs calling TempFile simultaneously will not choose the + // same file. The caller can use f.Name() to find the pathname of the file. + // It is the caller's responsibility to remove the file when no longer + // needed. + TempFile(ctx context.Context, dir, prefix string) (File, error) + + // ReadDir reads the directory named by d(irname and returns a list of + // directory entries sorted by filename. + ReadDir(ctx context.Context, path string) ([]os.FileInfo, error) + // MkdirAll creates a directory named path, along with any necessary + // parents, and returns nil, or else returns an error. The permission bits + // perm are used for all directories that MkdirAll creates. If path is/ + // already a directory, MkdirAll does nothing and returns nil. + MkdirAll(ctx context.Context, filename string, perm os.FileMode) error + + // Lstat returns a FileInfo describing the named file. If the file is a + // symbolic link, the returned FileInfo describes the symbolic link. Lstat + // makes no attempt to follow the link. + Lstat(ctx context.Context, filename string) (os.FileInfo, error) + // Symlink creates a symbolic-link from link to target. target may be an + // absolute or relative path, and need not refer to an existing node. + // Parent directories of link are created as necessary. + Symlink(ctx context.Context, target, link string) error + // Readlink returns the target path of link. + Readlink(ctx context.Context, link string) (string, error) + + // // Chroot returns a new filesystem from the same type where the new root is + // // the given path. Files outside of the designated directory tree cannot be + // // accessed. + // Chroot(path string) (Filesystem, error) + // // Root returns the root path of the filesystem. + // Root() string +} + +type File interface { + // Name returns the name of the file as presented to Open. + Name() string + ctxio.Writer + ctxio.Reader + ctxio.ReaderAt + io.Seeker + ctxio.Closer + // Lock locks the file like e.g. flock. It protects against access from + // other processes. + Lock() error + // Unlock unlocks the file. + Unlock() error + // Truncate the file. + Truncate(ctx context.Context, size int64) error +} diff --git a/pkg/ctxbilly/mem.go b/pkg/ctxbilly/mem.go new file mode 100644 index 0000000..934e18a --- /dev/null +++ b/pkg/ctxbilly/mem.go @@ -0,0 +1,166 @@ +package ctxbilly + +import ( + "context" + "io/fs" + + "github.com/go-git/go-billy/v5" +) + +func WrapFileSystem(bf billy.Filesystem) Filesystem { + return &wrapFS{ + Filesystem: bf, + } +} + +type wrapFS struct { + billy.Filesystem +} + +var _ Filesystem = (*wrapFS)(nil) + +// Create implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Create of MemFS.Filesystem. +func (m *wrapFS) Create(ctx context.Context, filename string) (File, error) { + bf, err := m.Filesystem.Create(filename) + if err != nil { + return nil, err + } + return &wrapFile{bf}, nil +} + +// Lstat implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Lstat of MemFS.Filesystem. +func (m *wrapFS) Lstat(ctx context.Context, filename string) (fs.FileInfo, error) { + return m.Filesystem.Lstat(filename) +} + +// MkdirAll implements Filesystem. +// Subtle: this method shadows the method (Filesystem).MkdirAll of MemFS.Filesystem. +func (m *wrapFS) MkdirAll(ctx context.Context, filename string, perm fs.FileMode) error { + return m.Filesystem.MkdirAll(filename, perm) +} + +// Open implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Open of MemFS.Filesystem. +func (m *wrapFS) Open(ctx context.Context, filename string) (File, error) { + bf, err := m.Filesystem.Open(filename) + if err != nil { + return nil, err + } + return WrapFile(bf), nil +} + +// OpenFile implements Filesystem. +// Subtle: this method shadows the method (Filesystem).OpenFile of MemFS.Filesystem. +func (m *wrapFS) OpenFile(ctx context.Context, filename string, flag int, perm fs.FileMode) (File, error) { + bf, err := m.Filesystem.OpenFile(filename, flag, perm) + if err != nil { + return nil, err + } + return WrapFile(bf), nil +} + +// ReadDir implements Filesystem. +// Subtle: this method shadows the method (Filesystem).ReadDir of MemFS.Filesystem. +func (m *wrapFS) ReadDir(ctx context.Context, path string) ([]fs.FileInfo, error) { + return m.Filesystem.ReadDir(path) +} + +// Readlink implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Readlink of MemFS.Filesystem. +func (m *wrapFS) Readlink(ctx context.Context, link string) (string, error) { + return m.Filesystem.Readlink(link) +} + +// Remove implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Remove of MemFS.Filesystem. +func (m *wrapFS) Remove(ctx context.Context, filename string) error { + return m.Filesystem.Remove(filename) +} + +// Rename implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Rename of MemFS.Filesystem. +func (m *wrapFS) Rename(ctx context.Context, oldpath string, newpath string) error { + return m.Filesystem.Rename(oldpath, newpath) +} + +// Stat implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Stat of MemFS.Filesystem. +func (m *wrapFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { + return m.Filesystem.Stat(filename) +} + +// Symlink implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Symlink of MemFS.Filesystem. +func (m *wrapFS) Symlink(ctx context.Context, target string, link string) error { + return m.Filesystem.Symlink(target, link) +} + +// TempFile implements Filesystem. +// Subtle: this method shadows the method (Filesystem).TempFile of MemFS.Filesystem. +func (m *wrapFS) TempFile(ctx context.Context, dir string, prefix string) (File, error) { + file, err := m.Filesystem.TempFile(dir, prefix) + if err != nil { + return nil, err + } + return WrapFile(file), nil +} + +func WrapFile(bf billy.File) File { + return &wrapFile{File: bf} +} + +type wrapFile struct { + billy.File +} + +var _ File = (*wrapFile)(nil) + +// Close implements File. +// Subtle: this method shadows the method (File).Close of MemFile.File. +func (m *wrapFile) Close(ctx context.Context) error { + return m.File.Close() +} + +// Lock implements File. +// Subtle: this method shadows the method (File).Lock of MemFile.File. +func (m *wrapFile) Lock() error { + return m.File.Lock() +} + +// Name implements File. +// Subtle: this method shadows the method (File).Name of MemFile.File. +func (m *wrapFile) Name() string { + return m.File.Name() +} + +// Truncate implements File. +// Subtle: this method shadows the method (File).Truncate of memFile.File. +func (m *wrapFile) Truncate(ctx context.Context, size int64) error { + return m.File.Truncate(size) +} + +// Read implements File. +// Subtle: this method shadows the method (File).Read of MemFile.File. +func (m *wrapFile) Read(ctx context.Context, p []byte) (n int, err error) { + return m.File.Read(p) +} + +// ReadAt implements File. +// Subtle: this method shadows the method (File).ReadAt of MemFile.File. +func (m *wrapFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + return m.File.ReadAt(p, off) +} + +// Unlock implements File. +// Subtle: this method shadows the method (File).Unlock of MemFile.File. +func (m *wrapFile) Unlock() error { + return m.File.Unlock() +} + +// Write implements File. +// Subtle: this method shadows the method (File).Write of MemFile.File. +func (m *wrapFile) Write(ctx context.Context, p []byte) (n int, err error) { + return m.File.Write(p) +} diff --git a/pkg/ctxio/cachereader.go b/pkg/ctxio/cachereader.go new file mode 100644 index 0000000..14e70f9 --- /dev/null +++ b/pkg/ctxio/cachereader.go @@ -0,0 +1,63 @@ +package ctxio + +import ( + "context" + "errors" + "io" + "sync" +) + +type CacheReader struct { + m sync.Mutex + + fo int64 + fr *FileBuffer + to int64 + tr Reader +} + +var _ FileReader = (*CacheReader)(nil) + +func NewCacheReader(r Reader) (FileReader, error) { + fr := NewFileBuffer(nil) + tr := TeeReader(r, fr) + return &CacheReader{fr: fr, tr: tr}, nil +} + +func (dtr *CacheReader) ReadAt(ctx context.Context, p []byte, off int64) (int, error) { + dtr.m.Lock() + defer dtr.m.Unlock() + tb := off + int64(len(p)) + + if tb > dtr.fo { + w, err := CopyN(ctx, Discard, dtr.tr, tb-dtr.fo) + dtr.to += w + if err != nil && err != io.EOF { + return 0, err + } + } + + n, err := dtr.fr.ReadAt(ctx, p, off) + dtr.fo += int64(n) + return n, err +} + +func (dtr *CacheReader) Read(ctx context.Context, p []byte) (n int, err error) { + dtr.m.Lock() + defer dtr.m.Unlock() + // use directly tee reader here + n, err = dtr.tr.Read(ctx, p) + dtr.to += int64(n) + return +} + +func (dtr *CacheReader) Close(ctx context.Context) error { + frcloser := dtr.fr.Close(ctx) + + var closeerr error + if rc, ok := dtr.tr.(ReadCloser); ok { + closeerr = rc.Close(ctx) + } + + return errors.Join(frcloser, closeerr) +} diff --git a/pkg/ctxio/copy.go b/pkg/ctxio/copy.go new file mode 100644 index 0000000..4c98a8e --- /dev/null +++ b/pkg/ctxio/copy.go @@ -0,0 +1,89 @@ +package ctxio + +// // CopyN copies n bytes (or until an error) from src to dst. +// // It returns the number of bytes copied and the earliest +// // error encountered while copying. +// // On return, written == n if and only if err == nil. +// // +// // If dst implements [ReaderFrom], the copy is implemented using it. +// func CopyN(ctx context.Context, dst Writer, src Reader, n int64) (written int64, err error) { +// written, err = Copy(ctx, dst, LimitReader(src, n)) +// if written == n { +// return n, nil +// } +// if written < n && err == nil { +// // src stopped early; must have been EOF. +// err = io.EOF +// } + +// return +// } + +// // Copy copies from src to dst until either EOF is reached +// // on src or an error occurs. It returns the number of bytes +// // copied and the first error encountered while copying, if any. +// // +// // A successful Copy returns err == nil, not err == EOF. +// // Because Copy is defined to read from src until EOF, it does +// // not treat an EOF from Read as an error to be reported. +// // +// // If src implements [WriterTo], +// // the copy is implemented by calling src.WriteTo(dst). +// // Otherwise, if dst implements [ReaderFrom], +// // the copy is implemented by calling dst.ReadFrom(src). +// func Copy(ctx context.Context, dst Writer, src Reader) (written int64, err error) { +// return copyBuffer(ctx, dst, src, nil) +// } + +// // copyBuffer is the actual implementation of Copy and CopyBuffer. +// // if buf is nil, one is allocated. +// func copyBuffer(ctx context.Context, dst Writer, src Reader, buf []byte) (written int64, err error) { +// // If the reader has a WriteTo method, use it to do the copy. +// // Avoids an allocation and a copy. +// if wt, ok := src.(WriterTo); ok { +// return wt.WriteTo(dst) +// } +// // Similarly, if the writer has a ReadFrom method, use it to do the copy. +// if rt, ok := dst.(ReaderFrom); ok { +// return rt.ReadFrom(src) +// } +// if buf == nil { +// size := 32 * 1024 +// if l, ok := src.(*LimitedReader); ok && int64(size) > l.N { +// if l.N < 1 { +// size = 1 +// } else { +// size = int(l.N) +// } +// } +// buf = make([]byte, size) +// } +// for { +// nr, er := src.Read(ctx, buf) +// if nr > 0 { +// nw, ew := dst.Write(ctx, buf[0:nr]) +// if nw < 0 || nr < nw { +// nw = 0 +// if ew == nil { +// ew = errInvalidWrite +// } +// } +// written += int64(nw) +// if ew != nil { +// err = ew +// break +// } +// if nr != nw { +// err = io.ErrShortWrite +// break +// } +// } +// if er != nil { +// if er != io.EOF { +// err = er +// } +// break +// } +// } +// return written, err +// } diff --git a/pkg/ctxio/filebuffer.go b/pkg/ctxio/filebuffer.go new file mode 100644 index 0000000..23cb88e --- /dev/null +++ b/pkg/ctxio/filebuffer.go @@ -0,0 +1,180 @@ +package ctxio + +import ( + "bytes" + "context" + "errors" + "io" + "os" +) + +// FileBuffer implements interfaces implemented by files. +// The main purpose of this type is to have an in memory replacement for a +// file. +type FileBuffer struct { + // buff is the backing buffer + buff *bytes.Buffer + // index indicates where in the buffer we are at + index int64 + isClosed bool +} + +var _ FileReader = (*FileBuffer)(nil) +var _ Writer = (*FileBuffer)(nil) + +// NewFileBuffer returns a new populated Buffer +func NewFileBuffer(b []byte) *FileBuffer { + return &FileBuffer{buff: bytes.NewBuffer(b)} +} + +// NewFileBufferFromReader is a convenience method that returns a new populated Buffer +// whose contents are sourced from a supplied reader by loading it entirely +// into memory. +func NewFileBufferFromReader(ctx context.Context, reader Reader) (*FileBuffer, error) { + data, err := ReadAll(ctx, reader) + if err != nil { + return nil, err + } + return NewFileBuffer(data), nil +} + +// NewFileBufferFromReader is a convenience method that returns a new populated Buffer +// whose contents are sourced from a supplied reader by loading it entirely +// into memory. +func NewFileBufferFromIoReader(reader io.Reader) (*FileBuffer, error) { + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + return NewFileBuffer(data), nil +} + +// Bytes returns the bytes available until the end of the buffer. +func (f *FileBuffer) Bytes() []byte { + if f.isClosed || f.index >= int64(f.buff.Len()) { + return []byte{} + } + return f.buff.Bytes()[f.index:] +} + +// String implements the Stringer interface +func (f *FileBuffer) String() string { + return string(f.buff.Bytes()[f.index:]) +} + +// Read implements io.Reader https://golang.org/pkg/io/#Reader +// Read reads up to len(p) bytes into p. It returns the number of bytes read (0 <= n <= len(p)) +// and any error encountered. Even if Read returns n < len(p), it may use all of p as scratch +// space during the call. If some data is available but not len(p) bytes, Read conventionally +// returns what is available instead of waiting for more. + +// When Read encounters an error or end-of-file condition after successfully reading n > 0 bytes, +// it returns the number of bytes read. It may return the (non-nil) error from the same call or +// return the error (and n == 0) from a subsequent call. An instance of this general case is +// that a Reader returning a non-zero number of bytes at the end of the input stream may return +// either err == EOF or err == nil. The next Read should return 0, EOF. +func (f *FileBuffer) Read(ctx context.Context, b []byte) (n int, err error) { + if f.isClosed { + return 0, os.ErrClosed + } + if len(b) == 0 { + return 0, nil + } + if f.index >= int64(f.buff.Len()) { + return 0, io.EOF + } + n, err = bytes.NewBuffer(f.buff.Bytes()[f.index:]).Read(b) + f.index += int64(n) + + return n, err +} + +// ReadAt implements io.ReaderAt https://golang.org/pkg/io/#ReaderAt +// ReadAt reads len(p) bytes into p starting at offset off in the underlying input source. +// It returns the number of bytes read (0 <= n <= len(p)) and any error encountered. +// +// When ReadAt returns n < len(p), it returns a non-nil error explaining why more bytes were not returned. +// In this respect, ReadAt is stricter than Read. +// +// Even if ReadAt returns n < len(p), it may use all of p as scratch space during the call. +// If some data is available but not len(p) bytes, ReadAt blocks until either all the data is available or an error occurs. +// In this respect ReadAt is different from Read. +// +// If the n = len(p) bytes returned by ReadAt are at the end of the input source, +// ReadAt may return either err == EOF or err == nil. +// +// If ReadAt is reading from an input source with a seek offset, +// ReadAt should not affect nor be affected by the underlying seek offset. +// Clients of ReadAt can execute parallel ReadAt calls on the same input source. +func (f *FileBuffer) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + if f.isClosed { + return 0, os.ErrClosed + } + if off < 0 { + return 0, errors.New("filebuffer.ReadAt: negative offset") + } + reqLen := len(p) + buffLen := int64(f.buff.Len()) + if off >= buffLen { + return 0, io.EOF + } + + n = copy(p, f.buff.Bytes()[off:]) + if n < reqLen { + err = io.EOF + } + return n, err +} + +// Write implements io.Writer https://golang.org/pkg/io/#Writer +// by appending the passed bytes to the buffer unless the buffer is closed or index negative. +func (f *FileBuffer) Write(ctx context.Context, p []byte) (n int, err error) { + if f.isClosed { + return 0, os.ErrClosed + } + if f.index < 0 { + return 0, io.EOF + } + // we might have rewinded, let's reset the buffer before appending to it + idx := int(f.index) + buffLen := f.buff.Len() + if idx != buffLen && idx <= buffLen { + f.buff = bytes.NewBuffer(f.Bytes()[:f.index]) + } + n, err = f.buff.Write(p) + + f.index += int64(n) + return n, err +} + +// Seek implements io.Seeker https://golang.org/pkg/io/#Seeker +func (f *FileBuffer) Seek(offset int64, whence int) (idx int64, err error) { + if f.isClosed { + return 0, os.ErrClosed + } + + var abs int64 + switch whence { + case 0: + abs = offset + case 1: + abs = int64(f.index) + offset + case 2: + abs = int64(f.buff.Len()) + offset + default: + return 0, errors.New("filebuffer.Seek: invalid whence") + } + if abs < 0 { + return 0, errors.New("filebuffer.Seek: negative position") + } + f.index = abs + return abs, nil +} + +// Close implements io.Closer https://golang.org/pkg/io/#Closer +// It closes the buffer, rendering it unusable for I/O. It returns an error, if any. +func (f *FileBuffer) Close(ctx context.Context) error { + f.isClosed = true + f.buff = nil + return nil +} diff --git a/pkg/ctxio/io.go b/pkg/ctxio/io.go new file mode 100644 index 0000000..fa0c561 --- /dev/null +++ b/pkg/ctxio/io.go @@ -0,0 +1,663 @@ +package ctxio + +import ( + "context" + "errors" + "io" + "sync" +) + +// Seek whence values. +const ( + SeekStart = 0 // seek relative to the origin of the file + SeekCurrent = 1 // seek relative to the current offset + SeekEnd = 2 // seek relative to the end +) + +// ErrShortWrite means that a write accepted fewer bytes than requested +// but failed to return an explicit error. +var ErrShortWrite = io.ErrShortWrite + +// errInvalidWrite means that a write returned an impossible count. +var errInvalidWrite = errors.New("invalid write result") + +// ErrShortBuffer means that a read required a longer buffer than was provided. +var ErrShortBuffer = io.ErrShortBuffer + +// EOF is the error returned by Read when no more input is available. +// (Read must return EOF itself, not an error wrapping EOF, +// because callers will test for EOF using ==.) +// Functions should return EOF only to signal a graceful end of input. +// If the EOF occurs unexpectedly in a structured data stream, +// the appropriate error is either [ErrUnexpectedEOF] or some other error +// giving more detail. +var EOF = io.EOF + +// ErrUnexpectedEOF means that EOF was encountered in the +// middle of reading a fixed-size block or data structure. +var ErrUnexpectedEOF = io.ErrUnexpectedEOF + +// ErrNoProgress is returned by some clients of a [Reader] when +// many calls to Read have failed to return any data or error, +// usually the sign of a broken [Reader] implementation. +var ErrNoProgress = io.ErrNoProgress + +// Reader is the interface that wraps the basic Read method. +// +// Read reads up to len(p) bytes into p. It returns the number of bytes +// read (0 <= n <= len(p)) and any error encountered. Even if Read +// returns n < len(p), it may use all of p as scratch space during the call. +// If some data is available but not len(p) bytes, Read conventionally +// returns what is available instead of waiting for more. +// +// When Read encounters an error or end-of-file condition after +// successfully reading n > 0 bytes, it returns the number of +// bytes read. It may return the (non-nil) error from the same call +// or return the error (and n == 0) from a subsequent call. +// An instance of this general case is that a Reader returning +// a non-zero number of bytes at the end of the input stream may +// return either err == EOF or err == nil. The next Read should +// return 0, EOF. +// +// Callers should always process the n > 0 bytes returned before +// considering the error err. Doing so correctly handles I/O errors +// that happen after reading some bytes and also both of the +// allowed EOF behaviors. +// +// If len(p) == 0, Read should always return n == 0. It may return a +// non-nil error if some error condition is known, such as EOF. +// +// Implementations of Read are discouraged from returning a +// zero byte count with a nil error, except when len(p) == 0. +// Callers should treat a return of 0 and nil as indicating that +// nothing happened; in particular it does not indicate EOF. +// +// Implementations must not retain p. +type Reader interface { + Read(ctx context.Context, p []byte) (n int, err error) +} + +// Writer is the interface that wraps the basic Write method. +// +// Write writes len(p) bytes from p to the underlying data stream. +// It returns the number of bytes written from p (0 <= n <= len(p)) +// and any error encountered that caused the write to stop early. +// Write must return a non-nil error if it returns n < len(p). +// Write must not modify the slice data, even temporarily. +// +// Implementations must not retain p. +type Writer interface { + Write(ctx context.Context, p []byte) (n int, err error) +} + +// Closer is the interface that wraps the basic Close method. +// +// The behavior of Close after the first call is undefined. +// Specific implementations may document their own behavior. +type Closer interface { + Close(ctx context.Context) error +} + +// Seeker is the interface that wraps the basic Seek method. +// +// Seek sets the offset for the next Read or Write to offset, +// interpreted according to whence: +// [SeekStart] means relative to the start of the file, +// [SeekCurrent] means relative to the current offset, and +// [SeekEnd] means relative to the end +// (for example, offset = -2 specifies the penultimate byte of the file). +// Seek returns the new offset relative to the start of the +// file or an error, if any. +// +// Seeking to an offset before the start of the file is an error. +// Seeking to any positive offset may be allowed, but if the new offset exceeds +// the size of the underlying object the behavior of subsequent I/O operations +// is implementation-dependent. +type Seeker interface { + Seek(offset int64, whence int) (int64, error) +} + +// ReadWriter is the interface that groups the basic Read and Write methods. +type ReadWriter interface { + Reader + Writer +} + +// ReadCloser is the interface that groups the basic Read and Close methods. +type ReadCloser interface { + Reader + Closer +} + +// WriteCloser is the interface that groups the basic Write and Close methods. +type WriteCloser interface { + Writer + Closer +} + +// ReadWriteCloser is the interface that groups the basic Read, Write and Close methods. +type ReadWriteCloser interface { + Reader + Writer + Closer +} + +// ReadSeeker is the interface that groups the basic Read and Seek methods. +type ReadSeeker interface { + Reader + Seeker +} + +// ReadSeekCloser is the interface that groups the basic Read, Seek and Close +// methods. +type ReadSeekCloser interface { + Reader + Seeker + Closer +} + +// WriteSeeker is the interface that groups the basic Write and Seek methods. +type WriteSeeker interface { + Writer + Seeker +} + +// ReadWriteSeeker is the interface that groups the basic Read, Write and Seek methods. +type ReadWriteSeeker interface { + Reader + Writer + Seeker +} + +// ReaderFrom is the interface that wraps the ReadFrom method. +// +// ReadFrom reads data from r until EOF or error. +// The return value n is the number of bytes read. +// Any error except EOF encountered during the read is also returned. +// +// The [Copy] function uses [ReaderFrom] if available. +type ReaderFrom interface { + ReadFrom(ctx context.Context, r Reader) (n int64, err error) +} + +// WriterTo is the interface that wraps the WriteTo method. +// +// WriteTo writes data to w until there's no more data to write or +// when an error occurs. The return value n is the number of bytes +// written. Any error encountered during the write is also returned. +// +// The Copy function uses WriterTo if available. +type WriterTo interface { + WriteTo(ctx context.Context, w Writer) (n int64, err error) +} + +// ReaderAt is the interface that wraps the basic ReadAt method. +// +// ReadAt reads len(p) bytes into p starting at offset off in the +// underlying input source. It returns the number of bytes +// read (0 <= n <= len(p)) and any error encountered. +// +// When ReadAt returns n < len(p), it returns a non-nil error +// explaining why more bytes were not returned. In this respect, +// ReadAt is stricter than Read. +// +// Even if ReadAt returns n < len(p), it may use all of p as scratch +// space during the call. If some data is available but not len(p) bytes, +// ReadAt blocks until either all the data is available or an error occurs. +// In this respect ReadAt is different from Read. +// +// If the n = len(p) bytes returned by ReadAt are at the end of the +// input source, ReadAt may return either err == EOF or err == nil. +// +// If ReadAt is reading from an input source with a seek offset, +// ReadAt should not affect nor be affected by the underlying +// seek offset. +// +// Clients of ReadAt can execute parallel ReadAt calls on the +// same input source. +// +// Implementations must not retain p. +type ReaderAt interface { + ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) +} + +// WriterAt is the interface that wraps the basic WriteAt method. +// +// WriteAt writes len(p) bytes from p to the underlying data stream +// at offset off. It returns the number of bytes written from p (0 <= n <= len(p)) +// and any error encountered that caused the write to stop early. +// WriteAt must return a non-nil error if it returns n < len(p). +// +// If WriteAt is writing to a destination with a seek offset, +// WriteAt should not affect nor be affected by the underlying +// seek offset. +// +// Clients of WriteAt can execute parallel WriteAt calls on the same +// destination if the ranges do not overlap. +// +// Implementations must not retain p. +type WriterAt interface { + WriteAt(ctx context.Context, p []byte, off int64) (n int, err error) +} + +// StringWriter is the interface that wraps the WriteString method. +type StringWriter interface { + WriteString(s string) (n int, err error) +} + +// WriteString writes the contents of the string s to w, which accepts a slice of bytes. +// If w implements [StringWriter], [StringWriter.WriteString] is invoked directly. +// Otherwise, [Writer.Write] is called exactly once. +func WriteString(ctx context.Context, w Writer, s string) (n int, err error) { + if sw, ok := w.(StringWriter); ok { + return sw.WriteString(s) + } + return w.Write(ctx, []byte(s)) +} + +// ReadAtLeast reads from r into buf until it has read at least min bytes. +// It returns the number of bytes copied and an error if fewer bytes were read. +// The error is EOF only if no bytes were read. +// If an EOF happens after reading fewer than min bytes, +// ReadAtLeast returns [ErrUnexpectedEOF]. +// If min is greater than the length of buf, ReadAtLeast returns [ErrShortBuffer]. +// On return, n >= min if and only if err == nil. +// If r returns an error having read at least min bytes, the error is dropped. +func ReadAtLeast(ctx context.Context, r Reader, buf []byte, min int) (n int, err error) { + if len(buf) < min { + return 0, ErrShortBuffer + } + for n < min && err == nil { + var nn int + nn, err = r.Read(ctx, buf[n:]) + n += nn + } + if n >= min { + err = nil + } else if n > 0 && err == EOF { + err = ErrUnexpectedEOF + } + return +} + +// ReadFull reads exactly len(buf) bytes from r into buf. +// It returns the number of bytes copied and an error if fewer bytes were read. +// The error is EOF only if no bytes were read. +// If an EOF happens after reading some but not all the bytes, +// ReadFull returns [ErrUnexpectedEOF]. +// On return, n == len(buf) if and only if err == nil. +// If r returns an error having read at least len(buf) bytes, the error is dropped. +func ReadFull(ctx context.Context, r Reader, buf []byte) (n int, err error) { + return ReadAtLeast(ctx, r, buf, len(buf)) +} + +// CopyN copies n bytes (or until an error) from src to dst. +// It returns the number of bytes copied and the earliest +// error encountered while copying. +// On return, written == n if and only if err == nil. +// +// If dst implements [ReaderFrom], the copy is implemented using it. +func CopyN(ctx context.Context, dst Writer, src Reader, n int64) (written int64, err error) { + written, err = Copy(ctx, dst, LimitReader(src, n)) + if written == n { + return n, nil + } + if written < n && err == nil { + // src stopped early; must have been EOF. + err = EOF + } + return +} + +// Copy copies from src to dst until either EOF is reached +// on src or an error occurs. It returns the number of bytes +// copied and the first error encountered while copying, if any. +// +// A successful Copy returns err == nil, not err == EOF. +// Because Copy is defined to read from src until EOF, it does +// not treat an EOF from Read as an error to be reported. +// +// If src implements [WriterTo], +// the copy is implemented by calling src.WriteTo(dst). +// Otherwise, if dst implements [ReaderFrom], +// the copy is implemented by calling dst.ReadFrom(src). +func Copy(ctx context.Context, dst Writer, src Reader) (written int64, err error) { + return copyBuffer(ctx, dst, src, nil) +} + +// CopyBuffer is identical to Copy except that it stages through the +// provided buffer (if one is required) rather than allocating a +// temporary one. If buf is nil, one is allocated; otherwise if it has +// zero length, CopyBuffer panics. +// +// If either src implements [WriterTo] or dst implements [ReaderFrom], +// buf will not be used to perform the copy. +func CopyBuffer(ctx context.Context, dst Writer, src Reader, buf []byte) (written int64, err error) { + if buf != nil && len(buf) == 0 { + panic("empty buffer in CopyBuffer") + } + return copyBuffer(ctx, dst, src, buf) +} + +// copyBuffer is the actual implementation of Copy and CopyBuffer. +// if buf is nil, one is allocated. +func copyBuffer(ctx context.Context, dst Writer, src Reader, buf []byte) (written int64, err error) { + // If the reader has a WriteTo method, use it to do the copy. + // Avoids an allocation and a copy. + if wt, ok := src.(WriterTo); ok { + return wt.WriteTo(ctx, dst) + } + // Similarly, if the writer has a ReadFrom method, use it to do the copy. + if rt, ok := dst.(ReaderFrom); ok { + return rt.ReadFrom(ctx, src) + } + if buf == nil { + size := 32 * 1024 + if l, ok := src.(*LimitedReader); ok && int64(size) > l.N { + if l.N < 1 { + size = 1 + } else { + size = int(l.N) + } + } + buf = make([]byte, size) + } + for { + nr, er := src.Read(ctx, buf) + if nr > 0 { + nw, ew := dst.Write(ctx, buf[0:nr]) + if nw < 0 || nr < nw { + nw = 0 + if ew == nil { + ew = errInvalidWrite + } + } + written += int64(nw) + if ew != nil { + err = ew + break + } + if nr != nw { + err = ErrShortWrite + break + } + } + if er != nil { + if er != EOF { + err = er + } + break + } + } + return written, err +} + +// LimitReader returns a Reader that reads from r +// but stops with EOF after n bytes. +// The underlying implementation is a *LimitedReader. +func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n} } + +// A LimitedReader reads from R but limits the amount of +// data returned to just N bytes. Each call to Read +// updates N to reflect the new amount remaining. +// Read returns EOF when N <= 0 or when the underlying R returns EOF. +type LimitedReader struct { + R Reader // underlying reader + N int64 // max bytes remaining +} + +func (l *LimitedReader) Read(ctx context.Context, p []byte) (n int, err error) { + if l.N <= 0 { + return 0, EOF + } + if int64(len(p)) > l.N { + p = p[0:l.N] + } + n, err = l.R.Read(ctx, p) + l.N -= int64(n) + return +} + +// NewSectionReader returns a [SectionReader] that reads from r +// starting at offset off and stops with EOF after n bytes. +func NewSectionReader(r ReaderAt, off int64, n int64) *SectionReader { + var remaining int64 + const maxint64 = 1<<63 - 1 + if off <= maxint64-n { + remaining = n + off + } else { + // Overflow, with no way to return error. + // Assume we can read up to an offset of 1<<63 - 1. + remaining = maxint64 + } + return &SectionReader{r, off, off, remaining, n} +} + +// SectionReader implements Read, Seek, and ReadAt on a section +// of an underlying [ReaderAt]. +type SectionReader struct { + r ReaderAt // constant after creation + base int64 // constant after creation + off int64 + limit int64 // constant after creation + n int64 // constant after creation +} + +func (s *SectionReader) Read(ctx context.Context, p []byte) (n int, err error) { + if s.off >= s.limit { + return 0, EOF + } + if max := s.limit - s.off; int64(len(p)) > max { + p = p[0:max] + } + n, err = s.r.ReadAt(ctx, p, s.off) + s.off += int64(n) + return +} + +var errWhence = errors.New("Seek: invalid whence") +var errOffset = errors.New("Seek: invalid offset") + +func (s *SectionReader) Seek(offset int64, whence int) (int64, error) { + switch whence { + default: + return 0, errWhence + case SeekStart: + offset += s.base + case SeekCurrent: + offset += s.off + case SeekEnd: + offset += s.limit + } + if offset < s.base { + return 0, errOffset + } + s.off = offset + return offset - s.base, nil +} + +func (s *SectionReader) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + if off < 0 || off >= s.Size() { + return 0, EOF + } + off += s.base + if max := s.limit - off; int64(len(p)) > max { + p = p[0:max] + n, err = s.r.ReadAt(ctx, p, off) + if err == nil { + err = EOF + } + return n, err + } + return s.r.ReadAt(ctx, p, off) +} + +// Size returns the size of the section in bytes. +func (s *SectionReader) Size() int64 { return s.limit - s.base } + +// Outer returns the underlying [ReaderAt] and offsets for the section. +// +// The returned values are the same that were passed to [NewSectionReader] +// when the [SectionReader] was created. +func (s *SectionReader) Outer() (r ReaderAt, off int64, n int64) { + return s.r, s.base, s.n +} + +// An OffsetWriter maps writes at offset base to offset base+off in the underlying writer. +type OffsetWriter struct { + w WriterAt + base int64 // the original offset + off int64 // the current offset +} + +// NewOffsetWriter returns an [OffsetWriter] that writes to w +// starting at offset off. +func NewOffsetWriter(w WriterAt, off int64) *OffsetWriter { + return &OffsetWriter{w, off, off} +} + +func (o *OffsetWriter) Write(ctx context.Context, p []byte) (n int, err error) { + n, err = o.w.WriteAt(ctx, p, o.off) + o.off += int64(n) + return +} + +func (o *OffsetWriter) WriteAt(ctx context.Context, p []byte, off int64) (n int, err error) { + if off < 0 { + return 0, errOffset + } + + off += o.base + return o.w.WriteAt(ctx, p, off) +} + +func (o *OffsetWriter) Seek(offset int64, whence int) (int64, error) { + switch whence { + default: + return 0, errWhence + case SeekStart: + offset += o.base + case SeekCurrent: + offset += o.off + } + if offset < o.base { + return 0, errOffset + } + o.off = offset + return offset - o.base, nil +} + +// TeeReader returns a [Reader] that writes to w what it reads from r. +// All reads from r performed through it are matched with +// corresponding writes to w. There is no internal buffering - +// the write must complete before the read completes. +// Any error encountered while writing is reported as a read error. +func TeeReader(r Reader, w Writer) Reader { + return &teeReader{r, w} +} + +type teeReader struct { + r Reader + w Writer +} + +func (t *teeReader) Read(ctx context.Context, p []byte) (n int, err error) { + n, err = t.r.Read(ctx, p) + if n > 0 { + if n, err := t.w.Write(ctx, p[:n]); err != nil { + return n, err + } + } + return +} + +// Discard is a [Writer] on which all Write calls succeed +// without doing anything. +var Discard Writer = discard{} + +type discard struct{} + +// discard implements ReaderFrom as an optimization so Copy to +// io.Discard can avoid doing unnecessary work. +var _ ReaderFrom = discard{} + +func (discard) Write(ctx context.Context, p []byte) (int, error) { + return len(p), nil +} + +func (discard) WriteString(ctx context.Context, s string) (int, error) { + return len(s), nil +} + +var blackHolePool = sync.Pool{ + New: func() any { + b := make([]byte, 8192) + return &b + }, +} + +func (discard) ReadFrom(ctx context.Context, r Reader) (n int64, err error) { + bufp := blackHolePool.Get().(*[]byte) + readSize := 0 + for { + readSize, err = r.Read(ctx, *bufp) + n += int64(readSize) + if err != nil { + blackHolePool.Put(bufp) + if err == EOF { + return n, nil + } + return + } + } +} + +// NopCloser returns a [ReadCloser] with a no-op Close method wrapping +// the provided [Reader] r. +// If r implements [WriterTo], the returned [ReadCloser] will implement [WriterTo] +// by forwarding calls to r. +func NopCloser(r Reader) ReadCloser { + if _, ok := r.(WriterTo); ok { + return nopCloserWriterTo{r} + } + return nopCloser{r} +} + +type nopCloser struct { + Reader +} + +func (nopCloser) Close(ctx context.Context) error { return nil } + +type nopCloserWriterTo struct { + Reader +} + +func (nopCloserWriterTo) Close(ctx context.Context) error { return nil } + +func (c nopCloserWriterTo) WriteTo(ctx context.Context, w Writer) (n int64, err error) { + return c.Reader.(WriterTo).WriteTo(ctx, w) +} + +// ReadAll reads from r until an error or EOF and returns the data it read. +// A successful call returns err == nil, not err == EOF. Because ReadAll is +// defined to read from src until EOF, it does not treat an EOF from Read +// as an error to be reported. +func ReadAll(ctx context.Context, r Reader) ([]byte, error) { + b := make([]byte, 0, 512) + for { + n, err := r.Read(ctx, b[len(b):cap(b)]) + b = b[:len(b)+n] + if err != nil { + if err == EOF { + err = nil + } + return b, err + } + + if len(b) == cap(b) { + // Add more capacity (let append pick how much). + b = append(b, 0)[:len(b)] + } + } +} diff --git a/pkg/ctxio/reader.go b/pkg/ctxio/reader.go index 2ba9f7d..8d2e4c7 100644 --- a/pkg/ctxio/reader.go +++ b/pkg/ctxio/reader.go @@ -5,28 +5,22 @@ import ( "io" ) -type ReaderAtCloser interface { +type FileReader interface { + Reader ReaderAt Closer } -type ReaderAt interface { - ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) -} - -type Reader interface { - Read(ctx context.Context, p []byte) (n int, err error) -} - -type Closer interface { - Close(ctx context.Context) error -} type contextReader struct { ctx context.Context r Reader } func (r *contextReader) Read(p []byte) (n int, err error) { + if r.ctx.Err() != nil { + return 0, r.ctx.Err() + } + return r.r.Read(r.ctx, p) } @@ -40,9 +34,31 @@ type contextReaderAt struct { } func (c *contextReaderAt) ReadAt(p []byte, off int64) (n int, err error) { + if c.ctx.Err() != nil { + return 0, c.ctx.Err() + } + return c.r.ReadAt(c.ctx, p, off) } func IoReader(ctx context.Context, r Reader) io.Reader { return &contextReader{ctx: ctx, r: r} } + +func WrapIoReader(r io.Reader) Reader { + return &wrapReader{r: r} +} + +type wrapReader struct { + r io.Reader +} + +var _ Reader = (*wrapReader)(nil) + +// Read implements Reader. +func (c *wrapReader) Read(ctx context.Context, p []byte) (n int, err error) { + if ctx.Err() != nil { + return 0, ctx.Err() + } + return c.r.Read(p) +} diff --git a/pkg/ctxio/seeker.go b/pkg/ctxio/seeker.go index 5284ecc..a482477 100644 --- a/pkg/ctxio/seeker.go +++ b/pkg/ctxio/seeker.go @@ -59,10 +59,10 @@ type ioSeekerCloserWrapper struct { pos int64 size int64 - r ReaderAtCloser + r FileReader } -func IoReadSeekCloserWrapper(ctx context.Context, r ReaderAtCloser, size int64) io.ReadSeekCloser { +func IoReadSeekCloserWrapper(ctx context.Context, r FileReader, size int64) io.ReadSeekCloser { return &ioSeekerCloserWrapper{ ctx: ctx, r: r, diff --git a/pkg/ctxio/teereader.go b/pkg/ctxio/teereader.go new file mode 100644 index 0000000..999a670 --- /dev/null +++ b/pkg/ctxio/teereader.go @@ -0,0 +1,20 @@ +package ctxio + +// func TeeReader(r Reader, w Writer) Reader { +// return &teeReader{r, w} +// } + +// type teeReader struct { +// r Reader +// w Writer +// } + +// func (t *teeReader) Read(ctx context.Context, p []byte) (n int, err error) { +// n, err = t.r.Read(ctx, p) +// if n > 0 { +// if n, err := t.w.Write(ctx, p[:n]); err != nil { +// return n, err +// } +// } +// return +// } diff --git a/pkg/go-nfs/.github/dependabot.yml b/pkg/go-nfs/.github/dependabot.yml new file mode 100644 index 0000000..12389a7 --- /dev/null +++ b/pkg/go-nfs/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/pkg/go-nfs/.github/workflows/codeql-analysis.yml b/pkg/go-nfs/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..ca22cc4 --- /dev/null +++ b/pkg/go-nfs/.github/workflows/codeql-analysis.yml @@ -0,0 +1,51 @@ +name: "Code scanning - action" + +on: + push: + pull_request: + schedule: + - cron: '0 18 * * 3' + +jobs: + CodeQL-Build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + # We must fetch at least the immediate parents so that if this is + # a pull request then we can checkout the head. + fetch-depth: 2 + + # If this run was triggered by a pull request event, then checkout + # the head of the pull request instead of the merge commit. + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + # Override language selection by uncommenting this and choosing your languages + # with: + # languages: go, javascript, csharp, python, cpp, java + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹ️ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/pkg/go-nfs/.github/workflows/go.yml b/pkg/go-nfs/.github/workflows/go.yml new file mode 100644 index 0000000..df1e4fe --- /dev/null +++ b/pkg/go-nfs/.github/workflows/go.yml @@ -0,0 +1,36 @@ +name: Go + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +permissions: + contents: read + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - name: Set up Go 1.x + uses: actions/setup-go@v3 + with: + go-version: ^1.19 + id: go + + - name: Check out code into the Go module directory + uses: actions/checkout@v3 + + - name: Get dependencies + run: go get -v -t -d ./... + + - name: Build + run: go build -v ./... + + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + + - name: Test + run: go test -v . diff --git a/pkg/go-nfs/CONTRIBUTING.md b/pkg/go-nfs/CONTRIBUTING.md new file mode 100644 index 0000000..f1f3f11 --- /dev/null +++ b/pkg/go-nfs/CONTRIBUTING.md @@ -0,0 +1,11 @@ +# Contributing Guidelines + +We appreciate your interest in improving go-nfs! + +## Looking for ways to contribute? + +There are several ways you can contribute: +- Start contributing immediately via the [opened](https://github.com/willscott/go-nfs/issues) issues on GitHub. + Defined issues provide an excellent starting point. +- Reporting issues, bugs, mistakes, or inconsistencies. + As many open source projects, we are short-staffed, we thus kindly ask you to be open to contribute a fix for discovered issues. diff --git a/pkg/go-nfs/LICENSE b/pkg/go-nfs/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/pkg/go-nfs/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/pkg/go-nfs/README.md b/pkg/go-nfs/README.md new file mode 100644 index 0000000..a92f706 --- /dev/null +++ b/pkg/go-nfs/README.md @@ -0,0 +1,96 @@ +Golang Network File Server +=== + +NFSv3 protocol implementation in pure Golang. + +Current Status: +* Minimally tested +* Mounts, read-only and read-write support + +Usage +=== + +The most interesting demo is currently in `example/osview`. + +Start the server +`go run ./example/osview .`. + +The local folder at `.` will be the initial view in the mount. mutations to metadata or contents +will be stored purely in memory and not written back to the OS. When run, this +demo will print the port it is listening on. + +The mount can be accessed using a command similar to +`mount -o port=,mountport= -t nfs localhost:/mount ` (For Mac users) + +or + +`mount -o port=,mountport=,nfsvers=3,noacl,tcp -t nfs localhost:/mount ` (For Linux users) + +API +=== + +The NFS server runs on a `net.Listener` to export a file system to NFS clients. +Usage is structured similarly to many other golang network servers. + +```golang +package main + +import ( + "fmt" + "log" + "net" + + "github.com/go-git/go-billy/v5/memfs" + nfs "github.com/willscott/go-nfs" + nfshelper "github.com/willscott/go-nfs/helpers" +) + +func main() { + listener, err := net.Listen("tcp", ":0") + panicOnErr(err, "starting TCP listener") + fmt.Printf("Server running at %s\n", listener.Addr()) + mem := memfs.New() + f, err := mem.Create("hello.txt") + panicOnErr(err, "creating file") + _, err = f.Write([]byte("hello world")) + panicOnErr(err, "writing data") + f.Close() + handler := nfshelper.NewNullAuthHandler(mem) + cacheHelper := nfshelper.NewCachingHandler(handler, 1) + panicOnErr(nfs.Serve(listener, cacheHelper), "serving nfs") +} + +func panicOnErr(err error, desc ...interface{}) { + if err == nil { + return + } + log.Println(desc...) + log.Panicln(err) +} +``` + +Notes +--- + +* Ports are typically determined through portmap. The need for running portmap +(which is the only part that needs a privileged listening port) can be avoided +through specific mount options. e.g. +`mount -o port=n,mountport=n -t nfs host:/mount /localmount` + +* This server currently uses [billy](https://github.com/go-git/go-billy/) to +provide a file system abstraction layer. There are some edges of the NFS protocol +which do not translate to this abstraction. + * NFS expects access to an `inode` or equivalent unique identifier to reference + files in a file system. These are considered opaque identifiers here, which + means they will not work as expected in cases of hard linking. + * The billy abstraction layer does not extend to exposing `uid` and `gid` + ownership of files. If ownership is important to your file system, you + will need to ensure that the `os.FileInfo` meets additional constraints. + In particular, the `Sys()` escape hatch is queried by this library, and + if your file system populates a [`syscall.Stat_t`](https://golang.org/pkg/syscall/#Stat_t) + concrete struct, the ownership specified in that object will be used. + +* Relevant RFCS: +[5531 - RPC protocol](https://tools.ietf.org/html/rfc5531), +[1813 - NFSv3](https://tools.ietf.org/html/rfc1813), +[1094 - NFS](https://tools.ietf.org/html/rfc1094) diff --git a/pkg/go-nfs/SECURITY.md b/pkg/go-nfs/SECURITY.md new file mode 100644 index 0000000..5f079c1 --- /dev/null +++ b/pkg/go-nfs/SECURITY.md @@ -0,0 +1,11 @@ +# Security Policy + +## Supported Versions + +The latest release reflects the current best recommendation / supported version at this time. + +## Reporting a Vulnerability + +Please email Will (the git commit author) if you need to report issues privately. +I will endeavor to respond within a day, but if I am offline, responses may be delayed longer than that. +If you need a stronger SLA to have confidence in using this code, feel free to reach out. diff --git a/pkg/go-nfs/capability_check.go b/pkg/go-nfs/capability_check.go new file mode 100644 index 0000000..5ad6596 --- /dev/null +++ b/pkg/go-nfs/capability_check.go @@ -0,0 +1,9 @@ +package nfs + +import ( + billy "github.com/go-git/go-billy/v5" +) + +func CapabilityCheck(fs Filesystem, cap billy.Capability) bool { + return true +} diff --git a/pkg/go-nfs/conn.go b/pkg/go-nfs/conn.go new file mode 100644 index 0000000..ec9cf76 --- /dev/null +++ b/pkg/go-nfs/conn.go @@ -0,0 +1,335 @@ +package nfs + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + + xdr2 "github.com/rasky/go-xdr/xdr2" + "github.com/willscott/go-nfs-client/nfs/rpc" + "github.com/willscott/go-nfs-client/nfs/xdr" + "go.opentelemetry.io/otel" +) + +var ( + // ErrInputInvalid is returned when input cannot be parsed + ErrInputInvalid = errors.New("invalid input") + // ErrAlreadySent is returned when writing a header/status multiple times + ErrAlreadySent = errors.New("response already started") +) + +// ResponseCode is a combination of accept_stat and reject_stat. +type ResponseCode uint32 + +// ResponseCode Codes +const ( + ResponseCodeSuccess ResponseCode = iota + ResponseCodeProgUnavailable + ResponseCodeProcUnavailable + ResponseCodeGarbageArgs + ResponseCodeSystemErr + ResponseCodeRPCMismatch + ResponseCodeAuthError +) + +type conn struct { + *Server + writeSerializer chan []byte + net.Conn +} + +var tracer = otel.Tracer("git.kmsign.ru/royalcat/tstor/pkg/go-nfs") + +func (c *conn) serve() { + ctx := context.Background() // TODO implement correct timeout on serve side + + c.writeSerializer = make(chan []byte, 1) + go c.serializeWrites(ctx) + + bio := bufio.NewReader(c.Conn) + for { + w, err := c.readRequestHeader(ctx, bio) + if err != nil { + if err == io.EOF { + // Clean close. + c.Close() + return + } + return + } + Log.Tracef("request: %v", w.req) + err = c.handle(ctx, w) + respErr := w.finish(ctx) + if err != nil { + Log.Errorf("error handling req: %v", err) + // failure to handle at a level needing to close the connection. + c.Close() + return + } + if respErr != nil { + Log.Errorf("error sending response: %v", respErr) + c.Close() + return + } + } +} + +func (c *conn) serializeWrites(ctx context.Context) { + // todo: maybe don't need the extra buffer + writer := bufio.NewWriter(c.Conn) + var fragmentBuf [4]byte + var fragmentInt uint32 + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-c.writeSerializer: + if !ok { + return + } + // prepend the fragmentation header + fragmentInt = uint32(len(msg)) + fragmentInt |= (1 << 31) + binary.BigEndian.PutUint32(fragmentBuf[:], fragmentInt) + n, err := writer.Write(fragmentBuf[:]) + if n < 4 || err != nil { + return + } + n, err = writer.Write(msg) + if err != nil { + return + } + if n < len(msg) { + panic("todo: ensure writes complete fully.") + } + if err = writer.Flush(); err != nil { + return + } + } + } +} + +// Handle a request. errors from this method indicate a failure to read or +// write on the network stream, and trigger a disconnection of the connection. +func (c *conn) handle(ctx context.Context, w *response) error { + ctx, span := tracer.Start(ctx, fmt.Sprintf("nfs.handle.%s", NFSProcedure(w.req.Header.Proc).String())) + defer span.End() + + handler := c.Server.handlerFor(w.req.Header.Prog, w.req.Header.Proc) + if handler == nil { + Log.Errorf("No handler for %d.%d", w.req.Header.Prog, w.req.Header.Proc) + if err := w.drain(ctx); err != nil { + return err + } + return c.err(ctx, w, &ResponseCodeProcUnavailableError{}) + } + appError := handler(ctx, w, c.Server.Handler) + if drainErr := w.drain(ctx); drainErr != nil { + return drainErr + } + if appError != nil && !w.responded { + Log.Errorf("call to %+v failed: %v", handler, appError) + if err := c.err(ctx, w, appError); err != nil { + return err + } + } + if !w.responded { + Log.Errorf("Handler did not indicate response status via writing or erroring") + if err := c.err(ctx, w, &ResponseCodeSystemError{}); err != nil { + return err + } + } + return nil +} + +func (c *conn) err(ctx context.Context, w *response, err error) error { + select { + case <-ctx.Done(): + return nil + default: + } + + if w.err == nil { + w.err = err + } + + if w.responded { + return nil + } + + rpcErr := w.errorFmt(err) + if writeErr := w.writeHeader(rpcErr.Code()); writeErr != nil { + return writeErr + } + + body, _ := rpcErr.MarshalBinary() + return w.Write(body) +} + +type request struct { + xid uint32 + rpc.Header + Body io.Reader +} + +func (r *request) String() string { + if r.Header.Prog == nfsServiceID { + return fmt.Sprintf("RPC #%d (nfs.%s)", r.xid, NFSProcedure(r.Header.Proc)) + } else if r.Header.Prog == mountServiceID { + return fmt.Sprintf("RPC #%d (mount.%s)", r.xid, MountProcedure(r.Header.Proc)) + } + return fmt.Sprintf("RPC #%d (%d.%d)", r.xid, r.Header.Prog, r.Header.Proc) +} + +type response struct { + *conn + writer *bytes.Buffer + responded bool + err error + errorFmt func(error) RPCError + req *request +} + +func (w *response) writeXdrHeader() error { + err := xdr.Write(w.writer, &w.req.xid) + if err != nil { + return err + } + respType := uint32(1) + err = xdr.Write(w.writer, &respType) + if err != nil { + return err + } + return nil +} + +func (w *response) writeHeader(code ResponseCode) error { + if w.responded { + return ErrAlreadySent + } + w.responded = true + if err := w.writeXdrHeader(); err != nil { + return err + } + + status := rpc.MsgAccepted + if code == ResponseCodeAuthError || code == ResponseCodeRPCMismatch { + status = rpc.MsgDenied + } + + err := xdr.Write(w.writer, &status) + if err != nil { + return err + } + + if status == rpc.MsgAccepted { + // Write opaque_auth header. + err = xdr.Write(w.writer, &rpc.AuthNull) + if err != nil { + return err + } + } + + return xdr.Write(w.writer, &code) +} + +// Write a response to an xdr message +func (w *response) Write(dat []byte) error { + if !w.responded { + if err := w.writeHeader(ResponseCodeSuccess); err != nil { + return err + } + } + + acc := 0 + for acc < len(dat) { + n, err := w.writer.Write(dat[acc:]) + if err != nil { + return err + } + acc += n + } + return nil +} + +// drain reads the rest of the request frame if not consumed by the handler. +func (w *response) drain(ctx context.Context) error { + if reader, ok := w.req.Body.(*io.LimitedReader); ok { + if reader.N == 0 { + return nil + } + // todo: wrap body in a context reader. + _, err := io.CopyN(io.Discard, w.req.Body, reader.N) + if err == nil || err == io.EOF { + return nil + } + return err + } + return io.ErrUnexpectedEOF +} + +func (w *response) finish(ctx context.Context) error { + select { + case w.conn.writeSerializer <- w.writer.Bytes(): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (c *conn) readRequestHeader(ctx context.Context, reader *bufio.Reader) (w *response, err error) { + fragment, err := xdr.ReadUint32(reader) + if err != nil { + if xdrErr, ok := err.(*xdr2.UnmarshalError); ok { + if xdrErr.Err == io.EOF { + return nil, io.EOF + } + } + return nil, err + } + if fragment&(1<<31) == 0 { + Log.Warnf("Warning: haven't implemented fragment reconstruction.\n") + return nil, ErrInputInvalid + } + reqLen := fragment - uint32(1<<31) + if reqLen < 40 { + return nil, ErrInputInvalid + } + + r := io.LimitedReader{R: reader, N: int64(reqLen)} + + xid, err := xdr.ReadUint32(&r) + if err != nil { + return nil, err + } + reqType, err := xdr.ReadUint32(&r) + if err != nil { + return nil, err + } + if reqType != 0 { // 0 = request, 1 = response + return nil, ErrInputInvalid + } + + req := request{ + xid, + rpc.Header{}, + &r, + } + if err = xdr.Read(&r, &req.Header); err != nil { + return nil, err + } + + w = &response{ + conn: c, + req: &req, + errorFmt: basicErrorFormatter, + // TODO: use a pool for these. + writer: bytes.NewBuffer([]byte{}), + } + return w, nil +} diff --git a/pkg/go-nfs/errors.go b/pkg/go-nfs/errors.go new file mode 100644 index 0000000..af08be6 --- /dev/null +++ b/pkg/go-nfs/errors.go @@ -0,0 +1,230 @@ +package nfs + +import ( + "encoding" + "encoding/binary" + "errors" + "fmt" +) + +// RPCError provides the error interface for errors thrown by +// procedures to be transmitted over the XDR RPC channel +type RPCError interface { + // An RPCError is an `error` with this method + Error() string + // Code is the RPC Response code to send + Code() ResponseCode + // BinaryMarshaler is the on-wire representation of this error + encoding.BinaryMarshaler +} + +// AuthStat is an enumeration of why authentication ahs failed +type AuthStat uint32 + +// AuthStat Codes +const ( + AuthStatOK AuthStat = iota + AuthStatBadCred + AuthStatRejectedCred + AuthStatBadVerifier + AuthStatRejectedVerfier + AuthStatTooWeak + AuthStatInvalidResponse + AuthStatFailed + AuthStatKerbGeneric + AuthStatTimeExpire + AuthStatTktFile + AuthStatDecode + AuthStatNetAddr + AuthStatRPCGSSCredProblem + AuthStatRPCGSSCTXProblem +) + +// AuthError is an RPCError +type AuthError struct { + AuthStat +} + +// Code for AuthErrors is ResponseCodeAuthError +func (a *AuthError) Code() ResponseCode { + return ResponseCodeAuthError +} + +// Error is a textual representaiton of the auth error. From the RFC +func (a *AuthError) Error() string { + switch a.AuthStat { + case AuthStatOK: + return "Auth Status: OK" + case AuthStatBadCred: + return "Auth Status: bad credential" + case AuthStatRejectedCred: + return "Auth Status: client must begin new session" + case AuthStatBadVerifier: + return "Auth Status: bad verifier" + case AuthStatRejectedVerfier: + return "Auth Status: verifier expired or replayed" + case AuthStatTooWeak: + return "Auth Status: rejected for security reasons" + case AuthStatInvalidResponse: + return "Auth Status: bogus response verifier" + case AuthStatFailed: + return "Auth Status: reason unknown" + case AuthStatKerbGeneric: + return "Auth Status: kerberos generic error" + case AuthStatTimeExpire: + return "Auth Status: time of credential expired" + case AuthStatTktFile: + return "Auth Status: problem with ticket file" + case AuthStatDecode: + return "Auth Status: can't decode authenticator" + case AuthStatNetAddr: + return "Auth Status: wrong net address in ticket" + case AuthStatRPCGSSCredProblem: + return "Auth Status: no credentials for user" + case AuthStatRPCGSSCTXProblem: + return "Auth Status: problem with context" + } + return "Auth Status: Unknown" +} + +// MarshalBinary sends the specific auth status +func (a *AuthError) MarshalBinary() (data []byte, err error) { + var resp [4]byte + binary.LittleEndian.PutUint32(resp[:], uint32(a.AuthStat)) + return resp[:], nil +} + +// RPCMismatchError is an RPCError +type RPCMismatchError struct { + Low uint32 + High uint32 +} + +// Code for RPCMismatchError is ResponseCodeRPCMismatch +func (r *RPCMismatchError) Code() ResponseCode { + return ResponseCodeRPCMismatch +} + +func (r *RPCMismatchError) Error() string { + return fmt.Sprintf("RPC Mismatch: Expected version between %d and %d.", r.Low, r.High) +} + +// MarshalBinary sends the specific rpc mismatch range +func (r *RPCMismatchError) MarshalBinary() (data []byte, err error) { + var resp [8]byte + binary.LittleEndian.PutUint32(resp[0:4], uint32(r.Low)) + binary.LittleEndian.PutUint32(resp[4:8], uint32(r.High)) + return resp[:], nil +} + +// ResponseCodeProcUnavailableError is an RPCError +type ResponseCodeProcUnavailableError struct { +} + +// Code for ResponseCodeProcUnavailableError +func (r *ResponseCodeProcUnavailableError) Code() ResponseCode { + return ResponseCodeProcUnavailable +} + +func (r *ResponseCodeProcUnavailableError) Error() string { + return "The requested procedure is unexported" +} + +// MarshalBinary - this error has no associated body +func (r *ResponseCodeProcUnavailableError) MarshalBinary() (data []byte, err error) { + return []byte{}, nil +} + +// ResponseCodeSystemError is an RPCError +type ResponseCodeSystemError struct { +} + +// Code for ResponseCodeSystemError +func (r *ResponseCodeSystemError) Code() ResponseCode { + return ResponseCodeSystemErr +} + +func (r *ResponseCodeSystemError) Error() string { + return "memory allocation failure" +} + +// MarshalBinary - this error has no associated body +func (r *ResponseCodeSystemError) MarshalBinary() (data []byte, err error) { + return []byte{}, nil +} + +// basicErrorFormatter is the default error handler for response errors. +// if the error is already formatted, it is directly written. Otherwise, +// ResponseCodeSystemError is sent to the client. +func basicErrorFormatter(err error) RPCError { + var rpcErr RPCError + if errors.As(err, &rpcErr) { + return rpcErr + } + return &ResponseCodeSystemError{} +} + +// NFSStatusError represents an error at the NFS level. +type NFSStatusError struct { + NFSStatus + WrappedErr error +} + +// Error is The wrapped error +func (s *NFSStatusError) Error() string { + message := s.NFSStatus.String() + if s.WrappedErr != nil { + message = fmt.Sprintf("%s: %v", message, s.WrappedErr) + } + return message +} + +// Code for NFS issues are successful RPC responses +func (s *NFSStatusError) Code() ResponseCode { + return ResponseCodeSuccess +} + +// MarshalBinary - The binary form of the code. +func (s *NFSStatusError) MarshalBinary() (data []byte, err error) { + var resp [4]byte + binary.BigEndian.PutUint32(resp[0:4], uint32(s.NFSStatus)) + return resp[:], nil +} + +// Unwrap unpacks wrapped errors +func (s *NFSStatusError) Unwrap() error { + return s.WrappedErr +} + +// StatusErrorWithBody is an NFS error with a payload. +type StatusErrorWithBody struct { + NFSStatusError + Body []byte +} + +// MarshalBinary provides the wire format of the error response +func (s *StatusErrorWithBody) MarshalBinary() (data []byte, err error) { + head, err := s.NFSStatusError.MarshalBinary() + return append(head, s.Body...), err +} + +// errFormatterWithBody appends a provided body to errors +func errFormatterWithBody(body []byte) func(err error) RPCError { + return func(err error) RPCError { + if nerr, ok := err.(*NFSStatusError); ok { + return &StatusErrorWithBody{*nerr, body[:]} + } + var rErr RPCError + if errors.As(err, &rErr) { + return rErr + } + return &ResponseCodeSystemError{} + } +} + +var ( + opAttrErrorBody = [4]byte{} + opAttrErrorFormatter = errFormatterWithBody(opAttrErrorBody[:]) + wccDataErrorBody = [8]byte{} + wccDataErrorFormatter = errFormatterWithBody(wccDataErrorBody[:]) +) diff --git a/pkg/go-nfs/example/helloworld/main.go b/pkg/go-nfs/example/helloworld/main.go new file mode 100644 index 0000000..87f2d98 --- /dev/null +++ b/pkg/go-nfs/example/helloworld/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "net" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/memfs" + + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" + nfshelper "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" +) + +// ROFS is an intercepter for the filesystem indicating it should +// be read only. The undelrying billy.Memfs indicates it supports +// writing, but does not in implement billy.Change to support +// modification of permissions / modTimes, and as such cannot be +// used as RW system. +type ROFS struct { + nfs.Filesystem +} + +// Capabilities exports the filesystem as readonly +func (ROFS) Capabilities() billy.Capability { + return billy.ReadCapability | billy.SeekCapability +} + +func main() { + ctx := context.Background() + + listener, err := net.Listen("tcp", ":0") + if err != nil { + fmt.Printf("Failed to listen: %v\n", err) + return + } + fmt.Printf("Server running at %s\n", listener.Addr()) + + mem := helpers.WrapBillyFS(memfs.New()) + f, err := mem.Create(ctx, "hello.txt") + if err != nil { + fmt.Printf("Failed to create file: %v\n", err) + return + } + _, _ = f.Write(ctx, []byte("hello world")) + _ = f.Close(ctx) + + handler := nfshelper.NewNullAuthHandler(ROFS{mem}) + cacheHelper := nfshelper.NewCachingHandler(handler, 1024) + fmt.Printf("%v", nfs.Serve(listener, cacheHelper)) +} diff --git a/pkg/go-nfs/example/osnfs/changeos.go b/pkg/go-nfs/example/osnfs/changeos.go new file mode 100644 index 0000000..38ca4d3 --- /dev/null +++ b/pkg/go-nfs/example/osnfs/changeos.go @@ -0,0 +1,38 @@ +package main + +import ( + "os" + "time" + + "github.com/go-git/go-billy/v5" +) + +// NewChangeOSFS wraps billy osfs to add the change interface +func NewChangeOSFS(fs billy.Filesystem) billy.Filesystem { + return COS{fs} +} + +// COS or OSFS + Change wraps a billy.FS to not fail the `Change` interface. +type COS struct { + billy.Filesystem +} + +// Chmod changes mode +func (fs COS) Chmod(name string, mode os.FileMode) error { + return os.Chmod(fs.Join(fs.Root(), name), mode) +} + +// Lchown changes ownership +func (fs COS) Lchown(name string, uid, gid int) error { + return os.Lchown(fs.Join(fs.Root(), name), uid, gid) +} + +// Chown changes ownership +func (fs COS) Chown(name string, uid, gid int) error { + return os.Chown(fs.Join(fs.Root(), name), uid, gid) +} + +// Chtimes changes access time +func (fs COS) Chtimes(name string, atime time.Time, mtime time.Time) error { + return os.Chtimes(fs.Join(fs.Root(), name), atime, mtime) +} diff --git a/pkg/go-nfs/example/osnfs/changeos_unix.go b/pkg/go-nfs/example/osnfs/changeos_unix.go new file mode 100644 index 0000000..2fe93c6 --- /dev/null +++ b/pkg/go-nfs/example/osnfs/changeos_unix.go @@ -0,0 +1,28 @@ +//go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris + +package main + +import ( + "golang.org/x/sys/unix" +) + +func (fs COS) Mknod(path string, mode uint32, major uint32, minor uint32) error { + dev := unix.Mkdev(major, minor) + return unix.Mknod(fs.Join(fs.Root(), path), mode, int(dev)) +} + +func (fs COS) Mkfifo(path string, mode uint32) error { + return unix.Mkfifo(fs.Join(fs.Root(), path), mode) +} + +func (fs COS) Link(path string, link string) error { + return unix.Link(fs.Join(fs.Root(), path), link) +} + +func (fs COS) Socket(path string) error { + fd, err := unix.Socket(unix.AF_UNIX, unix.SOCK_STREAM, 0) + if err != nil { + return err + } + return unix.Bind(fd, &unix.SockaddrUnix{Name: fs.Join(fs.Root(), path)}) +} diff --git a/pkg/go-nfs/example/osnfs/main.go b/pkg/go-nfs/example/osnfs/main.go new file mode 100644 index 0000000..feadccc --- /dev/null +++ b/pkg/go-nfs/example/osnfs/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "fmt" + "net" + "os" + + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" + nfshelper "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" + osfs "github.com/go-git/go-billy/v5/osfs" +) + +func main() { + port := "" + if len(os.Args) < 2 { + fmt.Printf("Usage: osnfs [port]\n") + return + } else if len(os.Args) == 3 { + port = os.Args[2] + } + + listener, err := net.Listen("tcp", ":"+port) + if err != nil { + fmt.Printf("Failed to listen: %v\n", err) + return + } + fmt.Printf("osnfs server running at %s\n", listener.Addr()) + + bfs := osfs.New(os.Args[1]) + bfsPlusChange := helpers.WrapBillyFS(NewChangeOSFS(bfs)) + + handler := nfshelper.NewNullAuthHandler(bfsPlusChange) + cacheHelper := nfshelper.NewCachingHandler(handler, 1024) + fmt.Printf("%v", nfs.Serve(listener, cacheHelper)) +} diff --git a/pkg/go-nfs/example/osview/main.go b/pkg/go-nfs/example/osview/main.go new file mode 100644 index 0000000..355df17 --- /dev/null +++ b/pkg/go-nfs/example/osview/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "fmt" + "net" + "os" + + "github.com/willscott/memphis" + + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" + nfshelper "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" +) + +func main() { + port := "" + if len(os.Args) < 2 { + fmt.Printf("Usage: osview [port]\n") + return + } else if len(os.Args) == 3 { + port = os.Args[2] + } + + listener, err := net.Listen("tcp", ":"+port) + if err != nil { + fmt.Printf("Failed to listen: %v\n", err) + return + } + fmt.Printf("Server running at %s\n", listener.Addr()) + + fs := memphis.FromOS(os.Args[1]) + bfs := helpers.WrapBillyFS(fs.AsBillyFS(0, 0)) + + handler := nfshelper.NewNullAuthHandler(bfs) + cacheHelper := nfshelper.NewCachingHandler(handler, 1024) + fmt.Printf("%v", nfs.Serve(listener, cacheHelper)) +} diff --git a/pkg/go-nfs/file.go b/pkg/go-nfs/file.go new file mode 100644 index 0000000..2a108b1 --- /dev/null +++ b/pkg/go-nfs/file.go @@ -0,0 +1,377 @@ +package nfs + +import ( + "context" + "errors" + "hash/fnv" + "io" + "math" + "os" + "time" + + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/file" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +// FileAttribute holds metadata about a filesystem object +type FileAttribute struct { + Type FileType + FileMode uint32 + Nlink uint32 + UID uint32 + GID uint32 + Filesize uint64 + Used uint64 + SpecData [2]uint32 + FSID uint64 + Fileid uint64 + Atime, Mtime, Ctime FileTime +} + +// FileType represents a NFS File Type +type FileType uint32 + +// Enumeration of NFS FileTypes +const ( + FileTypeRegular FileType = iota + 1 + FileTypeDirectory + FileTypeBlock + FileTypeCharacter + FileTypeLink + FileTypeSocket + FileTypeFIFO +) + +func (f FileType) String() string { + switch f { + case FileTypeRegular: + return "Regular" + case FileTypeDirectory: + return "Directory" + case FileTypeBlock: + return "Block Device" + case FileTypeCharacter: + return "Character Device" + case FileTypeLink: + return "Symbolic Link" + case FileTypeSocket: + return "Socket" + case FileTypeFIFO: + return "FIFO" + default: + return "Unknown" + } +} + +// Mode provides the OS interpreted mode of the file attributes +func (f *FileAttribute) Mode() os.FileMode { + return os.FileMode(f.FileMode) +} + +// FileCacheAttribute is the subset of FileAttribute used by +// wcc_attr +type FileCacheAttribute struct { + Filesize uint64 + Mtime, Ctime FileTime +} + +// AsCache provides the wcc view of the file attributes +func (f FileAttribute) AsCache() *FileCacheAttribute { + wcc := FileCacheAttribute{ + Filesize: f.Filesize, + Mtime: f.Mtime, + Ctime: f.Ctime, + } + return &wcc +} + +// ToFileAttribute creates an NFS fattr3 struct from an OS.FileInfo +func ToFileAttribute(info os.FileInfo, filePath string) *FileAttribute { + f := FileAttribute{} + + m := info.Mode() + f.FileMode = uint32(m) + if info.IsDir() { + f.Type = FileTypeDirectory + } else if m&os.ModeSymlink != 0 { + f.Type = FileTypeLink + } else if m&os.ModeCharDevice != 0 { + f.Type = FileTypeCharacter + } else if m&os.ModeDevice != 0 { + f.Type = FileTypeBlock + } else if m&os.ModeSocket != 0 { + f.Type = FileTypeSocket + } else if m&os.ModeNamedPipe != 0 { + f.Type = FileTypeFIFO + } else { + f.Type = FileTypeRegular + } + // The number of hard links to the file. + f.Nlink = 1 + + if a := file.GetInfo(info); a != nil { + f.Nlink = a.Nlink + f.UID = a.UID + f.GID = a.GID + f.SpecData = [2]uint32{a.Major, a.Minor} + f.Fileid = a.Fileid + } else { + hasher := fnv.New64() + _, _ = hasher.Write([]byte(filePath)) + f.Fileid = hasher.Sum64() + } + + f.Filesize = uint64(info.Size()) + f.Used = uint64(info.Size()) + f.Atime = ToNFSTime(info.ModTime()) + f.Mtime = f.Atime + f.Ctime = f.Atime + return &f +} + +// tryStat attempts to create a FileAttribute from a path. +func tryStat(ctx context.Context, fs Filesystem, path []string) *FileAttribute { + fullPath := fs.Join(path...) + attrs, err := fs.Lstat(ctx, fullPath) + if err != nil || attrs == nil { + Log.Errorf("err loading attrs for %s: %v", fs.Join(path...), err) + return nil + } + return ToFileAttribute(attrs, fullPath) +} + +// WriteWcc writes the `wcc_data` representation of an object. +func WriteWcc(writer io.Writer, pre *FileCacheAttribute, post *FileAttribute) error { + if pre == nil { + if err := xdr.Write(writer, uint32(0)); err != nil { + return err + } + } else { + if err := xdr.Write(writer, uint32(1)); err != nil { + return err + } + if err := xdr.Write(writer, *pre); err != nil { + return err + } + } + if post == nil { + if err := xdr.Write(writer, uint32(0)); err != nil { + return err + } + } else { + if err := xdr.Write(writer, uint32(1)); err != nil { + return err + } + if err := xdr.Write(writer, *post); err != nil { + return err + } + } + return nil +} + +// WritePostOpAttrs writes the `post_op_attr` representation of a files attributes +func WritePostOpAttrs(writer io.Writer, post *FileAttribute) error { + if post == nil { + if err := xdr.Write(writer, uint32(0)); err != nil { + return err + } + } else { + if err := xdr.Write(writer, uint32(1)); err != nil { + return err + } + if err := xdr.Write(writer, *post); err != nil { + return err + } + } + return nil +} + +// SetFileAttributes represents a command to update some metadata +// about a file. +type SetFileAttributes struct { + SetMode *uint32 + SetUID *uint32 + SetGID *uint32 + SetSize *uint64 + SetAtime *time.Time + SetMtime *time.Time +} + +// Apply uses a `Change` implementation to set defined attributes on a +// provided file. +func (s *SetFileAttributes) Apply(ctx context.Context, changer Change, fs Filesystem, file string) error { + curOS, err := fs.Lstat(ctx, file) + if errors.Is(err, os.ErrNotExist) { + return &NFSStatusError{NFSStatusNoEnt, os.ErrNotExist} + } else if errors.Is(err, os.ErrPermission) { + return &NFSStatusError{NFSStatusAccess, os.ErrPermission} + } else if err != nil { + return nil + } + curr := ToFileAttribute(curOS, file) + + if s.SetMode != nil { + mode := os.FileMode(*s.SetMode) & os.ModePerm + if mode != curr.Mode().Perm() { + if changer == nil { + return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission} + } + if err := changer.Chmod(ctx, file, mode); err != nil { + if errors.Is(err, os.ErrPermission) { + return &NFSStatusError{NFSStatusAccess, os.ErrPermission} + } + return err + } + } + } + if s.SetUID != nil || s.SetGID != nil { + euid := curr.UID + if s.SetUID != nil { + euid = *s.SetUID + } + egid := curr.GID + if s.SetGID != nil { + egid = *s.SetGID + } + if euid != curr.UID || egid != curr.GID { + if changer == nil { + return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission} + } + if err := changer.Lchown(ctx, file, int(euid), int(egid)); err != nil { + if errors.Is(err, os.ErrPermission) { + return &NFSStatusError{NFSStatusAccess, os.ErrPermission} + } + return err + } + } + } + if s.SetSize != nil { + if curr.Mode()&os.ModeSymlink != 0 { + return &NFSStatusError{NFSStatusNotSupp, os.ErrInvalid} + } + fp, err := fs.OpenFile(ctx, file, os.O_WRONLY|os.O_EXCL, 0) + if errors.Is(err, os.ErrPermission) { + return &NFSStatusError{NFSStatusAccess, err} + } else if err != nil { + return err + } + if *s.SetSize > math.MaxInt64 { + return &NFSStatusError{NFSStatusInval, os.ErrInvalid} + } + if err := fp.Truncate(ctx, int64(*s.SetSize)); err != nil { + return err + } + if err := fp.Close(ctx); err != nil { + return err + } + } + + if s.SetAtime != nil || s.SetMtime != nil { + atime := curr.Atime.Native() + if s.SetAtime != nil { + atime = s.SetAtime + } + mtime := curr.Mtime.Native() + if s.SetMtime != nil { + mtime = s.SetMtime + } + if atime != curr.Atime.Native() || mtime != curr.Mtime.Native() { + if changer == nil { + return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission} + } + if err := changer.Chtimes(ctx, file, *atime, *mtime); err != nil { + if errors.Is(err, os.ErrPermission) { + return &NFSStatusError{NFSStatusAccess, err} + } + return err + } + } + } + return nil +} + +// Mode returns a mode if specified or the provided default mode. +func (s *SetFileAttributes) Mode(def os.FileMode) os.FileMode { + if s.SetMode != nil { + return os.FileMode(*s.SetMode) & os.ModePerm + } + return def +} + +// ReadSetFileAttributes reads an sattr3 xdr stream into a go struct. +func ReadSetFileAttributes(r io.Reader) (*SetFileAttributes, error) { + attrs := SetFileAttributes{} + hasMode, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + if hasMode != 0 { + mode, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + attrs.SetMode = &mode + } + hasUID, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + if hasUID != 0 { + uid, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + attrs.SetUID = &uid + } + hasGID, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + if hasGID != 0 { + gid, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + attrs.SetGID = &gid + } + hasSize, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + if hasSize != 0 { + var size uint64 + attrs.SetSize = &size + if err := xdr.Read(r, &size); err != nil { + return nil, err + } + } + aTime, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + if aTime == 1 { + now := time.Now() + attrs.SetAtime = &now + } else if aTime == 2 { + t := FileTime{} + if err := xdr.Read(r, &t); err != nil { + return nil, err + } + attrs.SetAtime = t.Native() + } + mTime, err := xdr.ReadUint32(r) + if err != nil { + return nil, err + } + if mTime == 1 { + now := time.Now() + attrs.SetMtime = &now + } else if mTime == 2 { + t := FileTime{} + if err := xdr.Read(r, &t); err != nil { + return nil, err + } + attrs.SetMtime = t.Native() + } + return &attrs, nil +} diff --git a/pkg/go-nfs/file/file.go b/pkg/go-nfs/file/file.go new file mode 100644 index 0000000..2853868 --- /dev/null +++ b/pkg/go-nfs/file/file.go @@ -0,0 +1,17 @@ +package file + +import "os" + +type FileInfo struct { + Nlink uint32 + UID uint32 + GID uint32 + Major uint32 + Minor uint32 + Fileid uint64 +} + +// GetInfo extracts some non-standardized items from the result of a Stat call. +func GetInfo(fi os.FileInfo) *FileInfo { + return getInfo(fi) +} diff --git a/pkg/go-nfs/file/file_unix.go b/pkg/go-nfs/file/file_unix.go new file mode 100644 index 0000000..6658c20 --- /dev/null +++ b/pkg/go-nfs/file/file_unix.go @@ -0,0 +1,24 @@ +//go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris + +package file + +import ( + "os" + "syscall" + + "golang.org/x/sys/unix" +) + +func getInfo(info os.FileInfo) *FileInfo { + fi := &FileInfo{} + if s, ok := info.Sys().(*syscall.Stat_t); ok { + fi.Nlink = uint32(s.Nlink) + fi.UID = s.Uid + fi.GID = s.Gid + fi.Major = unix.Major(uint64(s.Rdev)) + fi.Minor = unix.Minor(uint64(s.Rdev)) + fi.Fileid = s.Ino + return fi + } + return nil +} diff --git a/pkg/go-nfs/file/file_windows.go b/pkg/go-nfs/file/file_windows.go new file mode 100644 index 0000000..ef173d5 --- /dev/null +++ b/pkg/go-nfs/file/file_windows.go @@ -0,0 +1,12 @@ +//go:build windows + +package file + +import "os" + +func getInfo(info os.FileInfo) *FileInfo { + // https://godoc.org/golang.org/x/sys/windows#GetFileInformationByHandle + // can be potentially used to populate Nlink + + return nil +} diff --git a/pkg/go-nfs/filesystem.go b/pkg/go-nfs/filesystem.go new file mode 100644 index 0000000..3a06cc8 --- /dev/null +++ b/pkg/go-nfs/filesystem.go @@ -0,0 +1,101 @@ +package nfs + +import ( + "context" + "io" + "os" + "time" + + "git.kmsign.ru/royalcat/tstor/pkg/ctxio" +) + +// FSStat returns metadata about a file system +type FSStat struct { + TotalSize uint64 + FreeSize uint64 + AvailableSize uint64 + TotalFiles uint64 + FreeFiles uint64 + AvailableFiles uint64 + // CacheHint is called "invarsec" in the nfs standard + CacheHint time.Duration +} + +type Filesystem interface { + // Create creates the named file with mode 0666 (before umask), truncating + // it if it already exists. If successful, methods on the returned File can + // be used for I/O; the associated file descriptor has mode O_RDWR. + Create(ctx context.Context, filename string) (File, error) + // Open opens the named file for reading. If successful, methods on the + // returned file can be used for reading; the associated file descriptor has + // mode O_RDONLY. + Open(ctx context.Context, filename string) (File, error) + // OpenFile is the generalized open call; most users will use Open or Create + // instead. It opens the named file with specified flag (O_RDONLY etc.) and + // perm, (0666 etc.) if applicable. If successful, methods on the returned + // File can be used for I/O. + OpenFile(ctx context.Context, filename string, flag int, perm os.FileMode) (File, error) + // Stat returns a FileInfo describing the named file. + Stat(ctx context.Context, filename string) (os.FileInfo, error) + // Rename renames (moves) oldpath to newpath. If newpath already exists and + // is not a directory, Rename replaces it. OS-specific restrictions may + // apply when oldpath and newpath are in different directories. + Rename(ctx context.Context, oldpath, newpath string) error + // Remove removes the named file or directory. + Remove(ctx context.Context, filename string) error + // Join joins any number of path elements into a single path, adding a + // Separator if necessary. Join calls filepath.Clean on the result; in + // particular, all empty strings are ignored. On Windows, the result is a + // UNC path if and only if the first path element is a UNC path. + Join(elem ...string) string + + // ReadDir reads the directory named by d(irname and returns a list of + // directory entries sorted by filename. + ReadDir(ctx context.Context, path string) ([]os.FileInfo, error) + // MkdirAll creates a directory named path, along with any necessary + // parents, and returns nil, or else returns an error. The permission bits + // perm are used for all directories that MkdirAll creates. If path is/ + // already a directory, MkdirAll does nothing and returns nil. + MkdirAll(ctx context.Context, filename string, perm os.FileMode) error + + // Lstat returns a FileInfo describing the named file. If the file is a + // symbolic link, the returned FileInfo describes the symbolic link. Lstat + // makes no attempt to follow the link. + Lstat(ctx context.Context, filename string) (os.FileInfo, error) + // Symlink creates a symbolic-link from link to target. target may be an + // absolute or relative path, and need not refer to an existing node. + // Parent directories of link are created as necessary. + Symlink(ctx context.Context, target, link string) error + // Readlink returns the target path of link. + Readlink(ctx context.Context, link string) (string, error) +} + +type File interface { + // Name returns the name of the file as presented to Open. + Name() string + ctxio.Writer + ctxio.Reader + ctxio.ReaderAt + io.Seeker + ctxio.Closer + + // Truncate the file. + Truncate(ctx context.Context, size int64) error +} + +// Change abstract the FileInfo change related operations in a storage-agnostic +// interface as an extension to the Basic interface +type Change interface { + // Chmod changes the mode of the named file to mode. If the file is a + // symbolic link, it changes the mode of the link's target. + Chmod(ctx context.Context, name string, mode os.FileMode) error + // Lchown changes the numeric uid and gid of the named file. If the file is + // a symbolic link, it changes the uid and gid of the link itself. + Lchown(ctx context.Context, name string, uid, gid int) error + // Chtimes changes the access and modification times of the named file, + // similar to the Unix utime() or utimes() functions. + // + // The underlying filesystem may truncate or round the values to a less + // precise time unit. + Chtimes(ctx context.Context, name string, atime time.Time, mtime time.Time) error +} diff --git a/pkg/go-nfs/handler.go b/pkg/go-nfs/handler.go new file mode 100644 index 0000000..cac4d72 --- /dev/null +++ b/pkg/go-nfs/handler.go @@ -0,0 +1,52 @@ +package nfs + +import ( + "context" + "io/fs" + "net" + + "git.kmsign.ru/royalcat/tstor/pkg/ctxbilly" +) + +// Handler represents the interface of the file system / vfs being exposed over NFS +type Handler interface { + // Required methods + + Mount(context.Context, net.Conn, MountRequest) (MountStatus, Filesystem, []AuthFlavor) + + // Change can return 'nil' if filesystem is read-only + // If the returned value can be cast to `UnixChange`, mknod and link RPCs will be available. + Change(Filesystem) Change + + // Optional methods - generic helpers or trivial implementations can be sufficient depending on use case. + + // Fill in information about a file system's free space. + FSStat(context.Context, Filesystem, *FSStat) error + + // represent file objects as opaque references + // Can be safely implemented via helpers/cachinghandler. + ToHandle(fs Filesystem, path []string) []byte + FromHandle(fh []byte) (Filesystem, []string, error) + InvalidateHandle(Filesystem, []byte) error + + // How many handles can be safely maintained by the handler. + HandleLimit() int +} + +// UnixChange extends the billy `Change` interface with support for special files. +type UnixChange interface { + ctxbilly.Change + Mknod(ctx context.Context, path string, mode uint32, major uint32, minor uint32) error + Mkfifo(ctx context.Context, path string, mode uint32) error + Socket(ctx context.Context, path string) error + Link(ctx context.Context, path string, link string) error +} + +// CachingHandler represents the optional caching work that a user may wish to over-ride with +// their own implementations, but which can be otherwise provided through defaults. +type CachingHandler interface { + VerifierFor(path string, contents []fs.FileInfo) uint64 + + // fs.FileInfo needs to be sorted by Name(), nil in case of a cache-miss + DataForVerifier(path string, verifier uint64) []fs.FileInfo +} diff --git a/pkg/go-nfs/helpers/billlyfs.go b/pkg/go-nfs/helpers/billlyfs.go new file mode 100644 index 0000000..3dd3c2c --- /dev/null +++ b/pkg/go-nfs/helpers/billlyfs.go @@ -0,0 +1,157 @@ +package helpers + +import ( + "context" + "io/fs" + + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "github.com/go-git/go-billy/v5" +) + +func WrapBillyFS(bf billy.Filesystem) nfs.Filesystem { + return &wrapFS{ + Filesystem: bf, + } +} + +type wrapFS struct { + billy.Filesystem +} + +var _ nfs.Filesystem = (*wrapFS)(nil) + +// Create implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Create of MemFS.Filesystem. +func (m *wrapFS) Create(ctx context.Context, filename string) (nfs.File, error) { + bf, err := m.Filesystem.Create(filename) + if err != nil { + return nil, err + } + return &wrapFile{bf}, nil +} + +// Lstat implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Lstat of MemFS.Filesystem. +func (m *wrapFS) Lstat(ctx context.Context, filename string) (fs.FileInfo, error) { + return m.Filesystem.Lstat(filename) +} + +// MkdirAll implements Filesystem. +// Subtle: this method shadows the method (Filesystem).MkdirAll of MemFS.Filesystem. +func (m *wrapFS) MkdirAll(ctx context.Context, filename string, perm fs.FileMode) error { + return m.Filesystem.MkdirAll(filename, perm) +} + +// Open implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Open of MemFS.Filesystem. +func (m *wrapFS) Open(ctx context.Context, filename string) (nfs.File, error) { + bf, err := m.Filesystem.Open(filename) + if err != nil { + return nil, err + } + return WrapFile(bf), nil +} + +// OpenFile implements Filesystem. +// Subtle: this method shadows the method (Filesystem).OpenFile of MemFS.Filesystem. +func (m *wrapFS) OpenFile(ctx context.Context, filename string, flag int, perm fs.FileMode) (nfs.File, error) { + bf, err := m.Filesystem.OpenFile(filename, flag, perm) + if err != nil { + return nil, err + } + return WrapFile(bf), nil +} + +// ReadDir implements Filesystem. +// Subtle: this method shadows the method (Filesystem).ReadDir of MemFS.Filesystem. +func (m *wrapFS) ReadDir(ctx context.Context, path string) ([]fs.FileInfo, error) { + return m.Filesystem.ReadDir(path) +} + +// Readlink implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Readlink of MemFS.Filesystem. +func (m *wrapFS) Readlink(ctx context.Context, link string) (string, error) { + return m.Filesystem.Readlink(link) +} + +// Remove implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Remove of MemFS.Filesystem. +func (m *wrapFS) Remove(ctx context.Context, filename string) error { + return m.Filesystem.Remove(filename) +} + +// Rename implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Rename of MemFS.Filesystem. +func (m *wrapFS) Rename(ctx context.Context, oldpath string, newpath string) error { + return m.Filesystem.Rename(oldpath, newpath) +} + +// Stat implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Stat of MemFS.Filesystem. +func (m *wrapFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { + return m.Filesystem.Stat(filename) +} + +// Symlink implements Filesystem. +// Subtle: this method shadows the method (Filesystem).Symlink of MemFS.Filesystem. +func (m *wrapFS) Symlink(ctx context.Context, target string, link string) error { + return m.Filesystem.Symlink(target, link) +} + +func WrapFile(bf billy.File) nfs.File { + return &wrapFile{File: bf} +} + +type wrapFile struct { + billy.File +} + +var _ nfs.File = (*wrapFile)(nil) + +// Close implements File. +// Subtle: this method shadows the method (File).Close of MemFile.File. +func (m *wrapFile) Close(ctx context.Context) error { + return m.File.Close() +} + +// Lock implements File. +// Subtle: this method shadows the method (File).Lock of MemFile.File. +func (m *wrapFile) Lock() error { + return m.File.Lock() +} + +// Name implements File. +// Subtle: this method shadows the method (File).Name of MemFile.File. +func (m *wrapFile) Name() string { + return m.File.Name() +} + +// Truncate implements File. +// Subtle: this method shadows the method (File).Truncate of memFile.File. +func (m *wrapFile) Truncate(ctx context.Context, size int64) error { + return m.File.Truncate(size) +} + +// Read implements File. +// Subtle: this method shadows the method (File).Read of MemFile.File. +func (m *wrapFile) Read(ctx context.Context, p []byte) (n int, err error) { + return m.File.Read(p) +} + +// ReadAt implements File. +// Subtle: this method shadows the method (File).ReadAt of MemFile.File. +func (m *wrapFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + return m.File.ReadAt(p, off) +} + +// Unlock implements File. +// Subtle: this method shadows the method (File).Unlock of MemFile.File. +func (m *wrapFile) Unlock() error { + return m.File.Unlock() +} + +// Write implements File. +// Subtle: this method shadows the method (File).Write of MemFile.File. +func (m *wrapFile) Write(ctx context.Context, p []byte) (n int, err error) { + return m.File.Write(p) +} diff --git a/src/export/nfs/cache.go b/pkg/go-nfs/helpers/cachinghandler.go similarity index 90% rename from src/export/nfs/cache.go rename to pkg/go-nfs/helpers/cachinghandler.go index 926bbda..06d5934 100644 --- a/src/export/nfs/cache.go +++ b/pkg/go-nfs/helpers/cachinghandler.go @@ -1,15 +1,13 @@ -package nfs +package helpers import ( "crypto/sha256" "encoding/binary" "io/fs" "reflect" - "slices" - "github.com/willscott/go-nfs" + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" - "github.com/go-git/go-billy/v5" "github.com/google/uuid" lru "github.com/hashicorp/golang-lru/v2" ) @@ -46,14 +44,14 @@ type CachingHandler struct { } type entry struct { - f billy.Filesystem + f nfs.Filesystem p []string } // ToHandle takes a file and represents it with an opaque handle to reference it. // In stateless nfs (when it's serving a unix fs) this can be the device + inode // but we can generalize with a stateful local cache of handed out IDs. -func (c *CachingHandler) ToHandle(f billy.Filesystem, path []string) []byte { +func (c *CachingHandler) ToHandle(f nfs.Filesystem, path []string) []byte { joinedPath := f.Join(path...) if handle := c.searchReverseCache(f, joinedPath); handle != nil { @@ -81,7 +79,7 @@ func (c *CachingHandler) ToHandle(f billy.Filesystem, path []string) []byte { } // FromHandle converts from an opaque handle to the file it represents -func (c *CachingHandler) FromHandle(fh []byte) (billy.Filesystem, []string, error) { +func (c *CachingHandler) FromHandle(fh []byte) (nfs.Filesystem, []string, error) { id, err := uuid.FromBytes(fh) if err != nil { return nil, []string{}, err @@ -94,13 +92,16 @@ func (c *CachingHandler) FromHandle(fh []byte) (billy.Filesystem, []string, erro _, _ = c.activeHandles.Get(k) } } - - return f.f, slices.Clone(f.p), nil + if ok { + newP := make([]string, len(f.p)) + copy(newP, f.p) + return f.f, newP, nil + } } return nil, []string{}, &nfs.NFSStatusError{NFSStatus: nfs.NFSStatusStale} } -func (c *CachingHandler) searchReverseCache(f billy.Filesystem, path string) []byte { +func (c *CachingHandler) searchReverseCache(f nfs.Filesystem, path string) []byte { uuids, exists := c.reverseHandles[path] if !exists { @@ -133,7 +134,7 @@ func (c *CachingHandler) evictReverseCache(path string, handle uuid.UUID) { } } -func (c *CachingHandler) InvalidateHandle(fs billy.Filesystem, handle []byte) error { +func (c *CachingHandler) InvalidateHandle(fs nfs.Filesystem, handle []byte) error { //Remove from cache id, _ := uuid.FromBytes(handle) entry, ok := c.activeHandles.Get(id) diff --git a/pkg/go-nfs/helpers/memfs/memfs.go b/pkg/go-nfs/helpers/memfs/memfs.go new file mode 100644 index 0000000..5e1822a --- /dev/null +++ b/pkg/go-nfs/helpers/memfs/memfs.go @@ -0,0 +1,414 @@ +// Package memfs is a variant of "github.com/go-git/go-billy/v5/memfs" with +// stable mtimes for items. +package memfs + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/helper/chroot" + "github.com/go-git/go-billy/v5/util" +) + +const separator = filepath.Separator + +// Memory a very convenient filesystem based on memory files +type Memory struct { + s *storage +} + +// New returns a new Memory filesystem. +func New() billy.Filesystem { + fs := &Memory{s: newStorage()} + return chroot.New(fs, string(separator)) +} + +func (fs *Memory) Create(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666) +} + +func (fs *Memory) Open(filename string) (billy.File, error) { + return fs.OpenFile(filename, os.O_RDONLY, 0) +} + +func (fs *Memory) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { + f, has := fs.s.Get(filename) + if !has { + if !isCreate(flag) { + return nil, os.ErrNotExist + } + + var err error + f, err = fs.s.New(filename, perm, flag) + if err != nil { + return nil, err + } + } else { + if isExclusive(flag) { + return nil, os.ErrExist + } + + if target, isLink := fs.resolveLink(filename, f); isLink { + return fs.OpenFile(target, flag, perm) + } + } + + if f.mode.IsDir() { + return nil, fmt.Errorf("cannot open directory: %s", filename) + } + + return f.Duplicate(filename, perm, flag), nil +} + +func (fs *Memory) resolveLink(fullpath string, f *file) (target string, isLink bool) { + if !isSymlink(f.mode) { + return fullpath, false + } + + target = string(f.content.bytes) + if !isAbs(target) { + target = fs.Join(filepath.Dir(fullpath), target) + } + + return target, true +} + +// On Windows OS, IsAbs validates if a path is valid based on if stars with a +// unit (eg.: `C:\`) to assert that is absolute, but in this mem implementation +// any path starting by `separator` is also considered absolute. +func isAbs(path string) bool { + return filepath.IsAbs(path) || strings.HasPrefix(path, string(separator)) +} + +func (fs *Memory) Stat(filename string) (os.FileInfo, error) { + f, has := fs.s.Get(filename) + if !has { + return nil, os.ErrNotExist + } + + fi, _ := f.Stat() + + var err error + if target, isLink := fs.resolveLink(filename, f); isLink { + fi, err = fs.Stat(target) + if err != nil { + return nil, err + } + } + + // the name of the file should always the name of the stated file, so we + // overwrite the Stat returned from the storage with it, since the + // filename may belong to a link. + fi.(*fileInfo).name = filepath.Base(filename) + return fi, nil +} + +func (fs *Memory) Lstat(filename string) (os.FileInfo, error) { + f, has := fs.s.Get(filename) + if !has { + return nil, os.ErrNotExist + } + + return f.Stat() +} + +type ByName []os.FileInfo + +func (a ByName) Len() int { return len(a) } +func (a ByName) Less(i, j int) bool { return a[i].Name() < a[j].Name() } +func (a ByName) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (fs *Memory) ReadDir(path string) ([]os.FileInfo, error) { + if f, has := fs.s.Get(path); has { + if target, isLink := fs.resolveLink(path, f); isLink { + return fs.ReadDir(target) + } + } else { + return nil, &os.PathError{Op: "open", Path: path, Err: syscall.ENOENT} + } + + var entries []os.FileInfo + for _, f := range fs.s.Children(path) { + fi, _ := f.Stat() + entries = append(entries, fi) + } + + sort.Sort(ByName(entries)) + + return entries, nil +} + +func (fs *Memory) MkdirAll(path string, perm os.FileMode) error { + _, err := fs.s.New(path, perm|os.ModeDir, 0) + return err +} + +func (fs *Memory) TempFile(dir, prefix string) (billy.File, error) { + return util.TempFile(fs, dir, prefix) +} + +func (fs *Memory) Rename(from, to string) error { + return fs.s.Rename(from, to) +} + +func (fs *Memory) Remove(filename string) error { + return fs.s.Remove(filename) +} + +func (fs *Memory) Join(elem ...string) string { + return filepath.Join(elem...) +} + +func (fs *Memory) Symlink(target, link string) error { + _, err := fs.Stat(link) + if err == nil { + return os.ErrExist + } + + if !os.IsNotExist(err) { + return err + } + + return util.WriteFile(fs, link, []byte(target), 0777|os.ModeSymlink) +} + +func (fs *Memory) Readlink(link string) (string, error) { + f, has := fs.s.Get(link) + if !has { + return "", os.ErrNotExist + } + + if !isSymlink(f.mode) { + return "", &os.PathError{ + Op: "readlink", + Path: link, + Err: fmt.Errorf("not a symlink"), + } + } + + return string(f.content.bytes), nil +} + +// Capabilities implements the Capable interface. +func (fs *Memory) Capabilities() billy.Capability { + return billy.WriteCapability | + billy.ReadCapability | + billy.ReadAndWriteCapability | + billy.SeekCapability | + billy.TruncateCapability +} + +type file struct { + name string + content *content + position int64 + flag int + mode os.FileMode + mtime time.Time + + isClosed bool +} + +func (f *file) Name() string { + return f.name +} + +func (f *file) Read(b []byte) (int, error) { + n, err := f.ReadAt(b, f.position) + f.position += int64(n) + + if err == io.EOF && n != 0 { + err = nil + } + + return n, err +} + +func (f *file) ReadAt(b []byte, off int64) (int, error) { + if f.isClosed { + return 0, os.ErrClosed + } + + if !isReadAndWrite(f.flag) && !isReadOnly(f.flag) { + return 0, errors.New("read not supported") + } + + n, err := f.content.ReadAt(b, off) + + return n, err +} + +func (f *file) Seek(offset int64, whence int) (int64, error) { + if f.isClosed { + return 0, os.ErrClosed + } + + switch whence { + case io.SeekCurrent: + f.position += offset + case io.SeekStart: + f.position = offset + case io.SeekEnd: + f.position = int64(f.content.Len()) + offset + } + + return f.position, nil +} + +func (f *file) Write(p []byte) (int, error) { + return f.WriteAt(p, f.position) +} + +func (f *file) WriteAt(p []byte, off int64) (int, error) { + if f.isClosed { + return 0, os.ErrClosed + } + + if !isReadAndWrite(f.flag) && !isWriteOnly(f.flag) { + return 0, errors.New("write not supported") + } + + n, err := f.content.WriteAt(p, off) + f.position = off + int64(n) + f.mtime = time.Now() + + return n, err +} + +func (f *file) Close() error { + if f.isClosed { + return os.ErrClosed + } + + f.isClosed = true + return nil +} + +func (f *file) Truncate(size int64) error { + if size < int64(len(f.content.bytes)) { + f.content.bytes = f.content.bytes[:size] + } else if more := int(size) - len(f.content.bytes); more > 0 { + f.content.bytes = append(f.content.bytes, make([]byte, more)...) + } + f.mtime = time.Now() + + return nil +} + +func (f *file) Duplicate(filename string, mode os.FileMode, flag int) billy.File { + new := &file{ + name: filename, + content: f.content, + mode: mode, + flag: flag, + mtime: time.Now(), + } + + if isTruncate(flag) { + new.content.Truncate() + } + + if isAppend(flag) { + new.position = int64(new.content.Len()) + } + + return new +} + +func (f *file) Stat() (os.FileInfo, error) { + return &fileInfo{ + name: f.Name(), + mode: f.mode, + size: f.content.Len(), + mtime: f.mtime, + }, nil +} + +// Lock is a no-op in memfs. +func (f *file) Lock() error { + return nil +} + +// Unlock is a no-op in memfs. +func (f *file) Unlock() error { + return nil +} + +type fileInfo struct { + name string + size int + mode os.FileMode + mtime time.Time +} + +func (fi *fileInfo) Name() string { + return fi.name +} + +func (fi *fileInfo) Size() int64 { + return int64(fi.size) +} + +func (fi *fileInfo) Mode() os.FileMode { + return fi.mode +} + +func (fi *fileInfo) ModTime() time.Time { + return fi.mtime +} + +func (fi *fileInfo) IsDir() bool { + return fi.mode.IsDir() +} + +func (*fileInfo) Sys() interface{} { + return nil +} + +func (c *content) Truncate() { + c.bytes = make([]byte, 0) +} + +func (c *content) Len() int { + return len(c.bytes) +} + +func isCreate(flag int) bool { + return flag&os.O_CREATE != 0 +} + +func isExclusive(flag int) bool { + return flag&os.O_EXCL != 0 +} + +func isAppend(flag int) bool { + return flag&os.O_APPEND != 0 +} + +func isTruncate(flag int) bool { + return flag&os.O_TRUNC != 0 +} + +func isReadAndWrite(flag int) bool { + return flag&os.O_RDWR != 0 +} + +func isReadOnly(flag int) bool { + return flag == os.O_RDONLY +} + +func isWriteOnly(flag int) bool { + return flag&os.O_WRONLY != 0 +} + +func isSymlink(m os.FileMode) bool { + return m&os.ModeSymlink != 0 +} diff --git a/pkg/go-nfs/helpers/memfs/storage.go b/pkg/go-nfs/helpers/memfs/storage.go new file mode 100644 index 0000000..5d73331 --- /dev/null +++ b/pkg/go-nfs/helpers/memfs/storage.go @@ -0,0 +1,243 @@ +package memfs + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type storage struct { + files map[string]*file + children map[string]map[string]*file +} + +func newStorage() *storage { + return &storage{ + files: make(map[string]*file, 0), + children: make(map[string]map[string]*file, 0), + } +} + +func (s *storage) Has(path string) bool { + path = clean(path) + + _, ok := s.files[path] + return ok +} + +func (s *storage) New(path string, mode os.FileMode, flag int) (*file, error) { + path = clean(path) + if s.Has(path) { + if !s.MustGet(path).mode.IsDir() { + return nil, fmt.Errorf("file already exists %q", path) + } + + return nil, nil + } + + name := filepath.Base(path) + + f := &file{ + name: name, + content: &content{name: name}, + mode: mode, + flag: flag, + mtime: time.Now(), + } + + s.files[path] = f + if err := s.createParent(path, mode, f); err != nil { + return nil, err + } + return f, nil +} + +func (s *storage) createParent(path string, mode os.FileMode, f *file) error { + base := filepath.Dir(path) + base = clean(base) + if f.Name() == string(separator) { + return nil + } + + if _, err := s.New(base, mode.Perm()|os.ModeDir, 0); err != nil { + return err + } + + if _, ok := s.children[base]; !ok { + s.children[base] = make(map[string]*file, 0) + } + + s.children[base][f.Name()] = f + return nil +} + +func (s *storage) Children(path string) []*file { + path = clean(path) + + l := make([]*file, 0) + for _, f := range s.children[path] { + l = append(l, f) + } + + return l +} + +func (s *storage) MustGet(path string) *file { + f, ok := s.Get(path) + if !ok { + panic(fmt.Errorf("couldn't find %q", path)) + } + + return f +} + +func (s *storage) Get(path string) (*file, bool) { + path = clean(path) + if !s.Has(path) { + return nil, false + } + + file, ok := s.files[path] + return file, ok +} + +func (s *storage) Rename(from, to string) error { + from = clean(from) + to = clean(to) + + if !s.Has(from) { + return os.ErrNotExist + } + + move := [][2]string{{from, to}} + + for pathFrom := range s.files { + if pathFrom == from || !strings.HasPrefix(pathFrom, from) { + continue + } + + rel, _ := filepath.Rel(from, pathFrom) + pathTo := filepath.Join(to, rel) + + move = append(move, [2]string{pathFrom, pathTo}) + } + + for _, ops := range move { + from := ops[0] + to := ops[1] + + if err := s.move(from, to); err != nil { + return err + } + } + + return nil +} + +func (s *storage) move(from, to string) error { + s.files[to] = s.files[from] + s.files[to].name = filepath.Base(to) + s.children[to] = s.children[from] + + defer func() { + delete(s.children, from) + delete(s.files, from) + delete(s.children[filepath.Dir(from)], filepath.Base(from)) + }() + + return s.createParent(to, 0644, s.files[to]) +} + +func (s *storage) Remove(path string) error { + path = clean(path) + + f, has := s.Get(path) + if !has { + return os.ErrNotExist + } + + if f.mode.IsDir() && len(s.children[path]) != 0 { + return fmt.Errorf("dir: %s contains files", path) + } + + base, file := filepath.Split(path) + base = filepath.Clean(base) + + delete(s.children[base], file) + delete(s.files, path) + return nil +} + +func clean(path string) string { + return filepath.Clean(filepath.FromSlash(path)) +} + +type content struct { + name string + bytes []byte + + m sync.RWMutex +} + +func (c *content) WriteAt(p []byte, off int64) (int, error) { + if off < 0 { + return 0, &os.PathError{ + Op: "writeat", + Path: c.name, + Err: errors.New("negative offset"), + } + } + + c.m.Lock() + prev := len(c.bytes) + + diff := int(off) - prev + if diff > 0 { + c.bytes = append(c.bytes, make([]byte, diff)...) + } + + c.bytes = append(c.bytes[:off], p...) + if len(c.bytes) < prev { + c.bytes = c.bytes[:prev] + } + c.m.Unlock() + + return len(p), nil +} + +func (c *content) ReadAt(b []byte, off int64) (n int, err error) { + if off < 0 { + return 0, &os.PathError{ + Op: "readat", + Path: c.name, + Err: errors.New("negative offset"), + } + } + + c.m.RLock() + size := int64(len(c.bytes)) + if off >= size { + c.m.RUnlock() + return 0, io.EOF + } + + l := int64(len(b)) + if off+l > size { + l = size - off + } + + btr := c.bytes[off : off+l] + n = copy(b, btr) + + if len(btr) < len(b) { + err = io.EOF + } + c.m.RUnlock() + + return +} diff --git a/pkg/go-nfs/helpers/nullauthhandler.go b/pkg/go-nfs/helpers/nullauthhandler.go new file mode 100644 index 0000000..87e4658 --- /dev/null +++ b/pkg/go-nfs/helpers/nullauthhandler.go @@ -0,0 +1,59 @@ +package helpers + +import ( + "context" + "net" + + "git.kmsign.ru/royalcat/tstor/pkg/ctxbilly" + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" +) + +// NewNullAuthHandler creates a handler for the provided filesystem +func NewNullAuthHandler(fs nfs.Filesystem) nfs.Handler { + return &NullAuthHandler{fs} +} + +// NullAuthHandler returns a NFS backing that exposes a given file system in response to all mount requests. +type NullAuthHandler struct { + fs nfs.Filesystem +} + +// Mount backs Mount RPC Requests, allowing for access control policies. +func (h *NullAuthHandler) Mount(ctx context.Context, conn net.Conn, req nfs.MountRequest) (status nfs.MountStatus, hndl nfs.Filesystem, auths []nfs.AuthFlavor) { + status = nfs.MountStatusOk + hndl = h.fs + auths = []nfs.AuthFlavor{nfs.AuthFlavorNull} + return +} + +// Change provides an interface for updating file attributes. +func (h *NullAuthHandler) Change(fs nfs.Filesystem) nfs.Change { + if c, ok := h.fs.(ctxbilly.Change); ok { + return c + } + return nil +} + +// FSStat provides information about a filesystem. +func (h *NullAuthHandler) FSStat(ctx context.Context, f nfs.Filesystem, s *nfs.FSStat) error { + return nil +} + +// ToHandle handled by CachingHandler +func (h *NullAuthHandler) ToHandle(f nfs.Filesystem, s []string) []byte { + return []byte{} +} + +// FromHandle handled by CachingHandler +func (h *NullAuthHandler) FromHandle([]byte) (nfs.Filesystem, []string, error) { + return nil, []string{}, nil +} + +func (c *NullAuthHandler) InvalidateHandle(nfs.Filesystem, []byte) error { + return nil +} + +// HandleLImit handled by cachingHandler +func (h *NullAuthHandler) HandleLimit() int { + return -1 +} diff --git a/pkg/go-nfs/log.go b/pkg/go-nfs/log.go new file mode 100644 index 0000000..db594d3 --- /dev/null +++ b/pkg/go-nfs/log.go @@ -0,0 +1,216 @@ +package nfs + +import ( + "fmt" + "log" + "os" +) + +var ( + Log Logger = &DefaultLogger{} +) + +type LogLevel int + +const ( + PanicLevel LogLevel = iota + FatalLevel + ErrorLevel + WarnLevel + InfoLevel + DebugLevel + TraceLevel + + panicLevelStr string = "[PANIC] " + fatalLevelStr string = "[FATAL] " + errorLevelStr string = "[ERROR] " + warnLevelStr string = "[WARN] " + infoLevelStr string = "[INFO] " + debugLevelStr string = "[DEBUG] " + traceLevelStr string = "[TRACE] " +) + +type Logger interface { + SetLevel(level LogLevel) + GetLevel() LogLevel + ParseLevel(level string) (LogLevel, error) + + Panic(args ...interface{}) + Fatal(args ...interface{}) + Error(args ...interface{}) + Warn(args ...interface{}) + Info(args ...interface{}) + Debug(args ...interface{}) + Trace(args ...interface{}) + Print(args ...interface{}) + + Panicf(format string, args ...interface{}) + Fatalf(format string, args ...interface{}) + Errorf(format string, args ...interface{}) + Warnf(format string, args ...interface{}) + Infof(format string, args ...interface{}) + Debugf(format string, args ...interface{}) + Tracef(format string, args ...interface{}) + Printf(format string, args ...interface{}) +} + +type DefaultLogger struct { + Level LogLevel +} + +func SetLogger(logger Logger) { + Log = logger +} + +func init() { + if os.Getenv("LOG_LEVEL") != "" { + if level, err := Log.ParseLevel(os.Getenv("LOG_LEVEL")); err == nil { + Log.SetLevel(level) + } + } else { + // set default log level to info + Log.SetLevel(InfoLevel) + } +} + +func (l *DefaultLogger) GetLevel() LogLevel { + return l.Level +} + +func (l *DefaultLogger) SetLevel(level LogLevel) { + l.Level = level +} + +func (l *DefaultLogger) ParseLevel(level string) (LogLevel, error) { + switch level { + case "panic": + return PanicLevel, nil + case "fatal": + return FatalLevel, nil + case "error": + return ErrorLevel, nil + case "warn": + return WarnLevel, nil + case "info": + return InfoLevel, nil + case "debug": + return DebugLevel, nil + case "trace": + return TraceLevel, nil + } + var ll LogLevel + return ll, fmt.Errorf("invalid log level %q", level) +} + +func (l *DefaultLogger) Panic(args ...interface{}) { + if l.Level < PanicLevel { + return + } + args = append([]interface{}{panicLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Panicf(format string, args ...interface{}) { + if l.Level < PanicLevel { + return + } + log.Printf(panicLevelStr+format, args...) +} + +func (l *DefaultLogger) Fatal(args ...interface{}) { + if l.Level < FatalLevel { + return + } + args = append([]interface{}{fatalLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Fatalf(format string, args ...interface{}) { + if l.Level < FatalLevel { + return + } + log.Printf(fatalLevelStr+format, args...) +} + +func (l *DefaultLogger) Error(args ...interface{}) { + if l.Level < ErrorLevel { + return + } + args = append([]interface{}{errorLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Errorf(format string, args ...interface{}) { + if l.Level < ErrorLevel { + return + } + log.Printf(errorLevelStr+format, args...) +} + +func (l *DefaultLogger) Warn(args ...interface{}) { + if l.Level < WarnLevel { + return + } + args = append([]interface{}{warnLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Warnf(format string, args ...interface{}) { + if l.Level < WarnLevel { + return + } + log.Printf(warnLevelStr+format, args...) +} + +func (l *DefaultLogger) Info(args ...interface{}) { + if l.Level < InfoLevel { + return + } + args = append([]interface{}{infoLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Infof(format string, args ...interface{}) { + if l.Level < InfoLevel { + return + } + log.Printf(infoLevelStr+format, args...) +} + +func (l *DefaultLogger) Debug(args ...interface{}) { + if l.Level < DebugLevel { + return + } + args = append([]interface{}{debugLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Debugf(format string, args ...interface{}) { + if l.Level < DebugLevel { + return + } + log.Printf(debugLevelStr+format, args...) +} + +func (l *DefaultLogger) Trace(args ...interface{}) { + if l.Level < TraceLevel { + return + } + args = append([]interface{}{traceLevelStr}, args...) + log.Print(args...) +} + +func (l *DefaultLogger) Tracef(format string, args ...interface{}) { + if l.Level < TraceLevel { + return + } + log.Printf(traceLevelStr+format, args...) +} + +func (l *DefaultLogger) Print(args ...interface{}) { + log.Print(args...) +} + +func (l *DefaultLogger) Printf(format string, args ...interface{}) { + log.Printf(format, args...) +} diff --git a/pkg/go-nfs/mount.go b/pkg/go-nfs/mount.go new file mode 100644 index 0000000..e95d098 --- /dev/null +++ b/pkg/go-nfs/mount.go @@ -0,0 +1,58 @@ +package nfs + +import ( + "bytes" + "context" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +const ( + mountServiceID = 100005 +) + +func init() { + _ = RegisterMessageHandler(mountServiceID, uint32(MountProcNull), onMountNull) + _ = RegisterMessageHandler(mountServiceID, uint32(MountProcMount), onMount) + _ = RegisterMessageHandler(mountServiceID, uint32(MountProcUmnt), onUMount) +} + +func onMountNull(ctx context.Context, w *response, userHandle Handler) error { + return w.writeHeader(ResponseCodeSuccess) +} + +func onMount(ctx context.Context, w *response, userHandle Handler) error { + // TODO: auth check. + dirpath, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return err + } + mountReq := MountRequest{Header: w.req.Header, Dirpath: dirpath} + status, handle, flavors := userHandle.Mount(ctx, w.conn, mountReq) + + if err := w.writeHeader(ResponseCodeSuccess); err != nil { + return err + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(status)); err != nil { + return err + } + + rootHndl := userHandle.ToHandle(handle, []string{}) + + if status == MountStatusOk { + _ = xdr.Write(writer, rootHndl) + _ = xdr.Write(writer, flavors) + } + return w.Write(writer.Bytes()) +} + +func onUMount(ctx context.Context, w *response, userHandle Handler) error { + _, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return err + } + + return w.writeHeader(ResponseCodeSuccess) +} diff --git a/pkg/go-nfs/mountinterface.go b/pkg/go-nfs/mountinterface.go new file mode 100644 index 0000000..1dc39ee --- /dev/null +++ b/pkg/go-nfs/mountinterface.go @@ -0,0 +1,90 @@ +package nfs + +import ( + "github.com/willscott/go-nfs-client/nfs/rpc" +) + +// FHSize is the maximum size of a FileHandle +const FHSize = 64 + +// MNTNameLen is the maximum size of a mount name +const MNTNameLen = 255 + +// MntPathLen is the maximum size of a mount path +const MntPathLen = 1024 + +// FileHandle maps to a fhandle3 +type FileHandle []byte + +// MountStatus defines the response to the Mount Procedure +type MountStatus uint32 + +// MountStatus Codes +const ( + MountStatusOk MountStatus = 0 + MountStatusErrPerm MountStatus = 1 + MountStatusErrNoEnt MountStatus = 2 + MountStatusErrIO MountStatus = 5 + MountStatusErrAcces MountStatus = 13 + MountStatusErrNotDir MountStatus = 20 + MountStatusErrInval MountStatus = 22 + MountStatusErrNameTooLong MountStatus = 63 + MountStatusErrNotSupp MountStatus = 10004 + MountStatusErrServerFault MountStatus = 10006 +) + +// MountProcedure is the valid RPC calls for the mount service. +type MountProcedure uint32 + +// MountProcedure Codes +const ( + MountProcNull MountProcedure = iota + MountProcMount + MountProcDump + MountProcUmnt + MountProcUmntAll + MountProcExport +) + +func (m MountProcedure) String() string { + switch m { + case MountProcNull: + return "Null" + case MountProcMount: + return "Mount" + case MountProcDump: + return "Dump" + case MountProcUmnt: + return "Umnt" + case MountProcUmntAll: + return "UmntAll" + case MountProcExport: + return "Export" + default: + return "Unknown" + } +} + +// AuthFlavor is a form of authentication, per rfc1057 section 7.2 +type AuthFlavor uint32 + +// AuthFlavor Codes +const ( + AuthFlavorNull AuthFlavor = 0 + AuthFlavorUnix AuthFlavor = 1 + AuthFlavorShort AuthFlavor = 2 + AuthFlavorDES AuthFlavor = 3 +) + +// MountRequest contains the format of a client request to open a mount. +type MountRequest struct { + rpc.Header + Dirpath []byte +} + +// MountResponse is the server's response with status `MountStatusOk` +type MountResponse struct { + rpc.Header + FileHandle + AuthFlavors []int +} diff --git a/pkg/go-nfs/nfs.go b/pkg/go-nfs/nfs.go new file mode 100644 index 0000000..bf85e0a --- /dev/null +++ b/pkg/go-nfs/nfs.go @@ -0,0 +1,38 @@ +package nfs + +import ( + "context" +) + +const ( + nfsServiceID = 100003 +) + +func init() { + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureNull), onNull) // 0 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureGetAttr), onGetAttr) // 1 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureSetAttr), onSetAttr) // 2 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureLookup), onLookup) // 3 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureAccess), onAccess) // 4 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureReadlink), onReadLink) // 5 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRead), onRead) // 6 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureWrite), onWrite) // 7 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureCreate), onCreate) // 8 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureMkDir), onMkdir) // 9 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureSymlink), onSymlink) // 10 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureMkNod), onMknod) // 11 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRemove), onRemove) // 12 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRmDir), onRmDir) // 13 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureRename), onRename) // 14 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureLink), onLink) // 15 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureReadDir), onReadDir) // 16 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureReadDirPlus), onReadDirPlus) // 17 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureFSStat), onFSStat) // 18 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureFSInfo), onFSInfo) // 19 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedurePathConf), onPathConf) // 20 + _ = RegisterMessageHandler(nfsServiceID, uint32(NFSProcedureCommit), onCommit) // 21 +} + +func onNull(ctx context.Context, w *response, userHandle Handler) error { + return w.Write([]byte{}) +} diff --git a/pkg/go-nfs/nfs_onaccess.go b/pkg/go-nfs/nfs_onaccess.go new file mode 100644 index 0000000..6674734 --- /dev/null +++ b/pkg/go-nfs/nfs_onaccess.go @@ -0,0 +1,45 @@ +package nfs + +import ( + "bytes" + "context" + + billy "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onAccess(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = opAttrErrorFormatter + roothandle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(roothandle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + mask, err := xdr.ReadUint32(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + mask = mask & (1 | 2 | 0x20) + } + + if err := xdr.Write(writer, mask); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_oncommit.go b/pkg/go-nfs/nfs_oncommit.go new file mode 100644 index 0000000..e2616d6 --- /dev/null +++ b/pkg/go-nfs/nfs_oncommit.go @@ -0,0 +1,51 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + billy "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +// onCommit - note this is a no-op, as we always push writes to the backing store. +func onCommit(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + handle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + // The conn will drain the unread offset and count arguments. + + fs, path, err := userHandle.FromHandle(handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusServerFault, os.ErrPermission} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return err + } + + // no pre-op cache data. + if err := xdr.Write(writer, uint32(0)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + // write the 8 bytes of write verification. + if err := xdr.Write(writer, w.Server.ID); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_oncreate.go b/pkg/go-nfs/nfs_oncreate.go new file mode 100644 index 0000000..3a181d0 --- /dev/null +++ b/pkg/go-nfs/nfs_oncreate.go @@ -0,0 +1,125 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + billy "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +const ( + createModeUnchecked = 0 + createModeGuarded = 1 + createModeExclusive = 2 +) + +func onCreate(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + obj := DirOpArg{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + how, err := xdr.ReadUint32(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + var attrs *SetFileAttributes + if how == createModeUnchecked || how == createModeGuarded { + sattr, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + attrs = sattr + } else if how == createModeExclusive { + // read createverf3 + var verf [8]byte + if err := xdr.Read(w.req.Body, &verf); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + Log.Errorf("failing create to indicate lack of support for 'exclusive' mode.") + // TODO: support 'exclusive' mode. + return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission} + } else { + // invalid + return &NFSStatusError{NFSStatusNotSupp, os.ErrInvalid} + } + + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + if len(string(obj.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, nil} + } + + newFile := append(path, string(obj.Filename)) + newFilePath := fs.Join(newFile...) + if s, err := fs.Stat(ctx, newFilePath); err == nil { + if s.IsDir() { + return &NFSStatusError{NFSStatusExist, nil} + } + if how == createModeGuarded { + return &NFSStatusError{NFSStatusExist, os.ErrPermission} + } + } else { + if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } else if !s.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + } + + file, err := fs.Create(ctx, newFilePath) + if err != nil { + Log.Errorf("Error Creating: %v", err) + return &NFSStatusError{NFSStatusAccess, err} + } + if err := file.Close(ctx); err != nil { + Log.Errorf("Error Creating: %v", err) + return &NFSStatusError{NFSStatusAccess, err} + } + + fp := userHandle.ToHandle(fs, newFile) + changer := userHandle.Change(fs) + if err := attrs.Apply(ctx, changer, fs, newFilePath); err != nil { + Log.Errorf("Error applying attributes: %v\n", err) + return &NFSStatusError{NFSStatusIO, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + // "handle follows" + if err := xdr.Write(writer, uint32(1)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, fp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, []string{file.Name()})); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + // dir_wcc (we don't include pre_op_attr) + if err := xdr.Write(writer, uint32(0)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onfsinfo.go b/pkg/go-nfs/nfs_onfsinfo.go new file mode 100644 index 0000000..152e366 --- /dev/null +++ b/pkg/go-nfs/nfs_onfsinfo.go @@ -0,0 +1,89 @@ +package nfs + +import ( + "bytes" + "context" + + "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +const ( + // FSInfoPropertyLink does the FS support hard links? + FSInfoPropertyLink = 0x0001 + // FSInfoPropertySymlink does the FS support soft links? + FSInfoPropertySymlink = 0x0002 + // FSInfoPropertyHomogeneous does the FS need PATHCONF calls for each file + FSInfoPropertyHomogeneous = 0x0008 + // FSInfoPropertyCanSetTime can the FS support setting access/mod times? + FSInfoPropertyCanSetTime = 0x0010 +) + +func onFSInfo(ctx context.Context, w *response, userHandle Handler) error { + roothandle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(roothandle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + type fsinfores struct { + Rtmax uint32 + Rtpref uint32 + Rtmult uint32 + Wtmax uint32 + Wtpref uint32 + Wtmult uint32 + Dtpref uint32 + Maxfilesize uint64 + TimeDelta uint64 + Properties uint32 + } + + res := fsinfores{ + Rtmax: 1 << 30, + Rtpref: 1 << 30, + Rtmult: 4096, + Wtmax: 1 << 30, + Wtpref: 1 << 30, + Wtmult: 4096, + Dtpref: 8192, + Maxfilesize: 1 << 62, // wild guess. this seems big. + TimeDelta: 1, // nanosecond precision. + Properties: 0, + } + + // TODO: these aren't great indications of support, really. + // if _, ok := fs.(billy.Symlink); ok { + // res.Properties |= FSInfoPropertyLink + // res.Properties |= FSInfoPropertySymlink + // } + // TODO: if the nfs share spans multiple virtual mounts, may need + // to support granular PATHINFO responses. + res.Properties |= FSInfoPropertyHomogeneous + // TODO: not a perfect indicator + + if CapabilityCheck(fs, billy.WriteCapability) { + res.Properties |= FSInfoPropertyCanSetTime + } + + // TODO: this whole struct should be specifiable by the userhandler. + + if err := xdr.Write(writer, res); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onfsstat.go b/pkg/go-nfs/nfs_onfsstat.go new file mode 100644 index 0000000..325a106 --- /dev/null +++ b/pkg/go-nfs/nfs_onfsstat.go @@ -0,0 +1,59 @@ +package nfs + +import ( + "bytes" + "context" + + billy "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onFSStat(ctx context.Context, w *response, userHandle Handler) error { + roothandle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(roothandle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + defaults := FSStat{ + TotalSize: 1 << 62, + FreeSize: 1 << 62, + AvailableSize: 1 << 62, + TotalFiles: 1 << 62, + FreeFiles: 1 << 62, + AvailableFiles: 1 << 62, + CacheHint: 0, + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + defaults.AvailableFiles = 0 + defaults.AvailableSize = 0 + } + + err = userHandle.FSStat(ctx, fs, &defaults) + if err != nil { + if _, ok := err.(*NFSStatusError); ok { + return err + } + return &NFSStatusError{NFSStatusServerFault, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := xdr.Write(writer, defaults); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_ongetattr.go b/pkg/go-nfs/nfs_ongetattr.go new file mode 100644 index 0000000..40c4148 --- /dev/null +++ b/pkg/go-nfs/nfs_ongetattr.go @@ -0,0 +1,48 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "os" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onGetAttr(ctx context.Context, w *response, userHandle Handler) error { + handle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, path, err := userHandle.FromHandle(handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + fullPath := fs.Join(path...) + info, err := fs.Lstat(ctx, fullPath) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusIO, err} + } + attr := ToFileAttribute(info, fullPath) + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, attr); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onlink.go b/pkg/go-nfs/nfs_onlink.go new file mode 100644 index 0000000..460a969 --- /dev/null +++ b/pkg/go-nfs/nfs_onlink.go @@ -0,0 +1,94 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +// Backing billy.FS doesn't support hard links +func onLink(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + obj := DirOpArg{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + target, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + if len(string(obj.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid} + } + + newFilePath := fs.Join(append(path, string(obj.Filename))...) + if _, err := fs.Stat(ctx, newFilePath); err == nil { + return &NFSStatusError{NFSStatusExist, os.ErrExist} + } + if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } else if !s.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + + fp := userHandle.ToHandle(fs, append(path, string(obj.Filename))) + changer := userHandle.Change(fs) + if changer == nil { + return &NFSStatusError{NFSStatusAccess, err} + } + cos, ok := changer.(UnixChange) + if !ok { + return &NFSStatusError{NFSStatusAccess, err} + } + + err = cos.Link(ctx, string(target), newFilePath) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + if err := attrs.Apply(ctx, changer, fs, newFilePath); err != nil { + return &NFSStatusError{NFSStatusIO, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + // "handle follows" + if err := xdr.Write(writer, uint32(1)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, fp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, append(path, string(obj.Filename)))); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onlookup.go b/pkg/go-nfs/nfs_onlookup.go new file mode 100644 index 0000000..6507d03 --- /dev/null +++ b/pkg/go-nfs/nfs_onlookup.go @@ -0,0 +1,86 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func lookupSuccessResponse(ctx context.Context, handle []byte, entPath, dirPath []string, fs Filesystem) ([]byte, error) { + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return nil, err + } + if err := xdr.Write(writer, handle); err != nil { + return nil, err + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, entPath)); err != nil { + return nil, err + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, dirPath)); err != nil { + return nil, err + } + return writer.Bytes(), nil +} + +func onLookup(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = opAttrErrorFormatter + obj := DirOpArg{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, p, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + dirInfo, err := fs.Lstat(ctx, fs.Join(p...)) + if err != nil || !dirInfo.IsDir() { + return &NFSStatusError{NFSStatusNotDir, err} + } + + // Special cases for "." and ".." + if bytes.Equal(obj.Filename, []byte(".")) { + resp, err := lookupSuccessResponse(ctx, obj.Handle, p, p, fs) + if err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(resp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil + } + if bytes.Equal(obj.Filename, []byte("..")) { + if len(p) == 0 { + return &NFSStatusError{NFSStatusAccess, os.ErrPermission} + } + pPath := p[0 : len(p)-1] + pHandle := userHandle.ToHandle(fs, pPath) + resp, err := lookupSuccessResponse(ctx, pHandle, pPath, p, fs) + if err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(resp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil + } + + reqPath := append(p, string(obj.Filename)) + if _, err = fs.Lstat(ctx, fs.Join(reqPath...)); err != nil { + return &NFSStatusError{NFSStatusNoEnt, os.ErrNotExist} + } + + newHandle := userHandle.ToHandle(fs, reqPath) + resp, err := lookupSuccessResponse(ctx, newHandle, reqPath, p, fs) + if err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(resp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onmkdir.go b/pkg/go-nfs/nfs_onmkdir.go new file mode 100644 index 0000000..d96bab1 --- /dev/null +++ b/pkg/go-nfs/nfs_onmkdir.go @@ -0,0 +1,94 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +const ( + mkdirDefaultMode = 755 +) + +func onMkdir(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + obj := DirOpArg{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + if len(string(obj.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid} + } + if string(obj.Filename) == "." || string(obj.Filename) == ".." { + return &NFSStatusError{NFSStatusExist, os.ErrExist} + } + + newFolder := append(path, string(obj.Filename)) + newFolderPath := fs.Join(newFolder...) + if s, err := fs.Stat(ctx, newFolderPath); err == nil { + if s.IsDir() { + return &NFSStatusError{NFSStatusExist, nil} + } + } else { + if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } else if !s.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + } + + if err := fs.MkdirAll(ctx, newFolderPath, attrs.Mode(mkdirDefaultMode)); err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + + fp := userHandle.ToHandle(fs, newFolder) + changer := userHandle.Change(fs) + if changer != nil { + if err := attrs.Apply(ctx, changer, fs, newFolderPath); err != nil { + return &NFSStatusError{NFSStatusIO, err} + } + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + // "handle follows" + if err := xdr.Write(writer, uint32(1)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, fp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, newFolder)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onmknod.go b/pkg/go-nfs/nfs_onmknod.go new file mode 100644 index 0000000..81ca2fa --- /dev/null +++ b/pkg/go-nfs/nfs_onmknod.go @@ -0,0 +1,158 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + billy "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +type nfs_ftype int32 + +const ( + FTYPE_NF3REG nfs_ftype = 1 + FTYPE_NF3DIR nfs_ftype = 2 + FTYPE_NF3BLK nfs_ftype = 3 + FTYPE_NF3CHR nfs_ftype = 4 + FTYPE_NF3LNK nfs_ftype = 5 + FTYPE_NF3SOCK nfs_ftype = 6 + FTYPE_NF3FIFO nfs_ftype = 7 +) + +// Backing billy.FS doesn't support creation of +// char, block, socket, or fifo pipe nodes +func onMknod(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + obj := DirOpArg{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + ftype, err := xdr.ReadUint32(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + // see if the filesystem supports mknod + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + c := userHandle.Change(fs) + if c == nil { + return &NFSStatusError{NFSStatusAccess, os.ErrPermission} + } + cu, ok := c.(UnixChange) + if !ok { + return &NFSStatusError{NFSStatusAccess, os.ErrPermission} + } + + if len(string(obj.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid} + } + + newFilePath := fs.Join(append(path, string(obj.Filename))...) + if _, err := fs.Stat(ctx, newFilePath); err == nil { + return &NFSStatusError{NFSStatusExist, os.ErrExist} + } + parent, err := fs.Stat(ctx, fs.Join(path...)) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } else if !parent.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + fp := userHandle.ToHandle(fs, append(path, string(obj.Filename))) + + switch nfs_ftype(ftype) { + case FTYPE_NF3CHR: + case FTYPE_NF3BLK: + // read devicedata3 = {sattr3, specdata3} + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + specData1, err := xdr.ReadUint32(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + specData2, err := xdr.ReadUint32(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + err = cu.Mknod(ctx, newFilePath, uint32(attrs.Mode(parent.Mode())), specData1, specData2) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + if err = attrs.Apply(ctx, cu, fs, newFilePath); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + case FTYPE_NF3SOCK: + // read sattr3 + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + if err := cu.Socket(ctx, newFilePath); err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + if err = attrs.Apply(ctx, cu, fs, newFilePath); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + case FTYPE_NF3FIFO: + // read sattr3 + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + err = cu.Mkfifo(ctx, newFilePath, uint32(attrs.Mode(parent.Mode()))) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + if err = attrs.Apply(ctx, cu, fs, newFilePath); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + default: + return &NFSStatusError{NFSStatusBadType, os.ErrInvalid} + // end of input. + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + // "handle follows" + if err := xdr.Write(writer, uint32(1)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + // fh3 + if err := xdr.Write(writer, fp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + // attr + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, append(path, string(obj.Filename)))); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + // wcc + if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + return nil +} diff --git a/pkg/go-nfs/nfs_onpathconf.go b/pkg/go-nfs/nfs_onpathconf.go new file mode 100644 index 0000000..1771b60 --- /dev/null +++ b/pkg/go-nfs/nfs_onpathconf.go @@ -0,0 +1,55 @@ +package nfs + +import ( + "bytes" + "context" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +// PathNameMax is the maximum length for a file name +const PathNameMax = 255 + +func onPathConf(ctx context.Context, w *response, userHandle Handler) error { + roothandle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(roothandle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + type PathConf struct { + LinkMax uint32 + NameMax uint32 + NoTrunc uint32 + ChownRestricted uint32 + CaseInsensitive uint32 + CasePreserving uint32 + } + + defaults := PathConf{ + LinkMax: 1, + NameMax: PathNameMax, + NoTrunc: 1, + ChownRestricted: 0, + CaseInsensitive: 0, + CasePreserving: 1, + } + if err := xdr.Write(writer, defaults); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onread.go b/pkg/go-nfs/nfs_onread.go new file mode 100644 index 0000000..8616acd --- /dev/null +++ b/pkg/go-nfs/nfs_onread.go @@ -0,0 +1,97 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "io" + "os" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +type nfsReadArgs struct { + Handle []byte + Offset uint64 + Count uint32 +} + +type nfsReadResponse struct { + Count uint32 + EOF uint32 + Data []byte +} + +// MaxRead is the advertised largest buffer the server is willing to read +const MaxRead = 1 << 24 + +// CheckRead is a size where - if a request to read is larger than this, +// the server will stat the file to learn it's actual size before allocating +// a buffer to read into. +const CheckRead = 1 << 15 + +func onRead(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = opAttrErrorFormatter + var obj nfsReadArgs + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + fh, err := fs.Open(ctx, fs.Join(path...)) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusAccess, err} + } + + resp := nfsReadResponse{} + + if obj.Count > CheckRead { + info, err := fs.Stat(ctx, fs.Join(path...)) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + if info.Size()-int64(obj.Offset) < int64(obj.Count) { + obj.Count = uint32(uint64(info.Size()) - obj.Offset) + } + } + if obj.Count > MaxRead { + obj.Count = MaxRead + } + resp.Data = make([]byte, obj.Count) + // todo: multiple reads if size isn't full + cnt, err := fh.ReadAt(ctx, resp.Data, int64(obj.Offset)) + if err != nil && !errors.Is(err, io.EOF) { + return &NFSStatusError{NFSStatusIO, err} + } + resp.Count = uint32(cnt) + resp.Data = resp.Data[:resp.Count] + if errors.Is(err, io.EOF) { + resp.EOF = 1 + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := xdr.Write(writer, resp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onreaddir.go b/pkg/go-nfs/nfs_onreaddir.go new file mode 100644 index 0000000..2769d02 --- /dev/null +++ b/pkg/go-nfs/nfs_onreaddir.go @@ -0,0 +1,195 @@ +package nfs + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/binary" + "errors" + "io" + "io/fs" + "os" + "path" + "sort" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +type readDirArgs struct { + Handle []byte + Cookie uint64 + CookieVerif uint64 + Count uint32 +} + +type readDirEntity struct { + FileID uint64 + Name []byte + Cookie uint64 + Next bool +} + +func onReadDir(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = opAttrErrorFormatter + obj := readDirArgs{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + if obj.Count < 1024 { + return &NFSStatusError{NFSStatusTooSmall, io.ErrShortBuffer} + } + + fs, p, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + contents, verifier, err := getDirListingWithVerifier(ctx, userHandle, obj.Handle, obj.CookieVerif) + if err != nil { + return err + } + if obj.Cookie > 0 && obj.CookieVerif > 0 && verifier != obj.CookieVerif { + return &NFSStatusError{NFSStatusBadCookie, nil} + } + + entities := make([]readDirEntity, 0) + maxBytes := uint32(100) // conservative overhead measure + + started := obj.Cookie == 0 + if started { + // add '.' and '..' to entities + dotdotFileID := uint64(0) + if len(p) > 0 { + dda := tryStat(ctx, fs, p[0:len(p)-1]) + if dda != nil { + dotdotFileID = dda.Fileid + } + } + dotFileID := uint64(0) + da := tryStat(ctx, fs, p) + if da != nil { + dotFileID = da.Fileid + } + entities = append(entities, + readDirEntity{Name: []byte("."), Cookie: 0, Next: true, FileID: dotFileID}, + readDirEntity{Name: []byte(".."), Cookie: 1, Next: true, FileID: dotdotFileID}, + ) + } + + eof := true + maxEntities := userHandle.HandleLimit() / 2 + for i, c := range contents { + // cookie equates to index within contents + 2 (for '.' and '..') + cookie := uint64(i + 2) + if started { + maxBytes += 512 // TODO: better estimation. + if maxBytes > obj.Count || len(entities) > maxEntities { + eof = false + break + } + + attrs := ToFileAttribute(c, path.Join(append(p, c.Name())...)) + entities = append(entities, readDirEntity{ + FileID: attrs.Fileid, + Name: []byte(c.Name()), + Cookie: cookie, + Next: true, + }) + } else if cookie == obj.Cookie { + started = true + } + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, p)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := xdr.Write(writer, verifier); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := xdr.Write(writer, len(entities) > 0); err != nil { // next + return &NFSStatusError{NFSStatusServerFault, err} + } + if len(entities) > 0 { + entities[len(entities)-1].Next = false + // no next for last entity + + for _, e := range entities { + if err := xdr.Write(writer, e); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + } + } + if err := xdr.Write(writer, eof); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + // TODO: track writer size at this point to validate maxcount estimation and stop early if needed. + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} + +func getDirListingWithVerifier(ctx context.Context, userHandle Handler, fsHandle []byte, verifier uint64) ([]fs.FileInfo, uint64, error) { + // figure out what directory it is. + fs, p, err := userHandle.FromHandle(fsHandle) + if err != nil { + return nil, 0, &NFSStatusError{NFSStatusStale, err} + } + + path := fs.Join(p...) + // see if the verifier has this dir cached: + if vh, ok := userHandle.(CachingHandler); verifier != 0 && ok { + entries := vh.DataForVerifier(path, verifier) + if entries != nil { + return entries, verifier, nil + } + } + // load the entries. + contents, err := fs.ReadDir(ctx, path) + if err != nil { + if os.IsPermission(err) { + return nil, 0, &NFSStatusError{NFSStatusAccess, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return nil, 0, &NFSStatusError{NFSStatusJukebox, err} + } + return nil, 0, &NFSStatusError{NFSStatusIO, err} + } + + sort.Slice(contents, func(i, j int) bool { + return contents[i].Name() < contents[j].Name() + }) + + if vh, ok := userHandle.(CachingHandler); ok { + // let the user handler make a verifier if it can. + v := vh.VerifierFor(path, contents) + return contents, v, nil + } + + id := hashPathAndContents(path, contents) + return contents, id, nil +} + +func hashPathAndContents(path string, contents []fs.FileInfo) uint64 { + //calculate a cookie-verifier. + vHash := sha256.New() + + // Add the path to avoid collisions of directories with the same content + vHash.Write([]byte(path)) + + for _, c := range contents { + vHash.Write([]byte(c.Name())) // Never fails according to the docs + } + + verify := vHash.Sum(nil)[0:8] + return binary.BigEndian.Uint64(verify) +} diff --git a/pkg/go-nfs/nfs_onreaddirplus.go b/pkg/go-nfs/nfs_onreaddirplus.go new file mode 100644 index 0000000..9fa49cb --- /dev/null +++ b/pkg/go-nfs/nfs_onreaddirplus.go @@ -0,0 +1,153 @@ +package nfs + +import ( + "bytes" + "context" + "path" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +type readDirPlusArgs struct { + Handle []byte + Cookie uint64 + CookieVerif uint64 + DirCount uint32 + MaxCount uint32 +} + +type readDirPlusEntity struct { + FileID uint64 + Name []byte + Cookie uint64 + Attributes *FileAttribute `xdr:"optional"` + Handle *[]byte `xdr:"optional"` + Next bool +} + +func joinPath(parent []string, elements ...string) []string { + joinedPath := make([]string, 0, len(parent)+len(elements)) + joinedPath = append(joinedPath, parent...) + joinedPath = append(joinedPath, elements...) + return joinedPath +} + +func onReadDirPlus(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = opAttrErrorFormatter + obj := readDirPlusArgs{} + if err := xdr.Read(w.req.Body, &obj); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + // in case of test, nfs-client send: + // DirCount = 512 + // MaxCount = 4096 + if obj.DirCount < 512 || obj.MaxCount < 4096 { + return &NFSStatusError{NFSStatusTooSmall, nil} + } + + fs, p, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + contents, verifier, err := getDirListingWithVerifier(ctx, userHandle, obj.Handle, obj.CookieVerif) + if err != nil { + return err + } + if obj.Cookie > 0 && obj.CookieVerif > 0 && verifier != obj.CookieVerif { + return &NFSStatusError{NFSStatusBadCookie, nil} + } + + entities := make([]readDirPlusEntity, 0) + dirBytes := uint32(0) + maxBytes := uint32(100) // conservative overhead measure + + started := obj.Cookie == 0 + if started { + // add '.' and '..' to entities + dotdotFileID := uint64(0) + if len(p) > 0 { + dda := tryStat(ctx, fs, p[0:len(p)-1]) + if dda != nil { + dotdotFileID = dda.Fileid + } + } + dotFileID := uint64(0) + da := tryStat(ctx, fs, p) + if da != nil { + dotFileID = da.Fileid + } + entities = append(entities, + readDirPlusEntity{Name: []byte("."), Cookie: 0, Next: true, FileID: dotFileID, Attributes: da}, + readDirPlusEntity{Name: []byte(".."), Cookie: 1, Next: true, FileID: dotdotFileID}, + ) + } + + eof := true + maxEntities := userHandle.HandleLimit() / 2 + fb := 0 + fss := 0 + for i, c := range contents { + // cookie equates to index within contents + 2 (for '.' and '..') + cookie := uint64(i + 2) + fb++ + if started { + fss++ + dirBytes += uint32(len(c.Name()) + 20) + maxBytes += 512 // TODO: better estimation. + if dirBytes > obj.DirCount || maxBytes > obj.MaxCount || len(entities) > maxEntities { + eof = false + break + } + + filePath := joinPath(p, c.Name()) + handle := userHandle.ToHandle(fs, filePath) + attrs := ToFileAttribute(c, path.Join(filePath...)) + entities = append(entities, readDirPlusEntity{ + FileID: attrs.Fileid, + Name: []byte(c.Name()), + Cookie: cookie, + Attributes: attrs, + Handle: &handle, + Next: true, + }) + } else if cookie == obj.Cookie { + started = true + } + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, p)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, verifier); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := xdr.Write(writer, len(entities) > 0); err != nil { // next + return &NFSStatusError{NFSStatusServerFault, err} + } + if len(entities) > 0 { + entities[len(entities)-1].Next = false + // no next for last entity + + for _, e := range entities { + if err := xdr.Write(writer, e); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + } + } + if err := xdr.Write(writer, eof); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + // TODO: track writer size at this point to validate maxcount estimation and stop early if needed. + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onreadlink.go b/pkg/go-nfs/nfs_onreadlink.go new file mode 100644 index 0000000..887047a --- /dev/null +++ b/pkg/go-nfs/nfs_onreadlink.go @@ -0,0 +1,55 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "os" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onReadLink(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = opAttrErrorFormatter + handle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + out, err := fs.Readlink(ctx, fs.Join(path...)) + if err != nil { + if info, err := fs.Stat(ctx, fs.Join(path...)); err == nil { + if info.Mode()&os.ModeSymlink == 0 { + return &NFSStatusError{NFSStatusInval, err} + } + } + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + + return &NFSStatusError{NFSStatusAccess, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := xdr.Write(writer, out); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onremove.go b/pkg/go-nfs/nfs_onremove.go new file mode 100644 index 0000000..bffb446 --- /dev/null +++ b/pkg/go-nfs/nfs_onremove.go @@ -0,0 +1,85 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "os" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onRemove(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + obj := DirOpArg{} + if err := xdr.Read(w.req.Body, &obj); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + // TODO + // if !CapabilityCheck(fs, billy.WriteCapability) { + // return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + // } + + if len(string(obj.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, nil} + } + + fullPath := fs.Join(path...) + dirInfo, err := fs.Stat(ctx, fullPath) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if os.IsPermission(err) { + return &NFSStatusError{NFSStatusAccess, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusIO, err} + } + if !dirInfo.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + preCacheData := ToFileAttribute(dirInfo, fullPath).AsCache() + + toDelete := fs.Join(append(path, string(obj.Filename))...) + toDeleteHandle := userHandle.ToHandle(fs, append(path, string(obj.Filename))) + + err = fs.Remove(ctx, toDelete) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if os.IsPermission(err) { + return &NFSStatusError{NFSStatusAccess, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusIO, err} + } + + if err := userHandle.InvalidateHandle(fs, toDeleteHandle); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := WriteWcc(writer, preCacheData, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onrename.go b/pkg/go-nfs/nfs_onrename.go new file mode 100644 index 0000000..dc0d463 --- /dev/null +++ b/pkg/go-nfs/nfs_onrename.go @@ -0,0 +1,120 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "os" + "reflect" + + "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +var doubleWccErrorBody = [16]byte{} + +func onRename(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = errFormatterWithBody(doubleWccErrorBody[:]) + from := DirOpArg{} + err := xdr.Read(w.req.Body, &from) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs, fromPath, err := userHandle.FromHandle(from.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + + to := DirOpArg{} + if err = xdr.Read(w.req.Body, &to); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + fs2, toPath, err := userHandle.FromHandle(to.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + // check the two fs are the same + if !reflect.DeepEqual(fs, fs2) { + return &NFSStatusError{NFSStatusNotSupp, os.ErrPermission} + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + if len(string(from.Filename)) > PathNameMax || len(string(to.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid} + } + + fromDirPath := fs.Join(fromPath...) + fromDirInfo, err := fs.Stat(ctx, fromDirPath) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusIO, err} + } + if !fromDirInfo.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + preCacheData := ToFileAttribute(fromDirInfo, fromDirPath).AsCache() + + toDirPath := fs.Join(toPath...) + toDirInfo, err := fs.Stat(ctx, toDirPath) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusIO, err} + } + if !toDirInfo.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + preDestData := ToFileAttribute(toDirInfo, toDirPath).AsCache() + + oldHandle := userHandle.ToHandle(fs, append(fromPath, string(from.Filename))) + + fromLoc := fs.Join(append(fromPath, string(from.Filename))...) + toLoc := fs.Join(append(toPath, string(to.Filename))...) + + err = fs.Rename(ctx, fromLoc, toLoc) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if os.IsPermission(err) { + return &NFSStatusError{NFSStatusAccess, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusIO, err} + } + + if err := userHandle.InvalidateHandle(fs, oldHandle); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := WriteWcc(writer, preCacheData, tryStat(ctx, fs, fromPath)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WriteWcc(writer, preDestData, tryStat(ctx, fs, toPath)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onrmdir.go b/pkg/go-nfs/nfs_onrmdir.go new file mode 100644 index 0000000..12e3571 --- /dev/null +++ b/pkg/go-nfs/nfs_onrmdir.go @@ -0,0 +1,9 @@ +package nfs + +import ( + "context" +) + +func onRmDir(ctx context.Context, w *response, userHandle Handler) error { + return onRemove(ctx, w, userHandle) +} diff --git a/pkg/go-nfs/nfs_onsetattr.go b/pkg/go-nfs/nfs_onsetattr.go new file mode 100644 index 0000000..cc2c202 --- /dev/null +++ b/pkg/go-nfs/nfs_onsetattr.go @@ -0,0 +1,80 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "os" + + "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onSetAttr(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + handle, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, path, err := userHandle.FromHandle(handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fullPath := fs.Join(path...) + info, err := fs.Lstat(ctx, fullPath) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusAccess, err} + } + + // see if there's a "guard" + if guard, err := xdr.ReadUint32(w.req.Body); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } else if guard != 0 { + // read the ctime. + t := FileTime{} + if err := xdr.Read(w.req.Body, &t); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + attr := ToFileAttribute(info, fullPath) + if t != attr.Ctime { + return &NFSStatusError{NFSStatusNotSync, nil} + } + } + + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + changer := userHandle.Change(fs) + if err := attrs.Apply(ctx, changer, fs, fs.Join(path...)); err != nil { + // Already an nfsstatuserror + return err + } + + preAttr := ToFileAttribute(info, fullPath).AsCache() + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WriteWcc(writer, preAttr, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onsymlink.go b/pkg/go-nfs/nfs_onsymlink.go new file mode 100644 index 0000000..4b728c6 --- /dev/null +++ b/pkg/go-nfs/nfs_onsymlink.go @@ -0,0 +1,88 @@ +package nfs + +import ( + "bytes" + "context" + "os" + + "github.com/go-git/go-billy/v5" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func onSymlink(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + obj := DirOpArg{} + err := xdr.Read(w.req.Body, &obj) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + attrs, err := ReadSetFileAttributes(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + target, err := xdr.ReadOpaque(w.req.Body) + if err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, path, err := userHandle.FromHandle(obj.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + if !CapabilityCheck(fs, billy.WriteCapability) { + return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + } + + if len(string(obj.Filename)) > PathNameMax { + return &NFSStatusError{NFSStatusNameTooLong, os.ErrInvalid} + } + + newFilePath := fs.Join(append(path, string(obj.Filename))...) + if _, err := fs.Stat(ctx, newFilePath); err == nil { + return &NFSStatusError{NFSStatusExist, os.ErrExist} + } + if s, err := fs.Stat(ctx, fs.Join(path...)); err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } else if !s.IsDir() { + return &NFSStatusError{NFSStatusNotDir, nil} + } + + err = fs.Symlink(ctx, string(target), newFilePath) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + + fp := userHandle.ToHandle(fs, append(path, string(obj.Filename))) + changer := userHandle.Change(fs) + if changer != nil { + if err := attrs.Apply(ctx, changer, fs, newFilePath); err != nil { + return &NFSStatusError{NFSStatusIO, err} + } + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + // "handle follows" + if err := xdr.Write(writer, uint32(1)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, fp); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := WritePostOpAttrs(writer, tryStat(ctx, fs, append(path, string(obj.Filename)))); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := WriteWcc(writer, nil, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_onwrite.go b/pkg/go-nfs/nfs_onwrite.go new file mode 100644 index 0000000..cfe2eb4 --- /dev/null +++ b/pkg/go-nfs/nfs_onwrite.go @@ -0,0 +1,116 @@ +package nfs + +import ( + "bytes" + "context" + "errors" + "io" + "math" + "os" + + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +// writeStability is the level of durability requested with the write +type writeStability uint32 + +const ( + unstable writeStability = 0 + dataSync writeStability = 1 + fileSync writeStability = 2 +) + +type writeArgs struct { + Handle []byte + Offset uint64 + Count uint32 + How uint32 + Data []byte +} + +func onWrite(ctx context.Context, w *response, userHandle Handler) error { + w.errorFmt = wccDataErrorFormatter + var req writeArgs + if err := xdr.Read(w.req.Body, &req); err != nil { + return &NFSStatusError{NFSStatusInval, err} + } + + fs, path, err := userHandle.FromHandle(req.Handle) + if err != nil { + return &NFSStatusError{NFSStatusStale, err} + } + // TODO + // if !CapabilityCheck(fs, billy.WriteCapability) { + // return &NFSStatusError{NFSStatusROFS, os.ErrPermission} + // } + if len(req.Data) > math.MaxInt32 || req.Count > math.MaxInt32 { + return &NFSStatusError{NFSStatusFBig, os.ErrInvalid} + } + if req.How != uint32(unstable) && req.How != uint32(dataSync) && req.How != uint32(fileSync) { + return &NFSStatusError{NFSStatusInval, os.ErrInvalid} + } + + // stat first for pre-op wcc. + fullPath := fs.Join(path...) + info, err := fs.Stat(ctx, fullPath) + if err != nil { + if os.IsNotExist(err) { + return &NFSStatusError{NFSStatusNoEnt, err} + } + if errors.Is(err, context.DeadlineExceeded) { + return &NFSStatusError{NFSStatusJukebox, err} + } + return &NFSStatusError{NFSStatusAccess, err} + } + if !info.Mode().IsRegular() { + return &NFSStatusError{NFSStatusInval, os.ErrInvalid} + } + preOpCache := ToFileAttribute(info, fullPath).AsCache() + + // now the actual op. + file, err := fs.OpenFile(ctx, fs.Join(path...), os.O_RDWR, info.Mode().Perm()) + if err != nil { + return &NFSStatusError{NFSStatusAccess, err} + } + if req.Offset > 0 { + if _, err := file.Seek(int64(req.Offset), io.SeekStart); err != nil { + return &NFSStatusError{NFSStatusIO, err} + } + } + end := req.Count + if len(req.Data) < int(end) { + end = uint32(len(req.Data)) + } + writtenCount, err := file.Write(ctx, req.Data[:end]) + if err != nil { + Log.Errorf("Error writing: %v", err) + return &NFSStatusError{NFSStatusIO, err} + } + if err := file.Close(ctx); err != nil { + Log.Errorf("error closing: %v", err) + return &NFSStatusError{NFSStatusIO, err} + } + + writer := bytes.NewBuffer([]byte{}) + if err := xdr.Write(writer, uint32(NFSStatusOk)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := WriteWcc(writer, preOpCache, tryStat(ctx, fs, path)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, uint32(writtenCount)); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, fileSync); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + if err := xdr.Write(writer, w.Server.ID); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + + if err := w.Write(writer.Bytes()); err != nil { + return &NFSStatusError{NFSStatusServerFault, err} + } + return nil +} diff --git a/pkg/go-nfs/nfs_test.go b/pkg/go-nfs/nfs_test.go new file mode 100644 index 0000000..8a79752 --- /dev/null +++ b/pkg/go-nfs/nfs_test.go @@ -0,0 +1,293 @@ +package nfs_test + +import ( + "bytes" + "context" + "fmt" + "net" + "reflect" + "sort" + "testing" + + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers/memfs" + + nfsc "github.com/willscott/go-nfs-client/nfs" + rpc "github.com/willscott/go-nfs-client/nfs/rpc" + "github.com/willscott/go-nfs-client/nfs/util" + "github.com/willscott/go-nfs-client/nfs/xdr" +) + +func TestNFS(t *testing.T) { + ctx := context.Background() + + if testing.Verbose() { + util.DefaultLogger.SetDebug(true) + } + + // make an empty in-memory server. + listener, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatal(err) + } + + mem := helpers.WrapBillyFS(memfs.New()) + // File needs to exist in the root for memfs to acknowledge the root exists. + _, _ = mem.Create(ctx, "/test") + + handler := helpers.NewNullAuthHandler(mem) + cacheHelper := helpers.NewCachingHandler(handler, 1024) + go func() { + _ = nfs.Serve(listener, cacheHelper) + }() + + c, err := rpc.DialTCP(listener.Addr().Network(), listener.Addr().(*net.TCPAddr).String(), false) + if err != nil { + t.Fatal(err) + } + defer c.Close() + + var mounter nfsc.Mount + mounter.Client = c + target, err := mounter.Mount("/", rpc.AuthNull) + if err != nil { + t.Fatal(err) + } + defer func() { + _ = mounter.Unmount() + }() + + _, err = target.FSInfo() + if err != nil { + t.Fatal(err) + } + + // Validate sample file creation + _, err = target.Create("/helloworld.txt", 0666) + if err != nil { + t.Fatal(err) + } + if info, err := mem.Stat(ctx, "/helloworld.txt"); err != nil { + t.Fatal(err) + } else { + if info.Size() != 0 || info.Mode().Perm() != 0666 { + t.Fatal("incorrect creation.") + } + } + + // Validate writing to a file. + f, err := target.OpenFile("/helloworld.txt", 0666) + if err != nil { + t.Fatal(err) + } + b := []byte("hello world") + _, err = f.Write(b) + if err != nil { + t.Fatal(err) + } + mf, _ := mem.Open(ctx, "/helloworld.txt") + buf := make([]byte, len(b)) + if _, err = mf.Read(ctx, buf[:]); err != nil { + t.Fatal(err) + } + if !bytes.Equal(buf, b) { + t.Fatal("written does not match expected") + } + + // for test nfs.ReadDirPlus in case of many files + dirF1, err := mem.ReadDir(ctx, "/") + if err != nil { + t.Fatal(err) + } + shouldBeNames := []string{} + for _, f := range dirF1 { + shouldBeNames = append(shouldBeNames, f.Name()) + } + for i := 0; i < 2000; i++ { + fName := fmt.Sprintf("f-%04d.txt", i) + shouldBeNames = append(shouldBeNames, fName) + f, err := mem.Create(ctx, fName) + if err != nil { + t.Fatal(err) + } + f.Close(ctx) + } + + manyEntitiesPlus, err := target.ReadDirPlus("/") + if err != nil { + t.Fatal(err) + } + actualBeNamesPlus := []string{} + for _, e := range manyEntitiesPlus { + actualBeNamesPlus = append(actualBeNamesPlus, e.Name()) + } + + as := sort.StringSlice(shouldBeNames) + bs := sort.StringSlice(actualBeNamesPlus) + as.Sort() + bs.Sort() + if !reflect.DeepEqual(as, bs) { + t.Fatal("nfs.ReadDirPlus error") + } + + // for test nfs.ReadDir in case of many files + manyEntities, err := readDir(target, "/") + if err != nil { + t.Fatal(err) + } + actualBeNames := []string{} + for _, e := range manyEntities { + actualBeNames = append(actualBeNames, e.FileName) + } + + as2 := sort.StringSlice(shouldBeNames) + bs2 := sort.StringSlice(actualBeNames) + as2.Sort() + bs2.Sort() + if !reflect.DeepEqual(as2, bs2) { + fmt.Printf("should be %v\n", as2) + fmt.Printf("actual be %v\n", bs2) + t.Fatal("nfs.ReadDir error") + } + + // confirm rename works as expected + oldFA, _, err := target.Lookup("/f-0010.txt", false) + if err != nil { + t.Fatal(err) + } + + if err := target.Rename("/f-0010.txt", "/g-0010.txt"); err != nil { + t.Fatal(err) + } + new, _, err := target.Lookup("/g-0010.txt", false) + if err != nil { + t.Fatal(err) + } + if new.Sys() != oldFA.Sys() { + t.Fatal("rename failed to update") + } + _, _, err = target.Lookup("/f-0010.txt", false) + if err == nil { + t.Fatal("old handle should be invalid") + } + + // for test nfs.ReadDirPlus in case of empty directory + _, err = target.Mkdir("/empty", 0755) + if err != nil { + t.Fatal(err) + } + + emptyEntitiesPlus, err := target.ReadDirPlus("/empty") + if err != nil { + t.Fatal(err) + } + if len(emptyEntitiesPlus) != 0 { + t.Fatal("nfs.ReadDirPlus error reading empty dir") + } + + // for test nfs.ReadDir in case of empty directory + emptyEntities, err := readDir(target, "/empty") + if err != nil { + t.Fatal(err) + } + if len(emptyEntities) != 0 { + t.Fatal("nfs.ReadDir error reading empty dir") + } +} + +type readDirEntry struct { + FileId uint64 + FileName string + Cookie uint64 +} + +// readDir implementation "appropriated" from go-nfs-client implementation of READDIRPLUS +func readDir(target *nfsc.Target, dir string) ([]*readDirEntry, error) { + _, fh, err := target.Lookup(dir) + if err != nil { + return nil, err + } + + type readDirArgs struct { + rpc.Header + Handle []byte + Cookie uint64 + CookieVerif uint64 + Count uint32 + } + + type readDirList struct { + IsSet bool `xdr:"union"` + Entry readDirEntry `xdr:"unioncase=1"` + } + + type readDirListOK struct { + DirAttrs nfsc.PostOpAttr + CookieVerf uint64 + } + + cookie := uint64(0) + cookieVerf := uint64(0) + eof := false + + var entries []*readDirEntry + for !eof { + res, err := target.Call(&readDirArgs{ + Header: rpc.Header{ + Rpcvers: 2, + Vers: nfsc.Nfs3Vers, + Prog: nfsc.Nfs3Prog, + Proc: uint32(nfs.NFSProcedureReadDir), + Cred: rpc.AuthNull, + Verf: rpc.AuthNull, + }, + Handle: fh, + Cookie: cookie, + CookieVerif: cookieVerf, + Count: 4096, + }) + if err != nil { + return nil, err + } + + status, err := xdr.ReadUint32(res) + if err != nil { + return nil, err + } + + if err = nfsc.NFS3Error(status); err != nil { + return nil, err + } + + dirListOK := new(readDirListOK) + if err = xdr.Read(res, dirListOK); err != nil { + return nil, err + } + + for { + var item readDirList + if err = xdr.Read(res, &item); err != nil { + return nil, err + } + + if !item.IsSet { + break + } + + cookie = item.Entry.Cookie + if item.Entry.FileName == "." || item.Entry.FileName == ".." { + continue + } + entries = append(entries, &item.Entry) + } + + if err = xdr.Read(res, &eof); err != nil { + return nil, err + } + + cookieVerf = dirListOK.CookieVerf + } + + return entries, nil +} diff --git a/pkg/go-nfs/nfsinterface.go b/pkg/go-nfs/nfsinterface.go new file mode 100644 index 0000000..cf439af --- /dev/null +++ b/pkg/go-nfs/nfsinterface.go @@ -0,0 +1,188 @@ +package nfs + +// NFSProcedure is the valid RPC calls for the nfs service. +type NFSProcedure uint32 + +// NfsProcedure Codes +const ( + NFSProcedureNull NFSProcedure = iota + NFSProcedureGetAttr + NFSProcedureSetAttr + NFSProcedureLookup + NFSProcedureAccess + NFSProcedureReadlink + NFSProcedureRead + NFSProcedureWrite + NFSProcedureCreate + NFSProcedureMkDir + NFSProcedureSymlink + NFSProcedureMkNod + NFSProcedureRemove + NFSProcedureRmDir + NFSProcedureRename + NFSProcedureLink + NFSProcedureReadDir + NFSProcedureReadDirPlus + NFSProcedureFSStat + NFSProcedureFSInfo + NFSProcedurePathConf + NFSProcedureCommit +) + +func (n NFSProcedure) String() string { + switch n { + case NFSProcedureNull: + return "Null" + case NFSProcedureGetAttr: + return "GetAttr" + case NFSProcedureSetAttr: + return "SetAttr" + case NFSProcedureLookup: + return "Lookup" + case NFSProcedureAccess: + return "Access" + case NFSProcedureReadlink: + return "ReadLink" + case NFSProcedureRead: + return "Read" + case NFSProcedureWrite: + return "Write" + case NFSProcedureCreate: + return "Create" + case NFSProcedureMkDir: + return "Mkdir" + case NFSProcedureSymlink: + return "Symlink" + case NFSProcedureMkNod: + return "Mknod" + case NFSProcedureRemove: + return "Remove" + case NFSProcedureRmDir: + return "Rmdir" + case NFSProcedureRename: + return "Rename" + case NFSProcedureLink: + return "Link" + case NFSProcedureReadDir: + return "ReadDir" + case NFSProcedureReadDirPlus: + return "ReadDirPlus" + case NFSProcedureFSStat: + return "FSStat" + case NFSProcedureFSInfo: + return "FSInfo" + case NFSProcedurePathConf: + return "PathConf" + case NFSProcedureCommit: + return "Commit" + default: + return "Unknown" + } +} + +// NFSStatus (nfsstat3) is a result code for nfs rpc calls +type NFSStatus uint32 + +// NFSStatus codes +const ( + NFSStatusOk NFSStatus = 0 + NFSStatusPerm NFSStatus = 1 + NFSStatusNoEnt NFSStatus = 2 + NFSStatusIO NFSStatus = 5 + NFSStatusNXIO NFSStatus = 6 + NFSStatusAccess NFSStatus = 13 + NFSStatusExist NFSStatus = 17 + NFSStatusXDev NFSStatus = 18 + NFSStatusNoDev NFSStatus = 19 + NFSStatusNotDir NFSStatus = 20 + NFSStatusIsDir NFSStatus = 21 + NFSStatusInval NFSStatus = 22 + NFSStatusFBig NFSStatus = 27 + NFSStatusNoSPC NFSStatus = 28 + NFSStatusROFS NFSStatus = 30 + NFSStatusMlink NFSStatus = 31 + NFSStatusNameTooLong NFSStatus = 63 + NFSStatusNotEmpty NFSStatus = 66 + NFSStatusDQuot NFSStatus = 69 + NFSStatusStale NFSStatus = 70 + NFSStatusRemote NFSStatus = 71 + NFSStatusBadHandle NFSStatus = 10001 + NFSStatusNotSync NFSStatus = 10002 + NFSStatusBadCookie NFSStatus = 10003 + NFSStatusNotSupp NFSStatus = 10004 + NFSStatusTooSmall NFSStatus = 10005 + NFSStatusServerFault NFSStatus = 10006 + NFSStatusBadType NFSStatus = 10007 + NFSStatusJukebox NFSStatus = 10008 +) + +func (s NFSStatus) String() string { + switch s { + case NFSStatusOk: + return "Call Completed Successfull" + case NFSStatusPerm: + return "Not Owner" + case NFSStatusNoEnt: + return "No such file or directory" + case NFSStatusIO: + return "I/O error" + case NFSStatusNXIO: + return "I/O error: No such device" + case NFSStatusAccess: + return "Permission denied" + case NFSStatusExist: + return "File exists" + case NFSStatusXDev: + return "Attempt to do a cross device hard link" + case NFSStatusNoDev: + return "No such device" + case NFSStatusNotDir: + return "Not a directory" + case NFSStatusIsDir: + return "Is a directory" + case NFSStatusInval: + return "Invalid argument" + case NFSStatusFBig: + return "File too large" + case NFSStatusNoSPC: + return "No space left on device" + case NFSStatusROFS: + return "Read only file system" + case NFSStatusMlink: + return "Too many hard links" + case NFSStatusNameTooLong: + return "Name too long" + case NFSStatusNotEmpty: + return "Not empty" + case NFSStatusDQuot: + return "Resource quota exceeded" + case NFSStatusStale: + return "Invalid file handle" + case NFSStatusRemote: + return "Too many levels of remote in path" + case NFSStatusBadHandle: + return "Illegal NFS file handle" + case NFSStatusNotSync: + return "Synchronization mismatch" + case NFSStatusBadCookie: + return "Cookie is Stale" + case NFSStatusNotSupp: + return "Operation not supported" + case NFSStatusTooSmall: + return "Buffer or request too small" + case NFSStatusServerFault: + return "Unmapped error (EIO)" + case NFSStatusBadType: + return "Type not supported" + case NFSStatusJukebox: + return "Initiated, but too slow. Try again with new txn" + default: + return "unknown" + } +} + +// DirOpArg is a common serialization used for referencing an object in a directory +type DirOpArg struct { + Handle []byte + Filename []byte +} diff --git a/pkg/go-nfs/server.go b/pkg/go-nfs/server.go new file mode 100644 index 0000000..e9d2085 --- /dev/null +++ b/pkg/go-nfs/server.go @@ -0,0 +1,102 @@ +package nfs + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "net" + "time" +) + +// Server is a handle to the listening NFS server. +type Server struct { + Handler + ID [8]byte +} + +// RegisterMessageHandler registers a handler for a specific +// XDR procedure. +func RegisterMessageHandler(protocol uint32, proc uint32, handler HandleFunc) error { + if registeredHandlers == nil { + registeredHandlers = make(map[registeredHandlerID]HandleFunc) + } + for k := range registeredHandlers { + if k.protocol == protocol && k.proc == proc { + return errors.New("already registered") + } + } + id := registeredHandlerID{protocol, proc} + registeredHandlers[id] = handler + return nil +} + +// HandleFunc represents a handler for a specific protocol message. +type HandleFunc func(ctx context.Context, w *response, userHandler Handler) error + +// TODO: store directly as a uint64 for more efficient lookups +type registeredHandlerID struct { + protocol uint32 + proc uint32 +} + +var registeredHandlers map[registeredHandlerID]HandleFunc + +// Serve listens on the provided listener port for incoming client requests. +func (s *Server) Serve(l net.Listener) error { + defer l.Close() + if bytes.Equal(s.ID[:], []byte{0, 0, 0, 0, 0, 0, 0, 0}) { + if _, err := rand.Reader.Read(s.ID[:]); err != nil { + return err + } + } + + var tempDelay time.Duration + + for { + conn, err := l.Accept() + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + if tempDelay == 0 { + tempDelay = 5 * time.Millisecond + } else { + tempDelay *= 2 + } + if max := 1 * time.Second; tempDelay > max { + tempDelay = max + } + time.Sleep(tempDelay) + continue + } + return err + } + tempDelay = 0 + c := s.newConn(conn) + go c.serve() + } +} + +func (s *Server) newConn(nc net.Conn) *conn { + c := &conn{ + Server: s, + Conn: nc, + } + return c +} + +// TODO: keep an immutable map for each server instance to have less +// chance of races. +func (s *Server) handlerFor(prog uint32, proc uint32) HandleFunc { + for k, v := range registeredHandlers { + if k.protocol == prog && k.proc == proc { + return v + } + } + return nil +} + +// Serve is a singleton listener paralleling http.Serve +func Serve(l net.Listener, handler Handler) error { + srv := &Server{Handler: handler} + return srv.Serve(l) +} diff --git a/pkg/go-nfs/time.go b/pkg/go-nfs/time.go new file mode 100644 index 0000000..266dd27 --- /dev/null +++ b/pkg/go-nfs/time.go @@ -0,0 +1,32 @@ +package nfs + +import ( + "time" +) + +// FileTime is the NFS wire time format +// This is equivalent to go-nfs-client/nfs.NFS3Time +type FileTime struct { + Seconds uint32 + Nseconds uint32 +} + +// ToNFSTime generates the nfs 64bit time format from a golang time. +func ToNFSTime(t time.Time) FileTime { + return FileTime{ + Seconds: uint32(t.Unix()), + Nseconds: uint32(t.UnixNano() % int64(time.Second)), + } +} + +// Native generates a golang time from an nfs time spec +func (t FileTime) Native() *time.Time { + ts := time.Unix(int64(t.Seconds), int64(t.Nseconds)) + return &ts +} + +// EqualTimespec returns if this time is equal to a local time spec +func (t FileTime) EqualTimespec(sec int64, nsec int64) bool { + // TODO: bounds check on sec/nsec overflow + return t.Nseconds == uint32(nsec) && t.Seconds == uint32(sec) +} diff --git a/pkg/kvtrace/kvmetrics.go b/pkg/kvtrace/kvmetrics.go new file mode 100644 index 0000000..869c37a --- /dev/null +++ b/pkg/kvtrace/kvmetrics.go @@ -0,0 +1,90 @@ +package kvtrace + +import ( + "context" + + "github.com/royalcat/kv" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +var tracer = otel.Tracer("github.com/royalcat/kv/tracer") + +type traceSrtore[K, V any] struct { + kv kv.Store[K, V] + attrs []attribute.KeyValue +} + +func WrapTracing[K, V any](kv kv.Store[K, V], attrs ...attribute.KeyValue) kv.Store[K, V] { + return &traceSrtore[K, V]{ + kv: kv, + attrs: attrs, + } +} + +// Close implements kv.Store. +func (m *traceSrtore[K, V]) Close(ctx context.Context) error { + ctx, span := tracer.Start(ctx, "Close", trace.WithAttributes(m.attrs...)) + defer span.End() + + return m.kv.Close(ctx) +} + +// Delete implements kv.Store. +func (m *traceSrtore[K, V]) Delete(ctx context.Context, k K) error { + ctx, span := tracer.Start(ctx, "Delete", trace.WithAttributes(m.attrs...)) + defer span.End() + + return m.kv.Delete(ctx, k) +} + +// Get implements kv.Store. +func (m *traceSrtore[K, V]) Get(ctx context.Context, k K) (v V, found bool, err error) { + ctx, span := tracer.Start(ctx, "Get", trace.WithAttributes(m.attrs...)) + defer span.End() + + return m.kv.Get(ctx, k) +} + +// Range implements kv.Store. +func (m *traceSrtore[K, V]) Range(ctx context.Context, iter kv.Iter[K, V]) error { + ctx, span := tracer.Start(ctx, "Range", trace.WithAttributes(m.attrs...)) + defer span.End() + + count := 0 + iterCount := func(k K, v V) bool { + count++ + return iter(k, v) + } + + err := m.kv.Range(ctx, iterCount) + span.SetAttributes(attribute.Int("count", count)) + return err +} + +// RangeWithPrefix implements kv.Store. +func (m *traceSrtore[K, V]) RangeWithPrefix(ctx context.Context, k K, iter kv.Iter[K, V]) error { + ctx, span := tracer.Start(ctx, "RangeWithPrefix", trace.WithAttributes(m.attrs...)) + defer span.End() + + count := 0 + iterCount := func(k K, v V) bool { + count++ + return iter(k, v) + } + + err := m.kv.Range(ctx, iterCount) + span.SetAttributes(attribute.Int("count", count)) + return err +} + +// Set implements kv.Store. +func (m *traceSrtore[K, V]) Set(ctx context.Context, k K, v V) error { + ctx, span := tracer.Start(ctx, "Set", trace.WithAttributes(m.attrs...)) + defer span.End() + + return m.kv.Set(ctx, k, v) +} + +var _ kv.Store[any, any] = (*traceSrtore[any, any])(nil) diff --git a/src/config/default.go b/src/config/default.go index e75be06..5ace89f 100644 --- a/src/config/default.go +++ b/src/config/default.go @@ -1,6 +1,6 @@ package config -var defaultConfig = Config{ +var defaultConfig = Settings{ SourceDir: "./data", WebUi: WebUi{ Port: 4444, @@ -21,8 +21,9 @@ var defaultConfig = Config{ Enabled: false, }, NFS: NFS{ - Enabled: false, - Port: 8122, + Enabled: false, + Port: 8122, + CachePath: "./nfs-cache", }, }, diff --git a/src/config/load.go b/src/config/load.go index 2694baa..53f1156 100644 --- a/src/config/load.go +++ b/src/config/load.go @@ -13,8 +13,9 @@ import ( var k = koanf.New(".") -func Load(path string) (*Config, error) { +var Config = defaultConfig +func Load(path string) (*Settings, error) { err := k.Load(structs.Provider(defaultConfig, "koanf"), nil) if err != nil { return nil, err @@ -50,7 +51,7 @@ func Load(path string) (*Config, error) { return nil, err } - conf := Config{} + conf := Settings{} err = k.Unmarshal("", &conf) if err != nil { return nil, err diff --git a/src/config/model.go b/src/config/model.go index d8e8c03..8dd744e 100644 --- a/src/config/model.go +++ b/src/config/model.go @@ -1,7 +1,7 @@ package config // Config is the main config object -type Config struct { +type Settings struct { WebUi WebUi `koanf:"webUi"` TorrentClient TorrentClient `koanf:"torrent"` Mounts Mounts `koanf:"mounts"` @@ -67,8 +67,9 @@ type Mounts struct { } type NFS struct { - Enabled bool `koanf:"enabled"` - Port int `koanf:"port"` + Enabled bool `koanf:"enabled"` + Port int `koanf:"port"` + CachePath string `koanf:"cache_path"` } type HttpFs struct { diff --git a/src/delivery/graphql/generated.go b/src/delivery/graphql/generated.go index 2785488..3253c0c 100644 --- a/src/delivery/graphql/generated.go +++ b/src/delivery/graphql/generated.go @@ -57,6 +57,11 @@ type ComplexityRoot struct { Size func(childComplexity int) int } + CleanupResponse struct { + Count func(childComplexity int) int + List func(childComplexity int) int + } + Dir struct { Name func(childComplexity int) int } @@ -70,8 +75,14 @@ type ComplexityRoot struct { Size func(childComplexity int) int } + ListDirResponse struct { + Entries func(childComplexity int) int + Root func(childComplexity int) int + } + Mutation struct { CleanupTorrents func(childComplexity int, files *bool, dryRun bool) int + DedupeStorage func(childComplexity int) int DownloadTorrent func(childComplexity int, infohash string, file *string) int ValidateTorrents func(childComplexity int, filter model.TorrentFilter) int } @@ -138,12 +149,13 @@ type ComplexityRoot struct { type MutationResolver interface { ValidateTorrents(ctx context.Context, filter model.TorrentFilter) (bool, error) - CleanupTorrents(ctx context.Context, files *bool, dryRun bool) (int64, error) + CleanupTorrents(ctx context.Context, files *bool, dryRun bool) (*model.CleanupResponse, error) DownloadTorrent(ctx context.Context, infohash string, file *string) (*model.DownloadTorrentResponse, error) + DedupeStorage(ctx context.Context) (int64, error) } type QueryResolver interface { Torrents(ctx context.Context, filter *model.TorrentsFilter, pagination *model.Pagination) ([]*model.Torrent, error) - FsListDir(ctx context.Context, path string) ([]model.DirEntry, error) + FsListDir(ctx context.Context, path string) (*model.ListDirResponse, error) } type SubscriptionResolver interface { TaskProgress(ctx context.Context, taskID string) (<-chan model.Progress, error) @@ -190,6 +202,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.ArchiveFS.Size(childComplexity), true + case "CleanupResponse.count": + if e.complexity.CleanupResponse.Count == nil { + break + } + + return e.complexity.CleanupResponse.Count(childComplexity), true + + case "CleanupResponse.list": + if e.complexity.CleanupResponse.List == nil { + break + } + + return e.complexity.CleanupResponse.List(childComplexity), true + case "Dir.name": if e.complexity.Dir.Name == nil { break @@ -218,6 +244,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.File.Size(childComplexity), true + case "ListDirResponse.entries": + if e.complexity.ListDirResponse.Entries == nil { + break + } + + return e.complexity.ListDirResponse.Entries(childComplexity), true + + case "ListDirResponse.root": + if e.complexity.ListDirResponse.Root == nil { + break + } + + return e.complexity.ListDirResponse.Root(childComplexity), true + case "Mutation.cleanupTorrents": if e.complexity.Mutation.CleanupTorrents == nil { break @@ -230,6 +270,13 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.Mutation.CleanupTorrents(childComplexity, args["files"].(*bool), args["dryRun"].(bool)), true + case "Mutation.dedupeStorage": + if e.complexity.Mutation.DedupeStorage == nil { + break + } + + return e.complexity.Mutation.DedupeStorage(childComplexity), true + case "Mutation.downloadTorrent": if e.complexity.Mutation.DownloadTorrent == nil { break @@ -602,28 +649,34 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er var sources = []*ast.Source{ {Name: "../../../graphql/mutation.graphql", Input: `type Mutation { - validateTorrents(filter: TorrentFilter!): Boolean! - cleanupTorrents(files: Boolean, dryRun: Boolean!): Int! - downloadTorrent(infohash: String!, file: String): DownloadTorrentResponse + validateTorrents(filter: TorrentFilter!): Boolean! + cleanupTorrents(files: Boolean, dryRun: Boolean!): CleanupResponse! + downloadTorrent(infohash: String!, file: String): DownloadTorrentResponse + dedupeStorage: Int! } - input TorrentFilter @oneOf { - everything: Boolean - infohash: String - # pathGlob: String! + everything: Boolean + infohash: String + # pathGlob: String! } type DownloadTorrentResponse { - task: Task + task: Task +} + +type CleanupResponse { + count: Int! + list: [String!]! } type Task { - id: ID! -}`, BuiltIn: false}, + id: ID! +} +`, BuiltIn: false}, {Name: "../../../graphql/query.graphql", Input: `type Query { torrents(filter: TorrentsFilter, pagination: Pagination): [Torrent!]! - fsListDir(path: String!): [DirEntry!]! + fsListDir(path: String!): ListDirResponse! } input TorrentsFilter { @@ -634,6 +687,11 @@ input TorrentsFilter { peersCount: IntFilter } +type ListDirResponse { + root: DirEntry! + entries: [DirEntry!]! +} + input Pagination { offset: Int! limit: Int! @@ -1008,6 +1066,94 @@ func (ec *executionContext) fieldContext_ArchiveFS_size(ctx context.Context, fie return fc, nil } +func (ec *executionContext) _CleanupResponse_count(ctx context.Context, field graphql.CollectedField, obj *model.CleanupResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CleanupResponse_count(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Count, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int64) + fc.Result = res + return ec.marshalNInt2int64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CleanupResponse_count(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CleanupResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _CleanupResponse_list(ctx context.Context, field graphql.CollectedField, obj *model.CleanupResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_CleanupResponse_list(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.List, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]string) + fc.Result = res + return ec.marshalNString2ᚕstringᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_CleanupResponse_list(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "CleanupResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Dir_name(ctx context.Context, field graphql.CollectedField, obj *model.Dir) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Dir_name(ctx, field) if err != nil { @@ -1185,6 +1331,94 @@ func (ec *executionContext) fieldContext_File_size(ctx context.Context, field gr return fc, nil } +func (ec *executionContext) _ListDirResponse_root(ctx context.Context, field graphql.CollectedField, obj *model.ListDirResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ListDirResponse_root(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Root, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(model.DirEntry) + fc.Result = res + return ec.marshalNDirEntry2gitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐDirEntry(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ListDirResponse_root(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ListDirResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("FieldContext.Child cannot be called on type INTERFACE") + }, + } + return fc, nil +} + +func (ec *executionContext) _ListDirResponse_entries(ctx context.Context, field graphql.CollectedField, obj *model.ListDirResponse) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_ListDirResponse_entries(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Entries, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.([]model.DirEntry) + fc.Result = res + return ec.marshalNDirEntry2ᚕgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐDirEntryᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_ListDirResponse_entries(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "ListDirResponse", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("FieldContext.Child cannot be called on type INTERFACE") + }, + } + return fc, nil +} + func (ec *executionContext) _Mutation_validateTorrents(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_validateTorrents(ctx, field) if err != nil { @@ -1266,9 +1500,9 @@ func (ec *executionContext) _Mutation_cleanupTorrents(ctx context.Context, field } return graphql.Null } - res := resTmp.(int64) + res := resTmp.(*model.CleanupResponse) fc.Result = res - return ec.marshalNInt2int64(ctx, field.Selections, res) + return ec.marshalNCleanupResponse2ᚖgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐCleanupResponse(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Mutation_cleanupTorrents(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1278,7 +1512,13 @@ func (ec *executionContext) fieldContext_Mutation_cleanupTorrents(ctx context.Co IsMethod: true, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") + switch field.Name { + case "count": + return ec.fieldContext_CleanupResponse_count(ctx, field) + case "list": + return ec.fieldContext_CleanupResponse_list(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type CleanupResponse", field.Name) }, } defer func() { @@ -1351,6 +1591,50 @@ func (ec *executionContext) fieldContext_Mutation_downloadTorrent(ctx context.Co return fc, nil } +func (ec *executionContext) _Mutation_dedupeStorage(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_dedupeStorage(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().DedupeStorage(rctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int64) + fc.Result = res + return ec.marshalNInt2int64(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_dedupeStorage(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _Query_torrents(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query_torrents(ctx, field) if err != nil { @@ -1450,9 +1734,9 @@ func (ec *executionContext) _Query_fsListDir(ctx context.Context, field graphql. } return graphql.Null } - res := resTmp.([]model.DirEntry) + res := resTmp.(*model.ListDirResponse) fc.Result = res - return ec.marshalNDirEntry2ᚕgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐDirEntryᚄ(ctx, field.Selections, res) + return ec.marshalNListDirResponse2ᚖgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐListDirResponse(ctx, field.Selections, res) } func (ec *executionContext) fieldContext_Query_fsListDir(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { @@ -1462,7 +1746,13 @@ func (ec *executionContext) fieldContext_Query_fsListDir(ctx context.Context, fi IsMethod: true, IsResolver: true, Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("FieldContext.Child cannot be called on type INTERFACE") + switch field.Name { + case "root": + return ec.fieldContext_ListDirResponse_root(ctx, field) + case "entries": + return ec.fieldContext_ListDirResponse_entries(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ListDirResponse", field.Name) }, } defer func() { @@ -1723,6 +2013,8 @@ func (ec *executionContext) fieldContext_Schema_mutation(ctx context.Context, fi return ec.fieldContext_Mutation_cleanupTorrents(ctx, field) case "downloadTorrent": return ec.fieldContext_Mutation_downloadTorrent(ctx, field) + case "dedupeStorage": + return ec.fieldContext_Mutation_dedupeStorage(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type Mutation", field.Name) }, @@ -5400,6 +5692,50 @@ func (ec *executionContext) _ArchiveFS(ctx context.Context, sel ast.SelectionSet return out } +var cleanupResponseImplementors = []string{"CleanupResponse"} + +func (ec *executionContext) _CleanupResponse(ctx context.Context, sel ast.SelectionSet, obj *model.CleanupResponse) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, cleanupResponseImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("CleanupResponse") + case "count": + out.Values[i] = ec._CleanupResponse_count(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "list": + out.Values[i] = ec._CleanupResponse_list(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var dirImplementors = []string{"Dir", "DirEntry"} func (ec *executionContext) _Dir(ctx context.Context, sel ast.SelectionSet, obj *model.Dir) graphql.Marshaler { @@ -5519,6 +5855,50 @@ func (ec *executionContext) _File(ctx context.Context, sel ast.SelectionSet, obj return out } +var listDirResponseImplementors = []string{"ListDirResponse"} + +func (ec *executionContext) _ListDirResponse(ctx context.Context, sel ast.SelectionSet, obj *model.ListDirResponse) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, listDirResponseImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("ListDirResponse") + case "root": + out.Values[i] = ec._ListDirResponse_root(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "entries": + out.Values[i] = ec._ListDirResponse_entries(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var mutationImplementors = []string{"Mutation"} func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) graphql.Marshaler { @@ -5556,6 +5936,13 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_downloadTorrent(ctx, field) }) + case "dedupeStorage": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_dedupeStorage(ctx, field) + }) + if out.Values[i] == graphql.Null { + out.Invalids++ + } default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -6551,6 +6938,20 @@ func (ec *executionContext) marshalNBoolean2bool(ctx context.Context, sel ast.Se return res } +func (ec *executionContext) marshalNCleanupResponse2gitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐCleanupResponse(ctx context.Context, sel ast.SelectionSet, v model.CleanupResponse) graphql.Marshaler { + return ec._CleanupResponse(ctx, sel, &v) +} + +func (ec *executionContext) marshalNCleanupResponse2ᚖgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐCleanupResponse(ctx context.Context, sel ast.SelectionSet, v *model.CleanupResponse) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._CleanupResponse(ctx, sel, v) +} + func (ec *executionContext) marshalNDirEntry2gitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐDirEntry(ctx context.Context, sel ast.SelectionSet, v model.DirEntry) graphql.Marshaler { if v == nil { if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { @@ -6650,6 +7051,20 @@ func (ec *executionContext) marshalNInt2int64(ctx context.Context, sel ast.Selec return res } +func (ec *executionContext) marshalNListDirResponse2gitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐListDirResponse(ctx context.Context, sel ast.SelectionSet, v model.ListDirResponse) graphql.Marshaler { + return ec._ListDirResponse(ctx, sel, &v) +} + +func (ec *executionContext) marshalNListDirResponse2ᚖgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐListDirResponse(ctx context.Context, sel ast.SelectionSet, v *model.ListDirResponse) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._ListDirResponse(ctx, sel, v) +} + func (ec *executionContext) unmarshalNString2string(ctx context.Context, v interface{}) (string, error) { res, err := graphql.UnmarshalString(v) return res, graphql.ErrorOnPath(ctx, err) @@ -6665,6 +7080,38 @@ func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.S return res } +func (ec *executionContext) unmarshalNString2ᚕstringᚄ(ctx context.Context, v interface{}) ([]string, error) { + var vSlice []interface{} + if v != nil { + vSlice = graphql.CoerceList(v) + } + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) marshalNTorrent2ᚕᚖgitᚗkmsignᚗruᚋroyalcatᚋtstorᚋsrcᚋdeliveryᚋgraphqlᚋmodelᚐTorrentᚄ(ctx context.Context, sel ast.SelectionSet, v []*model.Torrent) graphql.Marshaler { ret := make(graphql.Array, len(v)) var wg sync.WaitGroup diff --git a/src/delivery/graphql/model/models_gen.go b/src/delivery/graphql/model/models_gen.go index 4a56ee6..6852932 100644 --- a/src/delivery/graphql/model/models_gen.go +++ b/src/delivery/graphql/model/models_gen.go @@ -32,6 +32,11 @@ type BooleanFilter struct { Eq *bool `json:"eq,omitempty"` } +type CleanupResponse struct { + Count int64 `json:"count"` + List []string `json:"list"` +} + type DateTimeFilter struct { Eq *time.Time `json:"eq,omitempty"` Gt *time.Time `json:"gt,omitempty"` @@ -68,6 +73,11 @@ type IntFilter struct { In []int64 `json:"in,omitempty"` } +type ListDirResponse struct { + Root DirEntry `json:"root"` + Entries []DirEntry `json:"entries"` +} + type Mutation struct { } diff --git a/src/delivery/graphql/resolver/mutation.resolvers.go b/src/delivery/graphql/resolver/mutation.resolvers.go index 210b7a6..ed7dc31 100644 --- a/src/delivery/graphql/resolver/mutation.resolvers.go +++ b/src/delivery/graphql/resolver/mutation.resolvers.go @@ -46,18 +46,24 @@ func (r *mutationResolver) ValidateTorrents(ctx context.Context, filter model.To } // CleanupTorrents is the resolver for the cleanupTorrents field. -func (r *mutationResolver) CleanupTorrents(ctx context.Context, files *bool, dryRun bool) (int64, error) { +func (r *mutationResolver) CleanupTorrents(ctx context.Context, files *bool, dryRun bool) (*model.CleanupResponse, error) { torrents, err := r.Service.ListTorrents(ctx) if err != nil { - return 0, err + return nil, err } if files != nil && *files { r, err := r.Service.Storage.CleanupFiles(ctx, torrents, dryRun) - return int64(r), err + return &model.CleanupResponse{ + Count: int64(len(r)), + List: r, + }, err } else { r, err := r.Service.Storage.CleanupDirs(ctx, torrents, dryRun) - return int64(r), err + return &model.CleanupResponse{ + Count: int64(len(r)), + List: r, + }, err } } @@ -80,6 +86,15 @@ func (r *mutationResolver) DownloadTorrent(ctx context.Context, infohash string, return &model.DownloadTorrentResponse{}, nil } +// DedupeStorage is the resolver for the dedupeStorage field. +func (r *mutationResolver) DedupeStorage(ctx context.Context) (int64, error) { + deduped, err := r.Service.Storage.Dedupe(ctx) + if err != nil { + return 0, err + } + return int64(deduped), nil +} + // Mutation returns graph.MutationResolver implementation. func (r *Resolver) Mutation() graph.MutationResolver { return &mutationResolver{r} } diff --git a/src/delivery/graphql/resolver/query.resolvers.go b/src/delivery/graphql/resolver/query.resolvers.go index a0a826d..1b6aa43 100644 --- a/src/delivery/graphql/resolver/query.resolvers.go +++ b/src/delivery/graphql/resolver/query.resolvers.go @@ -6,6 +6,7 @@ package resolver import ( "context" + "io/fs" graph "git.kmsign.ru/royalcat/tstor/src/delivery/graphql" "git.kmsign.ru/royalcat/tstor/src/delivery/graphql/model" @@ -64,51 +65,75 @@ func (r *queryResolver) Torrents(ctx context.Context, filter *model.TorrentsFilt return tr, nil } +type dirEntry interface { + Name() string + IsDir() bool +} + +func fillDirEntry(e dirEntry) model.DirEntry { + switch e.(type) { + case *vfs.ArchiveFS: + e := e.(*vfs.ArchiveFS) + return model.ArchiveFs{ + Name: e.Name(), + Size: e.Size(), + } + case *vfs.ResolverFS: + e := e.(*vfs.ResolverFS) + return model.ResolverFs{ + Name: e.Name(), + } + case *vfs.TorrentFs: + e := e.(*vfs.TorrentFs) + return model.TorrentFs{ + Name: e.Name(), + Torrent: model.MapTorrent(e.Torrent), + } + default: + if e.IsDir() { + return model.Dir{ + Name: e.Name(), + } + } + if de, ok := e.(fs.DirEntry); ok { + info, _ := de.Info() + return model.File{ + Name: e.Name(), + Size: info.Size(), + } + } + + if fe, ok := e.(fs.FileInfo); ok { + return model.File{ + Name: fe.Name(), + Size: fe.Size(), + } + } + } + + panic("this dir entry is strange af") +} + // FsListDir is the resolver for the fsListDir field. -func (r *queryResolver) FsListDir(ctx context.Context, path string) ([]model.DirEntry, error) { +func (r *queryResolver) FsListDir(ctx context.Context, path string) (*model.ListDirResponse, error) { + root, err := r.VFS.Stat(ctx, path) + if err != nil { + return nil, err + } + entries, err := r.VFS.ReadDir(ctx, path) if err != nil { return nil, err } out := []model.DirEntry{} for _, e := range entries { - switch e.(type) { - case *vfs.ArchiveFS: - e := e.(*vfs.ArchiveFS) - out = append(out, model.ArchiveFs{ - Name: e.Name(), - Size: e.Size, - }) - case *vfs.ResolverFS: - e := e.(*vfs.ResolverFS) - out = append(out, model.ResolverFs{ - Name: e.Name(), - }) - case *vfs.TorrentFs: - e := e.(*vfs.TorrentFs) - out = append(out, model.TorrentFs{ - Name: e.Name(), - Torrent: model.MapTorrent(e.Torrent), - }) - default: - if e.IsDir() { - out = append(out, model.Dir{Name: e.Name()}) - } else { - info, err := e.Info() - if err != nil { - return nil, err - } - - out = append(out, model.File{ - Name: e.Name(), - Size: info.Size(), - }) - } - } - + out = append(out, fillDirEntry(e)) } - return out, nil + return &model.ListDirResponse{ + Root: fillDirEntry(root), + Entries: out, + }, nil } // Query returns graph.QueryResolver implementation. diff --git a/src/delivery/http.go b/src/delivery/http.go index 244ec16..92dbc55 100644 --- a/src/delivery/http.go +++ b/src/delivery/http.go @@ -15,7 +15,7 @@ import ( "github.com/shurcooL/httpfs/html/vfstemplate" ) -func New(fc *filecache.Cache, ss *service.Stats, s *service.Service, vfs vfs.Filesystem, logPath string, cfg *config.Config) error { +func New(fc *filecache.Cache, ss *service.Stats, s *service.Service, vfs vfs.Filesystem, logPath string, cfg *config.Settings) error { log := slog.With() gin.SetMode(gin.ReleaseMode) diff --git a/src/delivery/router.go b/src/delivery/router.go index d03b2ff..e9e108f 100644 --- a/src/delivery/router.go +++ b/src/delivery/router.go @@ -7,6 +7,7 @@ import ( "git.kmsign.ru/royalcat/tstor/src/delivery/graphql/resolver" "git.kmsign.ru/royalcat/tstor/src/host/service" "git.kmsign.ru/royalcat/tstor/src/host/vfs" + "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler/extension" "github.com/99designs/gqlgen/graphql/handler/lru" @@ -33,7 +34,11 @@ func GraphQLHandler(service *service.Service, vfs vfs.Filesystem) http.Handler { graphqlHandler.SetQueryCache(lru.New(1000)) graphqlHandler.Use(extension.Introspection{}) graphqlHandler.Use(extension.AutomaticPersistedQuery{Cache: lru.New(100)}) - graphqlHandler.Use(otelgqlgen.Middleware()) + graphqlHandler.Use(otelgqlgen.Middleware( + otelgqlgen.WithCreateSpanFromFields(func(ctx *graphql.FieldContext) bool { + return ctx.Field.Directives.ForName("link") != nil + }), + )) return graphqlHandler } diff --git a/src/export/httpfs/httpfs.go b/src/export/httpfs/httpfs.go index 8a7c188..9a03a23 100644 --- a/src/export/httpfs/httpfs.go +++ b/src/export/httpfs/httpfs.go @@ -76,7 +76,7 @@ func (hfs *HTTPFS) filesToFileInfo(name string) ([]fs.FileInfo, error) { return out, nil } -var _ http.File = &httpFile{} +var _ http.File = (*httpFile)(nil) type httpFile struct { f vfs.File @@ -128,5 +128,5 @@ func (f *httpFile) Readdir(count int) ([]fs.FileInfo, error) { } func (f *httpFile) Stat() (fs.FileInfo, error) { - return f.f.Stat() + return f.f.Info() } diff --git a/src/export/nfs/handler.go b/src/export/nfs/handler.go index 6baa241..18ef6bd 100644 --- a/src/export/nfs/handler.go +++ b/src/export/nfs/handler.go @@ -2,11 +2,12 @@ package nfs import ( "log/slog" + "time" + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + nfshelper "git.kmsign.ru/royalcat/tstor/pkg/go-nfs/helpers" "git.kmsign.ru/royalcat/tstor/src/host/vfs" "git.kmsign.ru/royalcat/tstor/src/log" - nfs "github.com/willscott/go-nfs" - nfshelper "github.com/willscott/go-nfs/helpers" ) func NewNFSv3Handler(fs vfs.Filesystem) (nfs.Handler, error) { @@ -14,10 +15,13 @@ func NewNFSv3Handler(fs vfs.Filesystem) (nfs.Handler, error) { nfs.SetLogger(log.NewNFSLog(nfslog)) nfs.Log.SetLevel(nfs.InfoLevel) - bfs := &billyFsWrapper{fs: fs, log: nfslog} + bfs := &fsWrapper{fs: fs, log: nfslog, timeout: time.Minute} handler := nfshelper.NewNullAuthHandler(bfs) - cacheHelper := nfshelper.NewCachingHandler(handler, 1024) + cacheHelper, err := NewKvHandler(handler, bfs) + if err != nil { + return nil, err + } // cacheHelper := NewCachingHandler(handler) diff --git a/src/export/nfs/kvhandler.go b/src/export/nfs/kvhandler.go new file mode 100644 index 0000000..c814405 --- /dev/null +++ b/src/export/nfs/kvhandler.go @@ -0,0 +1,127 @@ +package nfs + +import ( + "context" + "fmt" + "path" + "slices" + "time" + + "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" + "git.kmsign.ru/royalcat/tstor/src/config" + + "github.com/google/uuid" + "github.com/royalcat/kv" +) + +const lifetime = time.Hour * 24 + +// NewKvHandler provides a basic to/from-file handle cache that can be tuned with a smaller cache of active directory listings. +func NewKvHandler(h nfs.Handler, fs nfs.Filesystem) (nfs.Handler, error) { + activeHandles, err := kv.NewBadgerKVMarhsler[uuid.UUID, []string](path.Join(config.Config.Mounts.NFS.CachePath, "handlers")) + if err != nil { + return nil, err + } + + // if s, ok := activeHandles.(kv.BadgerStore); ok { + // db := s.BadgerDB() + // enable with managed database + // go func() { + // for n := range time.NewTimer(lifetime / 2).C { + // db.SetDiscardTs(uint64(n.Add(-lifetime).Unix())) + // } + // }() + // } + + return &CachingHandler{ + Handler: h, + fs: fs, + activeHandles: activeHandles, + }, nil +} + +// CachingHandler implements to/from handle via an LRU cache. +type CachingHandler struct { + nfs.Handler + + fs nfs.Filesystem + activeHandles kv.Store[uuid.UUID, []string] +} + +// ToHandle takes a file and represents it with an opaque handle to reference it. +// In stateless nfs (when it's serving a unix fs) this can be the device + inode +// but we can generalize with a stateful local cache of handed out IDs. +func (c *CachingHandler) ToHandle(_ nfs.Filesystem, path []string) []byte { + + ctx := context.Background() + + var id uuid.UUID + c.activeHandles.Range(ctx, func(k uuid.UUID, v []string) bool { + if slices.Equal(path, v) { + id = k + return false + } + return true + }) + + if id != uuid.Nil { + return id[:] + } + + id = uuid.New() + + c.activeHandles.Set(ctx, id, path) + + return id[:] +} + +// FromHandle converts from an opaque handle to the file it represents +func (c *CachingHandler) FromHandle(fh []byte) (nfs.Filesystem, []string, error) { + ctx := context.Background() + + id, err := uuid.FromBytes(fh) + if err != nil { + return nil, []string{}, err + } + + paths, found, err := c.activeHandles.Get(ctx, id) + if err != nil { + return nil, nil, fmt.Errorf("kv error: %w", err) + } + + if found { + return c.fs, paths, nil + } + + return nil, []string{}, &nfs.NFSStatusError{NFSStatus: nfs.NFSStatusStale} +} + +func (c *CachingHandler) InvalidateHandle(fs nfs.Filesystem, handle []byte) error { + ctx := context.Background() + //Remove from cache + id, err := uuid.FromBytes(handle) + if err != nil { + return err + } + c.activeHandles.Delete(ctx, id) + return nil +} + +const maxInt = int(^uint(0) >> 1) + +// HandleLimit exports how many file handles can be safely stored by this cache. +func (c *CachingHandler) HandleLimit() int { + return maxInt +} + +func hasPrefix(path, prefix []string) bool { + if len(prefix) > len(path) { + return false + } + for i, e := range prefix { + if path[i] != e { + return false + } + } + return true +} diff --git a/src/export/nfs/wrapper-v3.go b/src/export/nfs/wrapper.go similarity index 52% rename from src/export/nfs/wrapper-v3.go rename to src/export/nfs/wrapper.go index 9891deb..753a5f9 100644 --- a/src/export/nfs/wrapper-v3.go +++ b/src/export/nfs/wrapper.go @@ -6,47 +6,45 @@ import ( "io/fs" "log/slog" "path/filepath" + "time" + + "git.kmsign.ru/royalcat/tstor/pkg/ctxbilly" + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" "git.kmsign.ru/royalcat/tstor/src/host/vfs" "github.com/go-git/go-billy/v5" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" - "go.opentelemetry.io/otel/trace" ) -var billyFsTracer = otel.Tracer("git.kmsign.ru/royalcat/tstor/src/export/nfs.billyFsWrapper") - -type billyFsWrapper struct { +type fsWrapper struct { fs vfs.Filesystem log *slog.Logger + + timeout time.Duration } -var _ billy.Filesystem = (*billyFsWrapper)(nil) -var _ billy.Dir = (*billyFsWrapper)(nil) +var _ nfs.Filesystem = (*fsWrapper)(nil) -func (*billyFsWrapper) ctx() context.Context { - return context.Background() -} +// var _ ctxbilly.Dir = (*billyFsWrapper)(nil) // Chroot implements billy.Filesystem. -func (*billyFsWrapper) Chroot(path string) (billy.Filesystem, error) { +func (*fsWrapper) Chroot(path string) (nfs.Filesystem, error) { return nil, billy.ErrNotSupported } // Create implements billy.Filesystem. -func (*billyFsWrapper) Create(filename string) (billy.File, error) { +func (*fsWrapper) Create(ctx context.Context, filename string) (nfs.File, error) { return nil, billy.ErrNotSupported } // Join implements billy.Filesystem. -func (*billyFsWrapper) Join(elem ...string) string { +func (*fsWrapper) Join(elem ...string) string { return filepath.Join(elem...) } // Lstat implements billy.Filesystem. -func (fs *billyFsWrapper) Lstat(filename string) (fs.FileInfo, error) { - ctx, span := billyFsTracer.Start(fs.ctx(), "Lstat", trace.WithAttributes(attribute.String("filename", filename))) - defer span.End() +func (fs *fsWrapper) Lstat(ctx context.Context, filename string) (fs.FileInfo, error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() info, err := fs.fs.Stat(ctx, filename) if err != nil { @@ -56,16 +54,14 @@ func (fs *billyFsWrapper) Lstat(filename string) (fs.FileInfo, error) { } // MkdirAll implements billy.Filesystem. -func (*billyFsWrapper) MkdirAll(filename string, perm fs.FileMode) error { +func (*fsWrapper) MkdirAll(ctx context.Context, filename string, perm fs.FileMode) error { return billy.ErrNotSupported } // Open implements billy.Filesystem. -func (fs *billyFsWrapper) Open(filename string) (billy.File, error) { - ctx, span := billyFsTracer.Start(fs.ctx(), "Open", - trace.WithAttributes(attribute.String("filename", filename)), - ) - defer span.End() +func (fs *fsWrapper) Open(ctx context.Context, filename string) (nfs.File, error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() file, err := fs.fs.Open(ctx, filename) if err != nil { @@ -79,11 +75,9 @@ func (fs *billyFsWrapper) Open(filename string) (billy.File, error) { } // OpenFile implements billy.Filesystem. -func (fs *billyFsWrapper) OpenFile(filename string, flag int, perm fs.FileMode) (billy.File, error) { - ctx, span := billyFsTracer.Start(fs.ctx(), "OpenFile", - trace.WithAttributes(attribute.String("filename", filename)), - ) - defer span.End() +func (fs *fsWrapper) OpenFile(ctx context.Context, filename string, flag int, perm fs.FileMode) (nfs.File, error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() file, err := fs.fs.Open(ctx, filename) if err != nil { @@ -97,11 +91,9 @@ func (fs *billyFsWrapper) OpenFile(filename string, flag int, perm fs.FileMode) } // ReadDir implements billy.Filesystem. -func (bfs *billyFsWrapper) ReadDir(path string) ([]fs.FileInfo, error) { - ctx, span := billyFsTracer.Start(bfs.ctx(), "OpenFile", - trace.WithAttributes(attribute.String("path", path)), - ) - defer span.End() +func (bfs *fsWrapper) ReadDir(ctx context.Context, path string) ([]fs.FileInfo, error) { + ctx, cancel := context.WithTimeout(ctx, bfs.timeout) + defer cancel() ffs, err := bfs.fs.ReadDir(ctx, path) if err != nil { @@ -125,36 +117,32 @@ func (bfs *billyFsWrapper) ReadDir(path string) ([]fs.FileInfo, error) { } // Readlink implements billy.Filesystem. -func (*billyFsWrapper) Readlink(link string) (string, error) { +func (*fsWrapper) Readlink(ctx context.Context, link string) (string, error) { return "", billy.ErrNotSupported } // Remove implements billy.Filesystem. -func (bfs *billyFsWrapper) Remove(filename string) error { - ctx, span := billyFsTracer.Start(bfs.ctx(), "Remove", - trace.WithAttributes(attribute.String("filename", filename)), - ) - defer span.End() +func (bfs *fsWrapper) Remove(ctx context.Context, filename string) error { + ctx, cancel := context.WithTimeout(ctx, bfs.timeout) + defer cancel() return bfs.fs.Unlink(ctx, filename) } // Rename implements billy.Filesystem. -func (*billyFsWrapper) Rename(oldpath string, newpath string) error { +func (*fsWrapper) Rename(ctx context.Context, oldpath string, newpath string) error { return billy.ErrNotSupported } // Root implements billy.Filesystem. -func (*billyFsWrapper) Root() string { +func (*fsWrapper) Root() string { return "/" } // Stat implements billy.Filesystem. -func (bfs *billyFsWrapper) Stat(filename string) (fs.FileInfo, error) { - ctx, span := billyFsTracer.Start(bfs.ctx(), "Remove", - trace.WithAttributes(attribute.String("filename", filename)), - ) - defer span.End() +func (bfs *fsWrapper) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { + ctx, cancel := context.WithTimeout(ctx, bfs.timeout) + defer cancel() info, err := bfs.fs.Stat(ctx, filename) if err != nil { @@ -164,28 +152,21 @@ func (bfs *billyFsWrapper) Stat(filename string) (fs.FileInfo, error) { } // Symlink implements billy.Filesystem. -func (fs *billyFsWrapper) Symlink(target string, link string) error { +func (fs *fsWrapper) Symlink(ctx context.Context, target string, link string) error { return billyErr(nil, vfs.ErrNotImplemented, fs.log) } -// TempFile implements billy.Filesystem. -func (fs *billyFsWrapper) TempFile(dir string, prefix string) (billy.File, error) { - return nil, billyErr(nil, vfs.ErrNotImplemented, fs.log) -} - type billyFile struct { - ctx context.Context - name string file vfs.File log *slog.Logger } -var _ billy.File = (*billyFile)(nil) +var _ ctxbilly.File = (*billyFile)(nil) // Close implements billy.File. -func (f *billyFile) Close() error { - return f.Close() +func (f *billyFile) Close(ctx context.Context) error { + return f.file.Close(ctx) } // Name implements billy.File. @@ -194,31 +175,12 @@ func (f *billyFile) Name() string { } // Read implements billy.File. -func (bf *billyFile) Read(p []byte) (n int, err error) { - ctx, span := billyFsTracer.Start(bf.ctx, "Read", - trace.WithAttributes(attribute.Int("length", len(p))), - ) - defer func() { - span.SetAttributes(attribute.Int("read", n)) - span.End() - }() - +func (bf *billyFile) Read(ctx context.Context, p []byte) (n int, err error) { return bf.file.Read(ctx, p) } // ReadAt implements billy.File. -func (bf *billyFile) ReadAt(p []byte, off int64) (n int, err error) { - ctx, span := billyFsTracer.Start(bf.ctx, "Read", - trace.WithAttributes( - attribute.Int("length", len(p)), - attribute.Int64("offset", off), - ), - ) - defer func() { - span.SetAttributes(attribute.Int("read", n)) - span.End() - }() - +func (bf *billyFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { return bf.file.ReadAt(ctx, p, off) } @@ -228,12 +190,12 @@ func (f *billyFile) Seek(offset int64, whence int) (int64, error) { } // Truncate implements billy.File. -func (f *billyFile) Truncate(size int64) error { +func (f *billyFile) Truncate(ctx context.Context, size int64) error { return billyErr(nil, vfs.ErrNotImplemented, f.log) } // Write implements billy.File. -func (f *billyFile) Write(p []byte) (n int, err error) { +func (f *billyFile) Write(ctx context.Context, p []byte) (n int, err error) { return 0, billyErr(nil, vfs.ErrNotImplemented, f.log) } diff --git a/src/export/webdav/fs.go b/src/export/webdav/fs.go index 1c8fc83..8b21345 100644 --- a/src/export/webdav/fs.go +++ b/src/export/webdav/fs.go @@ -96,6 +96,7 @@ type webDAVFile struct { func newFile(ctx context.Context, name string, f vfs.File, df func() ([]os.FileInfo, error)) *webDAVFile { return &webDAVFile{ ctx: ctx, + f: f, fi: newFileInfo(name, f.Size(), f.IsDir()), dirFunc: df, } diff --git a/src/host/datastorage/piece_storage.go b/src/host/datastorage/piece_storage.go index 26091f8..5197e3b 100644 --- a/src/host/datastorage/piece_storage.go +++ b/src/host/datastorage/piece_storage.go @@ -13,13 +13,12 @@ import ( "github.com/anacrolix/torrent/storage" "github.com/anacrolix/torrent/types/infohash" "github.com/hashicorp/go-multierror" - "github.com/royalcat/kv" ) +// NOT USED type PieceStorage struct { - basePath string - completion storage.PieceCompletion - dirInfohash kv.Store[string, infohash.T] + basePath string + completion storage.PieceCompletion } func NewPieceStorage(path string, completion storage.PieceCompletion) *PieceStorage { @@ -29,8 +28,6 @@ func NewPieceStorage(path string, completion storage.PieceCompletion) *PieceStor } } -var _ DataStorage = (*PieceStorage)(nil) - // OpenTorrent implements FileStorageDeleter. func (p *PieceStorage) OpenTorrent(info *metainfo.Info, infoHash infohash.T) (storage.TorrentImpl, error) { torrentPath := path.Join(p.basePath, infoHash.HexString()) diff --git a/src/host/datastorage/setup.go b/src/host/datastorage/setup.go index 5209385..209a5f3 100644 --- a/src/host/datastorage/setup.go +++ b/src/host/datastorage/setup.go @@ -10,7 +10,7 @@ import ( "github.com/anacrolix/torrent/storage" ) -func Setup(cfg config.TorrentClient) (DataStorage, storage.PieceCompletion, error) { +func Setup(cfg config.TorrentClient) (*DataStorage, storage.PieceCompletion, error) { pcp := filepath.Join(cfg.MetadataFolder, "piece-completion") if err := os.MkdirAll(pcp, 0744); err != nil { return nil, nil, fmt.Errorf("error creating piece completion folder: %w", err) diff --git a/src/host/datastorage/storage.go b/src/host/datastorage/storage.go index c17768e..3587beb 100644 --- a/src/host/datastorage/storage.go +++ b/src/host/datastorage/storage.go @@ -2,49 +2,62 @@ package datastorage import ( "context" + "crypto/sha1" + "fmt" + "io" + "io/fs" "log/slog" "os" "path" "path/filepath" "slices" + "git.kmsign.ru/royalcat/tstor/pkg/rlog" "git.kmsign.ru/royalcat/tstor/src/host/controller" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/storage" + "github.com/dustin/go-humanize" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/exp/maps" + "golang.org/x/sys/unix" ) -type DataStorage interface { - storage.ClientImplCloser - DeleteFile(file *torrent.File) error - CleanupDirs(ctx context.Context, expected []*controller.Torrent, dryRun bool) (int, error) - CleanupFiles(ctx context.Context, expected []*controller.Torrent, dryRun bool) (int, error) -} +// type DataStorage interface { +// storage.ClientImplCloser +// DeleteFile(file *torrent.File) error +// CleanupDirs(ctx context.Context, expected []*controller.Torrent, dryRun bool) (int, error) +// CleanupFiles(ctx context.Context, expected []*controller.Torrent, dryRun bool) (int, error) +// } + +var tracer = otel.Tracer("git.kmsign.ru/royalcat/tstor/src/host/datastorage") // NewFileStorage creates a new ClientImplCloser that stores files using the OS native filesystem. -func NewFileStorage(baseDir string, pc storage.PieceCompletion) *FileStorage { - return &FileStorage{ - baseDir: baseDir, +func NewFileStorage(baseDir string, pc storage.PieceCompletion) *DataStorage { + return &DataStorage{ ClientImplCloser: storage.NewFileOpts(storage.NewFileClientOpts{ ClientBaseDir: baseDir, PieceCompletion: pc, TorrentDirMaker: torrentDir, FilePathMaker: filePath, }), + baseDir: baseDir, pieceCompletion: pc, log: slog.With("component", "torrent-client"), } } // File-based storage for torrents, that isn't yet bound to a particular torrent. -type FileStorage struct { +type DataStorage struct { baseDir string storage.ClientImplCloser pieceCompletion storage.PieceCompletion log *slog.Logger } -func (me *FileStorage) Close() error { +func (me *DataStorage) Close() error { return me.pieceCompletion.Close() } @@ -61,14 +74,14 @@ func filePath(opts storage.FilePathMakerOpts) string { return filepath.Join(opts.File.Path...) } -func (fs *FileStorage) filePath(info *metainfo.Info, infoHash metainfo.Hash, fileInfo *metainfo.FileInfo) string { +func (fs *DataStorage) filePath(info *metainfo.Info, infoHash metainfo.Hash, fileInfo *metainfo.FileInfo) string { return filepath.Join(torrentDir(fs.baseDir, info, infoHash), filePath(storage.FilePathMakerOpts{ Info: info, File: fileInfo, })) } -func (fs *FileStorage) DeleteFile(file *torrent.File) error { +func (fs *DataStorage) DeleteFile(file *torrent.File) error { info := file.Torrent().Info() infoHash := file.Torrent().InfoHash() torrentDir := torrentDir(fs.baseDir, info, infoHash) @@ -88,7 +101,7 @@ func (fs *FileStorage) DeleteFile(file *torrent.File) error { return os.Remove(filePath) } -func (fs *FileStorage) CleanupDirs(ctx context.Context, expected []*controller.Torrent, dryRun bool) (int, error) { +func (fs *DataStorage) CleanupDirs(ctx context.Context, expected []*controller.Torrent, dryRun bool) ([]string, error) { log := fs.log.With("function", "CleanupDirs", "expectedTorrents", len(expected), "dryRun", dryRun) expectedEntries := []string{} @@ -98,7 +111,7 @@ func (fs *FileStorage) CleanupDirs(ctx context.Context, expected []*controller.T entries, err := os.ReadDir(fs.baseDir) if err != nil { - return 0, err + return nil, err } toDelete := []string{} @@ -109,7 +122,7 @@ func (fs *FileStorage) CleanupDirs(ctx context.Context, expected []*controller.T } if ctx.Err() != nil { - return 0, ctx.Err() + return nil, ctx.Err() } log.Info("deleting trash data", "dirsCount", len(toDelete)) @@ -119,40 +132,34 @@ func (fs *FileStorage) CleanupDirs(ctx context.Context, expected []*controller.T log.Warn("deleting trash data", "path", p) err := os.RemoveAll(p) if err != nil { - return i, err + return toDelete[:i], err } } } - return len(toDelete), nil + return toDelete, nil } -// func (fs *FileStorage) IsCompatable(ctx context.Context, addition *controller.Torrent, dryRun bool) (bool, error) { -// log := fs.log.With("function", "IsCompatable", "addition", addition.Name()) - -// ifp -// } - -func (fs *FileStorage) CleanupFiles(ctx context.Context, expected []*controller.Torrent, dryRun bool) (int, error) { - log := fs.log.With("function", "CleanupFiles", "expectedTorrents", len(expected), "dryRun", dryRun) +func (s *DataStorage) CleanupFiles(ctx context.Context, expected []*controller.Torrent, dryRun bool) ([]string, error) { + log := s.log.With("function", "CleanupFiles", "expectedTorrents", len(expected), "dryRun", dryRun) expectedEntries := []string{} { for _, e := range expected { files, err := e.Files(ctx) if err != nil { - return 0, err + return nil, err } for _, f := range files { - expectedEntries = append(expectedEntries, fs.filePath(e.Torrent().Info(), e.Torrent().InfoHash(), ptr(f.FileInfo()))) + expectedEntries = append(expectedEntries, s.filePath(e.Torrent().Info(), e.Torrent().InfoHash(), ptr(f.FileInfo()))) } } } entries := []string{} - err := filepath.Walk(fs.baseDir, - func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(s.baseDir, + func(path string, info fs.DirEntry, err error) error { if err != nil { return err } @@ -167,7 +174,7 @@ func (fs *FileStorage) CleanupFiles(ctx context.Context, expected []*controller. return nil }) if err != nil { - return 0, err + return nil, err } toDelete := []string{} @@ -178,20 +185,243 @@ func (fs *FileStorage) CleanupFiles(ctx context.Context, expected []*controller. } if ctx.Err() != nil { - return len(toDelete), ctx.Err() + return toDelete, ctx.Err() } log.Info("deleting trash data", "filesCount", len(toDelete)) if !dryRun { for i, p := range toDelete { - fs.log.Warn("deleting trash data", "path", p) + s.log.Warn("deleting trash data", "path", p) err := os.Remove(p) if err != nil { - return i, err + return toDelete[i:], err } } } - return len(toDelete), nil + return toDelete, nil +} + +func (s *DataStorage) iterFiles(ctx context.Context, iter func(ctx context.Context, path string, entry fs.FileInfo) error) error { + return filepath.Walk(s.baseDir, + func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + + if info.IsDir() { + return nil + } + + return iter(ctx, path, info) + }) +} + +func (s *DataStorage) Dedupe(ctx context.Context) (uint64, error) { + ctx, span := tracer.Start(ctx, fmt.Sprintf("Dedupe")) + defer span.End() + + log := rlog.FunctionLog(s.log, "Dedupe") + + sizeMap := map[int64][]string{} + err := s.iterFiles(ctx, func(ctx context.Context, path string, info fs.FileInfo) error { + size := info.Size() + sizeMap[size] = append(sizeMap[size], path) + return nil + }) + if err != nil { + return 0, err + } + + maps.DeleteFunc(sizeMap, func(k int64, v []string) bool { + return len(v) <= 1 + }) + + span.AddEvent("collected files with same size", trace.WithAttributes( + attribute.Int("count", len(sizeMap)), + )) + + var deduped uint64 = 0 + + i := 0 + for _, paths := range sizeMap { + if i%100 == 0 { + log.Info("deduping in progress", "current", i, "total", len(sizeMap)) + } + i++ + + if ctx.Err() != nil { + return deduped, ctx.Err() + } + + slices.Sort(paths) + paths = slices.Compact(paths) + if len(paths) <= 1 { + continue + } + + paths, err = applyErr(paths, filepath.Abs) + if err != nil { + return deduped, err + } + + dedupedGroup, err := s.dedupeFiles(ctx, paths) + if err != nil { + log.Error("Error applying dedupe", "files", paths, "error", err.Error()) + continue + } + + if dedupedGroup > 0 { + deduped += dedupedGroup + log.Info("deduped file group", + slog.String("files", fmt.Sprint(paths)), + slog.String("deduped", humanize.Bytes(dedupedGroup)), + slog.String("deduped_total", humanize.Bytes(deduped)), + ) + } + + } + + return deduped, nil +} + +func applyErr[E, O any](in []E, apply func(E) (O, error)) ([]O, error) { + out := make([]O, 0, len(in)) + for _, p := range in { + o, err := apply(p) + if err != nil { + return out, err + } + out = append(out, o) + + } + return out, nil +} + +// const blockSize uint64 = 4096 + +func (s *DataStorage) dedupeFiles(ctx context.Context, paths []string) (deduped uint64, err error) { + ctx, span := tracer.Start(ctx, fmt.Sprintf("dedupeFiles"), trace.WithAttributes( + attribute.StringSlice("files", paths), + )) + defer func() { + span.SetAttributes(attribute.Int64("deduped", int64(deduped))) + if err != nil { + span.RecordError(err) + } + span.End() + }() + + log := rlog.FunctionLog(s.log, "dedupeFiles") + + srcF, err := os.Open(paths[0]) + if err != nil { + return deduped, err + } + defer srcF.Close() + srcStat, err := srcF.Stat() + if err != nil { + return deduped, err + } + + srcFd := int(srcF.Fd()) + srcSize := srcStat.Size() + + fsStat := unix.Statfs_t{} + err = unix.Fstatfs(srcFd, &fsStat) + if err != nil { + span.RecordError(err) + return deduped, err + } + + srcHash, err := filehash(srcF) + if err != nil { + return deduped, err + } + + if fsStat.Bsize > srcSize { // for btrfs it means file in residing in not deduplicatable metadata + return deduped, nil + } + + blockSize := uint64((srcSize % fsStat.Bsize) * fsStat.Bsize) + + span.SetAttributes(attribute.Int64("blocksize", int64(blockSize))) + + rng := unix.FileDedupeRange{ + Src_offset: 0, + Src_length: blockSize, + Info: []unix.FileDedupeRangeInfo{}, + } + + for _, dst := range paths[1:] { + if ctx.Err() != nil { + return deduped, ctx.Err() + } + + destF, err := os.OpenFile(dst, os.O_RDWR, os.ModePerm) + if err != nil { + return deduped, err + } + defer destF.Close() + + dstHash, err := filehash(destF) + if err != nil { + return deduped, err + } + + if srcHash != dstHash { + destF.Close() + continue + } + + rng.Info = append(rng.Info, unix.FileDedupeRangeInfo{ + Dest_fd: int64(destF.Fd()), + Dest_offset: 0, + }) + } + + if len(rng.Info) == 0 { + return deduped, nil + } + + log.Info("found same files, deduping", "files", paths, "size", humanize.Bytes(uint64(srcStat.Size()))) + + if ctx.Err() != nil { + return deduped, ctx.Err() + } + + rng.Src_offset = 0 + for i := range rng.Info { + rng.Info[i].Dest_offset = 0 + } + + err = unix.IoctlFileDedupeRange(srcFd, &rng) + if err != nil { + return deduped, err + } + + for i := range rng.Info { + deduped += rng.Info[i].Bytes_deduped + + rng.Info[i].Status = 0 + rng.Info[i].Bytes_deduped = 0 + } + + return deduped, nil +} + +const compareBlockSize = 1024 * 128 + +func filehash(r io.Reader) ([20]byte, error) { + buf := make([]byte, compareBlockSize) + _, err := r.Read(buf) + if err != nil && err != io.EOF { + return [20]byte{}, err + } + + return sha1.Sum(buf), nil } func ptr[D any](v D) *D { diff --git a/src/host/service/service.go b/src/host/service/service.go index c746119..a5809c7 100644 --- a/src/host/service/service.go +++ b/src/host/service/service.go @@ -1,21 +1,27 @@ package service import ( + "bufio" "context" "fmt" "log/slog" "os" - "path" "path/filepath" "slices" "strings" + "sync" "git.kmsign.ru/royalcat/tstor/pkg/ctxio" + "git.kmsign.ru/royalcat/tstor/pkg/rlog" "git.kmsign.ru/royalcat/tstor/src/config" "git.kmsign.ru/royalcat/tstor/src/host/controller" "git.kmsign.ru/royalcat/tstor/src/host/datastorage" "git.kmsign.ru/royalcat/tstor/src/host/store" + "git.kmsign.ru/royalcat/tstor/src/host/tkv" "git.kmsign.ru/royalcat/tstor/src/host/vfs" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "go.uber.org/multierr" "golang.org/x/exp/maps" @@ -27,6 +33,8 @@ import ( "github.com/royalcat/kv" ) +var tracer = otel.Tracer("git.kmsign.ru/royalcat/tstor/src/service") + type DirAquire struct { Name string Hashes []infohash.T @@ -39,9 +47,11 @@ type Service struct { torrentLoaded chan struct{} + loadMutex sync.Mutex + // stats *Stats DefaultPriority types.PiecePriority - Storage datastorage.DataStorage + Storage *datastorage.DataStorage SourceDir string dirsAquire kv.Store[string, DirAquire] @@ -50,9 +60,9 @@ type Service struct { } func NewService(sourceDir string, cfg config.TorrentClient, c *torrent.Client, - storage datastorage.DataStorage, excludedFiles *store.FilesMappings, infoBytes *store.InfoBytes, + storage *datastorage.DataStorage, excludedFiles *store.FilesMappings, infoBytes *store.InfoBytes, ) (*Service, error) { - dirsAcquire, err := kv.NewBadgerKV[string, DirAquire](path.Join(cfg.MetadataFolder, "dir-acquire")) + dirsAcquire, err := tkv.New[string, DirAquire](cfg.MetadataFolder, "dir-acquire") if err != nil { return nil, err } @@ -66,12 +76,15 @@ func NewService(sourceDir string, cfg config.TorrentClient, c *torrent.Client, Storage: storage, SourceDir: sourceDir, torrentLoaded: make(chan struct{}), + loadMutex: sync.Mutex{}, dirsAquire: dirsAcquire, + // stats: newStats(), // TODO persistent } go func() { - err := s.loadTorrentFiles(context.Background()) + ctx := context.Background() + err := s.loadTorrentFiles(ctx) if err != nil { s.log.Error("initial torrent load failed", "error", err) } @@ -89,20 +102,32 @@ func (s *Service) Close() error { return err } -func (s *Service) AddTorrent(ctx context.Context, f vfs.File) (*torrent.Torrent, error) { +func (s *Service) LoadTorrent(ctx context.Context, f vfs.File) (*torrent.Torrent, error) { + ctx, span := tracer.Start(ctx, "LoadTorrent") + defer span.End() + + log := rlog.FunctionLog(s.log, "LoadTorrent") + defer f.Close(ctx) - stat, err := f.Stat() + stat, err := f.Info() if err != nil { return nil, fmt.Errorf("call stat failed: %w", err) } - mi, err := metainfo.Load(ctxio.IoReader(ctx, f)) + span.SetAttributes(attribute.String("filename", stat.Name())) + + mi, err := metainfo.Load(bufio.NewReader(ctxio.IoReader(ctx, f))) if err != nil { return nil, fmt.Errorf("loading torrent metadata from file %s, error: %w", stat.Name(), err) } + t, ok := s.c.Torrent(mi.HashInfoBytes()) if !ok { + + span.AddEvent("torrent not found, loading from file") + log.InfoContext(ctx, "torrent not found, loading from file") + spec, err := torrent.TorrentSpecFromMetaInfoErr(mi) if err != nil { return nil, fmt.Errorf("parse spec from metadata: %w", err) @@ -110,33 +135,18 @@ func (s *Service) AddTorrent(ctx context.Context, f vfs.File) (*torrent.Torrent, infoBytes := spec.InfoBytes if !isValidInfoHashBytes(infoBytes) { + log.WarnContext(ctx, "info loaded from spec not valid") infoBytes = nil } if len(infoBytes) == 0 { + log.InfoContext(ctx, "no info loaded from file, try to load from cache") infoBytes, err = s.infoBytes.GetBytes(spec.InfoHash) if err != nil && err != store.ErrNotFound { return nil, fmt.Errorf("get info bytes from database: %w", err) } } - var info metainfo.Info - err = bencode.Unmarshal(infoBytes, &info) - if err != nil { - infoBytes = nil - } else { - compatable, _, err := s.checkTorrentCompatable(ctx, spec.InfoHash, info) - if err != nil { - return nil, err - } - if !compatable { - return nil, fmt.Errorf( - "torrent with name '%s' not compatable existing infohash: %s, new: %s", - t.Name(), t.InfoHash().HexString(), spec.InfoHash.HexString(), - ) - } - } - t, _ = s.c.AddTorrentOpt(torrent.AddTorrentOpts{ InfoHash: spec.InfoHash, Storage: s.Storage, @@ -146,18 +156,33 @@ func (s *Service) AddTorrent(ctx context.Context, f vfs.File) (*torrent.Torrent, t.AllowDataDownload() t.AllowDataUpload() + span.AddEvent("torrent added to client") + select { case <-ctx.Done(): - return nil, fmt.Errorf("creating torrent timed out") + return nil, ctx.Err() case <-t.GotInfo(): err := s.infoBytes.Set(t.InfoHash(), t.Metainfo()) if err != nil { s.log.Error("error setting info bytes for torrent %s: %s", t.Name(), err.Error()) } - for _, f := range t.Files() { - f.SetPriority(s.DefaultPriority) - } + } + span.AddEvent("got info") + info := t.Info() + if info == nil { + return nil, fmt.Errorf("info is nil") + } + + compatable, _, err := s.checkTorrentCompatable(ctx, spec.InfoHash, *info) + if err != nil { + return nil, err + } + if !compatable { + return nil, fmt.Errorf( + "torrent with name '%s' not compatable existing infohash: %s, new: %s", + t.Name(), t.InfoHash().HexString(), spec.InfoHash.HexString(), + ) } } @@ -271,15 +296,15 @@ func (s *Service) checkTorrentFilesCompatable(aq DirAquire, existingFiles, newFi return true } -func (s *Service) getTorrentsByName(name string) []*torrent.Torrent { - out := []*torrent.Torrent{} - for _, t := range s.c.Torrents() { - if t.Name() == name { - out = append(out, t) - } - } - return out -} +// func (s *Service) getTorrentsByName(name string) []*torrent.Torrent { +// out := []*torrent.Torrent{} +// for _, t := range s.c.Torrents() { +// if t.Name() == name { +// out = append(out, t) +// } +// } +// return out +// } func isValidInfoHashBytes(d []byte) bool { var info metainfo.Info @@ -290,12 +315,12 @@ func isValidInfoHashBytes(d []byte) bool { func (s *Service) NewTorrentFs(ctx context.Context, f vfs.File) (vfs.Filesystem, error) { defer f.Close(ctx) - info, err := f.Stat() + info, err := f.Info() if err != nil { return nil, err } - t, err := s.AddTorrent(ctx, f) + t, err := s.LoadTorrent(ctx, f) if err != nil { return nil, err } @@ -311,7 +336,46 @@ func (s *Service) GetStats() torrent.ConnStats { return s.c.ConnStats() } +const loadWorkers = 5 + func (s *Service) loadTorrentFiles(ctx context.Context) error { + ctx, span := tracer.Start(ctx, "loadTorrentFiles", trace.WithAttributes( + attribute.Int("workers", loadWorkers), + )) + defer span.End() + + log := rlog.FunctionLog(s.log, "loadTorrentFiles") + + loaderPaths := make(chan string) + wg := sync.WaitGroup{} + + defer func() { + close(loaderPaths) + wg.Wait() + }() + + loaderWorker := func() { + wg.Add(1) + for path := range loaderPaths { + file, err := vfs.NewLazyOsFile(path) + if err != nil { + log.Error("error opening torrent file", "filename", path, rlog.Err(err)) + continue + } + defer file.Close(ctx) + + _, err = s.LoadTorrent(ctx, file) + if err != nil { + s.log.Error("failed adding torrent", "error", err) + } + } + wg.Done() + } + + for range loadWorkers { + go loaderWorker() + } + return filepath.Walk(s.SourceDir, func(path string, info os.FileInfo, err error) error { if err != nil { return fmt.Errorf("fs walk error: %w", err) @@ -326,13 +390,7 @@ func (s *Service) loadTorrentFiles(ctx context.Context) error { } if strings.HasSuffix(path, ".torrent") { - file := vfs.NewLazyOsFile(path) - defer file.Close(ctx) - - _, err = s.AddTorrent(ctx, file) - if err != nil { - s.log.Error("failed adding torrent", "error", err) - } + loaderPaths <- path } return nil diff --git a/src/host/tkv/new.go b/src/host/tkv/new.go new file mode 100644 index 0000000..3f740ea --- /dev/null +++ b/src/host/tkv/new.go @@ -0,0 +1,21 @@ +package tkv + +import ( + "path" + + "git.kmsign.ru/royalcat/tstor/pkg/kvtrace" + "github.com/royalcat/kv" + "go.opentelemetry.io/otel/attribute" +) + +func New[K kv.Bytes, V any](dbdir, name string) (store kv.Store[K, V], err error) { + dir := path.Join(dbdir, name) + store, err = kv.NewBadgerKV[K, V](dir) + if err != nil { + return nil, err + } + + store = kvtrace.WrapTracing(store, attribute.String("collection", name), attribute.String("database", "badger")) + + return store, err +} diff --git a/src/host/vfs/archive.go b/src/host/vfs/archive.go index cf1e1a6..4717ec8 100644 --- a/src/host/vfs/archive.go +++ b/src/host/vfs/archive.go @@ -3,40 +3,40 @@ package vfs import ( "archive/zip" "context" + "fmt" "io" "io/fs" - "os" "path" - "path/filepath" "strings" + "sync" + "time" "git.kmsign.ru/royalcat/tstor/pkg/ctxio" - "git.kmsign.ru/royalcat/tstor/src/iio" "github.com/bodgit/sevenzip" "github.com/nwaples/rardecode/v2" ) var ArchiveFactories = map[string]FsFactory{ ".zip": func(ctx context.Context, f File) (Filesystem, error) { - stat, err := f.Stat() + stat, err := f.Info() if err != nil { return nil, err } - return NewArchive(ctx, stat.Name(), f, stat.Size(), ZipLoader), nil + return NewArchive(ctx, stat.Name(), f, stat.Size(), ZipLoader) }, ".rar": func(ctx context.Context, f File) (Filesystem, error) { - stat, err := f.Stat() + stat, err := f.Info() if err != nil { return nil, err } - return NewArchive(ctx, stat.Name(), f, stat.Size(), RarLoader), nil + return NewArchive(ctx, stat.Name(), f, stat.Size(), RarLoader) }, ".7z": func(ctx context.Context, f File) (Filesystem, error) { - stat, err := f.Stat() + stat, err := f.Info() if err != nil { return nil, err } - return NewArchive(ctx, stat.Name(), f, stat.Size(), SevenZipLoader), nil + return NewArchive(ctx, stat.Name(), f, stat.Size(), SevenZipLoader) }, } @@ -47,52 +47,73 @@ var _ Filesystem = &ArchiveFS{} type ArchiveFS struct { name string - r ctxio.ReaderAt + size int64 - Size int64 - - files func() (map[string]File, error) + files map[string]File } -func NewArchive(ctx context.Context, name string, r ctxio.ReaderAt, size int64, loader archiveLoader) *ArchiveFS { - return &ArchiveFS{ - name: name, - r: r, - Size: size, - files: OnceValueWOErr(func() (map[string]File, error) { - zipFiles, err := loader(ctx, r, size) - if err != nil { - return nil, err - } - // TODO make optional - singleDir := true - for k := range zipFiles { - if !strings.HasPrefix(k, "/"+name+"/") { - singleDir = false - break - } - } +// ModTime implements Filesystem. +func (a *ArchiveFS) ModTime() time.Time { + return time.Time{} +} - files := make(map[string]File, len(zipFiles)) - for k, v := range zipFiles { - // TODO make optional - if strings.Contains(k, "/__MACOSX/") { - continue - } +// Mode implements Filesystem. +func (a *ArchiveFS) Mode() fs.FileMode { + return fs.ModeDir +} - if singleDir { - k, _ = strings.CutPrefix(k, "/"+name) - } +// Size implements Filesystem. +func (a *ArchiveFS) Size() int64 { + return int64(a.size) +} - files[k] = v - } +// Sys implements Filesystem. +func (a *ArchiveFS) Sys() any { + return nil +} - // FIXME - files["/.forcegallery"] = NewMemoryFile(".forcegallery", []byte{}) +// FsName implements Filesystem. +func (a *ArchiveFS) FsName() string { + return "archivefs" +} - return files, nil - }), +func NewArchive(ctx context.Context, name string, r ctxio.ReaderAt, size int64, loader archiveLoader) (*ArchiveFS, error) { + archiveFiles, err := loader(ctx, r, size) + if err != nil { + return nil, err } + + // TODO make optional + singleDir := true + for k := range archiveFiles { + if !strings.HasPrefix(k, "/"+name+"/") { + singleDir = false + break + } + } + + files := make(map[string]File, len(archiveFiles)) + for k, v := range archiveFiles { + // TODO make optional + if strings.Contains(k, "/__MACOSX/") { + continue + } + + if singleDir { + k, _ = strings.CutPrefix(k, "/"+name) + } + + files[k] = v + } + + // FIXME + files["/.forcegallery"] = NewMemoryFile(".forcegallery", []byte{}) + + return &ArchiveFS{ + name: name, + size: size, + files: files, + }, nil } // Unlink implements Filesystem. @@ -101,35 +122,21 @@ func (a *ArchiveFS) Unlink(ctx context.Context, filename string) error { } func (a *ArchiveFS) Open(ctx context.Context, filename string) (File, error) { - files, err := a.files() - if err != nil { - return nil, err - } - - return getFile(files, filename) + return getFile(a.files, filename) } -func (fs *ArchiveFS) ReadDir(ctx context.Context, path string) ([]fs.DirEntry, error) { - files, err := fs.files() - if err != nil { - return nil, err - } - - return listDirFromFiles(files, path) +func (a *ArchiveFS) ReadDir(ctx context.Context, path string) ([]fs.DirEntry, error) { + return listDirFromFiles(a.files, path) } // Stat implements Filesystem. func (afs *ArchiveFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { - files, err := afs.files() - if err != nil { - return nil, err + + if file, ok := afs.files[filename]; ok { + return file.Info() } - if file, ok := files[filename]; ok { - return file.Stat() - } - - for p, _ := range files { + for p, _ := range afs.files { if strings.HasPrefix(p, filename) { return newDirInfo(path.Base(filename)), nil } @@ -140,11 +147,7 @@ func (afs *ArchiveFS) Stat(ctx context.Context, filename string) (fs.FileInfo, e // Info implements Filesystem. func (a *ArchiveFS) Info() (fs.FileInfo, error) { - return &fileInfo{ - name: a.name, - size: a.Size, - isDir: true, - }, nil + return a, nil } // IsDir implements Filesystem. @@ -162,42 +165,46 @@ func (a *ArchiveFS) Type() fs.FileMode { return fs.ModeDir } -var _ File = &archiveFile{} +var _ File = (*archiveFile)(nil) -func NewArchiveFile(name string, readerFunc func() (iio.Reader, error), size int64) *archiveFile { +func NewArchiveFile(name string, size int64, af archiveFileReaderFactory) *archiveFile { return &archiveFile{ - name: name, - readerFunc: readerFunc, - size: size, + name: name, + size: size, + af: af, + + buffer: ctxio.NewFileBuffer(nil), } } +const readahead = 1024 * 16 + type archiveFile struct { name string + size int64 + af archiveFileReaderFactory - readerFunc func() (iio.Reader, error) - reader iio.Reader - size int64 + m sync.Mutex + + offset int64 + readen int64 + buffer *ctxio.FileBuffer } -func (d *archiveFile) Stat() (fs.FileInfo, error) { +// Name implements File. +func (d *archiveFile) Name() string { + return d.name +} + +// Type implements File. +func (d *archiveFile) Type() fs.FileMode { + return roMode +} + +func (d *archiveFile) Info() (fs.FileInfo, error) { return newFileInfo(d.name, d.size), nil } -func (d *archiveFile) load() error { - if d.reader != nil { - return nil - } - r, err := d.readerFunc() - if err != nil { - return err - } - - d.reader = r - - return nil -} - func (d *archiveFile) Size() int64 { return d.size } @@ -206,31 +213,60 @@ func (d *archiveFile) IsDir() bool { return false } -func (d *archiveFile) Close(ctx context.Context) (err error) { - if d.reader != nil { - err = d.reader.Close() - d.reader = nil +func (d *archiveFile) Close(ctx context.Context) error { + return d.buffer.Close(ctx) +} + +func (d *archiveFile) loadMore(ctx context.Context, to int64) error { + d.m.Lock() + defer d.m.Unlock() + + if to < d.readen { + return nil } - return + reader, err := d.af(ctx) + if err != nil { + return fmt.Errorf("failed to get file reader: %w", err) + } + _, err = d.buffer.Seek(0, io.SeekStart) + if err != nil { + return fmt.Errorf("failed to seek to start of the file: %w", err) + } + d.readen, err = ctxio.CopyN(ctx, d.buffer, ctxio.WrapIoReader(reader), to+readahead) + if err != nil && err != io.EOF { + return fmt.Errorf("error copying from archive file reader: %w", err) + } + + return nil } func (d *archiveFile) Read(ctx context.Context, p []byte) (n int, err error) { - if err := d.load(); err != nil { - return 0, err + err = d.loadMore(ctx, d.offset+int64(len(p))) + if err != nil { + return 0, fmt.Errorf("failed to load more from archive file: %w", err) } - - return d.reader.Read(p) + n, err = d.buffer.Read(ctx, p) + if err != nil && err != io.EOF { + return n, fmt.Errorf("failed to read from buffer: %w", err) + } + return n, nil } func (d *archiveFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { - if err := d.load(); err != nil { - return 0, err + err = d.loadMore(ctx, off+int64(len(p))) + if err != nil { + return 0, fmt.Errorf("failed to load more from archive file: %w", err) } - - return d.reader.ReadAt(p, off) + n, err = d.buffer.ReadAt(ctx, p, off) + if err != nil && err != io.EOF { + return n, fmt.Errorf("failed to read from buffer: %w", err) + } + return n, nil } +type archiveFileReaderFactory func(ctx context.Context) (io.ReadCloser, error) + var _ archiveLoader = ZipLoader func ZipLoader(ctx context.Context, ctxreader ctxio.ReaderAt, size int64) (map[string]*archiveFile, error) { @@ -248,16 +284,24 @@ func ZipLoader(ctx context.Context, ctxreader ctxio.ReaderAt, size int64) (map[s continue } - rf := func() (iio.Reader, error) { - zr, err := zipFile.Open() + i := i + af := func(ctx context.Context) (io.ReadCloser, error) { + reader := ctxio.IoReaderAt(ctx, ctxreader) + + zr, err := zip.NewReader(reader, size) if err != nil { return nil, err } - return iio.NewDiskTeeReader(zr) + rc, err := zr.File[i].Open() + if err != nil { + return nil, err + } + + return rc, nil } - out[AbsPath(zipFile.Name)] = NewArchiveFile(zipFile.Name, rf, zipFile.FileInfo().Size()) + out[AbsPath(zipFile.Name)] = NewArchiveFile(zipFile.Name, zipFile.FileInfo().Size(), af) } return out, nil @@ -274,25 +318,29 @@ func SevenZipLoader(ctx context.Context, ctxreader ctxio.ReaderAt, size int64) ( } out := make(map[string]*archiveFile) - for _, f := range r.File { + for i, f := range r.File { f := f if f.FileInfo().IsDir() { continue } - rf := func() (iio.Reader, error) { - zr, err := f.Open() + i := i + af := func(ctx context.Context) (io.ReadCloser, error) { + reader := ctxio.IoReaderAt(ctx, ctxreader) + zr, err := sevenzip.NewReader(reader, size) if err != nil { return nil, err } - return iio.NewDiskTeeReader(zr) + rc, err := zr.File[i].Open() + if err != nil { + return nil, err + } + + return rc, nil } - af := NewArchiveFile(f.Name, rf, f.FileInfo().Size()) - n := filepath.Join(string(os.PathSeparator), f.Name) - - out[n] = af + out[AbsPath(f.Name)] = NewArchiveFile(f.Name, f.FileInfo().Size(), af) } return out, nil @@ -318,15 +366,26 @@ func RarLoader(ctx context.Context, ctxreader ctxio.ReaderAt, size int64) (map[s return nil, err } - rf := func() (iio.Reader, error) { - return iio.NewDiskTeeReader(r) + name := header.Name + af := func(ctx context.Context) (io.ReadCloser, error) { + reader := ctxio.IoReadSeekerWrapper(ctx, ctxreader, size) + r, err := rardecode.NewReader(reader) + if err != nil { + return nil, err + } + + for header, err := r.Next(); err != io.EOF; header, err = r.Next() { + if err != nil { + return nil, err + } + if header.Name == name { + return io.NopCloser(r), nil + } + } + return nil, fmt.Errorf("file with name '%s' not found", name) } - n := filepath.Join(string(os.PathSeparator), header.Name) - - af := NewArchiveFile(header.Name, rf, header.UnPackedSize) - - out[n] = af + out[AbsPath(header.Name)] = NewArchiveFile(header.Name, header.UnPackedSize, af) } return out, nil diff --git a/src/host/vfs/archive_test.go b/src/host/vfs/archive_test.go index c6ae444..443abe2 100644 --- a/src/host/vfs/archive_test.go +++ b/src/host/vfs/archive_test.go @@ -1,4 +1,4 @@ -package vfs +package vfs_test import ( "archive/zip" @@ -8,9 +8,35 @@ import ( "testing" "git.kmsign.ru/royalcat/tstor/pkg/ctxio" + "git.kmsign.ru/royalcat/tstor/src/host/vfs" "github.com/stretchr/testify/require" ) +// TODO +// func TestArchiveFactories(t *testing.T) { +// t.Parallel() + +// ctx := context.Background() + +// require := require.New(t) + +// require.Contains(vfs.ArchiveFactories, ".zip") +// require.Contains(vfs.ArchiveFactories, ".rar") +// require.Contains(vfs.ArchiveFactories, ".7z") + +// fs, err := vfs.ArchiveFactories[".zip"](ctx, &vfs.DummyFile{}) +// require.NoError(err) +// require.NotNil(fs) + +// fs, err = vfs.ArchiveFactories[".rar"](ctx, &vfs.DummyFile{}) +// require.NoError(err) +// require.NotNil(fs) + +// fs, err = vfs.ArchiveFactories[".7z"](ctx, &vfs.DummyFile{}) +// require.NoError(err) +// require.NotNil(fs) +// } + var fileContent []byte = []byte("Hello World") func TestZipFilesystem(t *testing.T) { @@ -22,7 +48,8 @@ func TestZipFilesystem(t *testing.T) { ctx := context.Background() // TODO add single dir collapse test - zfs := NewArchive(ctx, "test", zReader, size, ZipLoader) + zfs, err := vfs.NewArchive(ctx, "test", zReader, size, vfs.ZipLoader) + require.NoError(err) files, err := zfs.ReadDir(ctx, "/path/to/test/file") require.NoError(err) diff --git a/src/host/vfs/dir.go b/src/host/vfs/dir.go index 6d77249..51b78cc 100644 --- a/src/host/vfs/dir.go +++ b/src/host/vfs/dir.go @@ -6,39 +6,54 @@ import ( "path" ) -var _ File = &dir{} +var _ File = &dirFile{} -func NewDir(name string) File { - return &dir{ +func newDirFile(name string) File { + return &dirFile{ name: path.Base(name), } } -type dir struct { +type dirFile struct { name string } -// Info implements File. -func (d *dir) Stat() (fs.FileInfo, error) { - return newDirInfo(d.name), nil -} - -func (d *dir) Size() int64 { - return 0 -} - -func (d *dir) IsDir() bool { - return true -} - -func (d *dir) Close(ctx context.Context) error { +// Close implements File. +func (d *dirFile) Close(ctx context.Context) error { return nil } -func (d *dir) Read(ctx context.Context, p []byte) (n int, err error) { - return 0, nil +// Info implements File. +func (d *dirFile) Info() (fs.FileInfo, error) { + return newDirInfo(d.name), nil } -func (d *dir) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { - return 0, nil +// IsDir implements File. +func (d *dirFile) IsDir() bool { + return true +} + +// Name implements File. +func (d *dirFile) Name() string { + return d.name +} + +// Read implements File. +func (d *dirFile) Read(ctx context.Context, p []byte) (n int, err error) { + return 0, fs.ErrInvalid +} + +// ReadAt implements File. +func (d *dirFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + return 0, fs.ErrInvalid +} + +// Size implements File. +func (d *dirFile) Size() int64 { + return 0 +} + +// Type implements File. +func (d *dirFile) Type() fs.FileMode { + return roMode | fs.ModeDir } diff --git a/src/host/vfs/dummy.go b/src/host/vfs/dummy.go new file mode 100644 index 0000000..0f57da7 --- /dev/null +++ b/src/host/vfs/dummy.go @@ -0,0 +1,125 @@ +package vfs + +import ( + "context" + "io/fs" + "os" + "path" + "time" +) + +var _ Filesystem = &DummyFs{} + +type DummyFs struct { + name string +} + +// ModTime implements Filesystem. +func (d *DummyFs) ModTime() time.Time { + return time.Time{} +} + +// Mode implements Filesystem. +func (d *DummyFs) Mode() fs.FileMode { + return fs.ModeDir +} + +// Size implements Filesystem. +func (d *DummyFs) Size() int64 { + panic("unimplemented") +} + +// Sys implements Filesystem. +func (d *DummyFs) Sys() any { + panic("unimplemented") +} + +// FsName implements Filesystem. +func (d *DummyFs) FsName() string { + return "dummyfs" +} + +// Stat implements Filesystem. +func (*DummyFs) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { + return newFileInfo(path.Base(filename), 0), nil // TODO +} + +func (d *DummyFs) Open(ctx context.Context, filename string) (File, error) { + return &DummyFile{}, nil +} + +func (d *DummyFs) Unlink(ctx context.Context, filename string) error { + return ErrNotImplemented +} + +func (d *DummyFs) ReadDir(ctx context.Context, path string) ([]fs.DirEntry, error) { + if path == "/dir/here" { + return []fs.DirEntry{ + newFileInfo("file1.txt", 0), + newFileInfo("file2.txt", 0), + }, nil + } + + return nil, os.ErrNotExist +} + +// Info implements Filesystem. +func (d *DummyFs) Info() (fs.FileInfo, error) { + return newDirInfo(d.name), nil +} + +// IsDir implements Filesystem. +func (d *DummyFs) IsDir() bool { + return true +} + +// Name implements Filesystem. +func (d *DummyFs) Name() string { + return d.name +} + +// Type implements Filesystem. +func (d *DummyFs) Type() fs.FileMode { + return fs.ModeDir +} + +var _ File = &DummyFile{} + +type DummyFile struct { + name string +} + +// Name implements File. +func (d *DummyFile) Name() string { + panic("unimplemented") +} + +// Type implements File. +func (d *DummyFile) Type() fs.FileMode { + panic("unimplemented") +} + +// Stat implements File. +func (d *DummyFile) Info() (fs.FileInfo, error) { + return newFileInfo(d.name, 0), nil +} + +func (d *DummyFile) Size() int64 { + return 0 +} + +func (d *DummyFile) IsDir() bool { + return false +} + +func (d *DummyFile) Close(ctx context.Context) error { + return nil +} + +func (d *DummyFile) Read(ctx context.Context, p []byte) (n int, err error) { + return 0, nil +} + +func (d *DummyFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + return 0, nil +} diff --git a/src/host/vfs/fs.go b/src/host/vfs/fs.go index 018967f..64f5547 100644 --- a/src/host/vfs/fs.go +++ b/src/host/vfs/fs.go @@ -8,12 +8,14 @@ import ( "time" "git.kmsign.ru/royalcat/tstor/pkg/ctxio" + "go.opentelemetry.io/otel" ) type File interface { IsDir() bool Size() int64 - Stat() (fs.FileInfo, error) + + fs.DirEntry ctxio.Reader ctxio.ReaderAt @@ -22,6 +24,8 @@ type File interface { var ErrNotImplemented = errors.New("not implemented") +var tracer = otel.Tracer("git.kmsign.ru/royalcat/tstor/src/host/vfs") + type Filesystem interface { // Open opens the named file for reading. If successful, methods on the // returned file can be used for reading; the associated file descriptor has @@ -35,10 +39,12 @@ type Filesystem interface { Stat(ctx context.Context, filename string) (fs.FileInfo, error) Unlink(ctx context.Context, filename string) error + // As filesystem mounted to some path, make sense to have the filesystem implement DirEntry fs.DirEntry } -const defaultMode = fs.FileMode(0555) +// readonly +const roMode = fs.FileMode(0555) type fileInfo struct { name string @@ -87,10 +93,10 @@ func (fi *fileInfo) Size() int64 { func (fi *fileInfo) Mode() fs.FileMode { if fi.isDir { - return defaultMode | fs.ModeDir + return roMode | fs.ModeDir } - return defaultMode + return roMode } func (fi *fileInfo) ModTime() time.Time { diff --git a/src/host/vfs/fs_test.go b/src/host/vfs/fs_test.go index 3caef23..3ea2dcc 100644 --- a/src/host/vfs/fs_test.go +++ b/src/host/vfs/fs_test.go @@ -37,7 +37,7 @@ func TestDirInfo(t *testing.T) { require.NotNil(fi.ModTime()) require.NotZero(fi.Type() & fs.ModeDir) require.NotZero(fi.Mode() & fs.ModeDir) - require.Equal(defaultMode|fs.ModeDir, fi.Mode()) + require.Equal(roMode|fs.ModeDir, fi.Mode()) require.Nil(fi.Sys()) } diff --git a/src/host/vfs/log.go b/src/host/vfs/log.go index 7560f26..1c19e1f 100644 --- a/src/host/vfs/log.go +++ b/src/host/vfs/log.go @@ -5,22 +5,62 @@ import ( "io/fs" "log/slog" "reflect" + "time" + + "git.kmsign.ru/royalcat/tstor/pkg/rlog" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" ) type LogFS struct { fs Filesystem log *slog.Logger + + timeout time.Duration + readTimeout time.Duration } var _ Filesystem = (*LogFS)(nil) -func WrapLogFS(fs Filesystem, log *slog.Logger) *LogFS { +func WrapLogFS(fs Filesystem) *LogFS { return &LogFS{ - fs: fs, - log: log.With("component", "fs"), + fs: fs, + log: rlog.ComponentLog("fs"), + timeout: time.Minute * 3, + readTimeout: time.Minute, } } +// ModTime implements Filesystem. +func (lfs *LogFS) ModTime() time.Time { + return lfs.ModTime() +} + +// Mode implements Filesystem. +func (lfs *LogFS) Mode() fs.FileMode { + return lfs.Mode() +} + +// Size implements Filesystem. +func (lfs *LogFS) Size() int64 { + return lfs.Size() +} + +// Sys implements Filesystem. +func (lfs *LogFS) Sys() any { + return lfs.Sys() +} + +func (fs *LogFS) FsName() string { + return "logfs" +} + +func (fs *LogFS) traceAttrs(add ...attribute.KeyValue) trace.SpanStartOption { + return trace.WithAttributes(append([]attribute.KeyValue{ + attribute.String("fs", fs.FsName()), + }, add...)...) +} + // Info implements Filesystem. func (fs *LogFS) Info() (fs.FileInfo, error) { return fs.fs.Info() @@ -42,36 +82,84 @@ func (fs *LogFS) Type() fs.FileMode { } // Open implements Filesystem. -func (fs *LogFS) Open(ctx context.Context, filename string) (File, error) { - file, err := fs.fs.Open(ctx, filename) +func (fs *LogFS) Open(ctx context.Context, filename string) (file File, err error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "Open", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + file, err = fs.fs.Open(ctx, filename) if err != nil { fs.log.With("filename", filename).Error("Failed to open file") } - file = WrapLogFile(file, filename, fs.log) + file = WrapLogFile(file, filename, fs.log, fs.readTimeout) return file, err } // ReadDir implements Filesystem. -func (fs *LogFS) ReadDir(ctx context.Context, path string) ([]fs.DirEntry, error) { - file, err := fs.fs.ReadDir(ctx, path) +func (fs *LogFS) ReadDir(ctx context.Context, path string) (entries []fs.DirEntry, err error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "ReadDir", + fs.traceAttrs(attribute.String("path", path)), + ) + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + entries, err = fs.fs.ReadDir(ctx, path) if err != nil { fs.log.ErrorContext(ctx, "Failed to read dir", "path", path, "error", err.Error(), "fs-type", reflect.TypeOf(fs.fs).Name()) } - return file, err + return entries, err } // Stat implements Filesystem. -func (fs *LogFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { - file, err := fs.fs.Stat(ctx, filename) +func (fs *LogFS) Stat(ctx context.Context, filename string) (info fs.FileInfo, err error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "Stat", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + info, err = fs.fs.Stat(ctx, filename) if err != nil { fs.log.Error("Failed to stat", "filename", filename, "error", err) } - return file, err + return info, err } // Unlink implements Filesystem. -func (fs *LogFS) Unlink(ctx context.Context, filename string) error { - err := fs.fs.Unlink(ctx, filename) +func (fs *LogFS) Unlink(ctx context.Context, filename string) (err error) { + ctx, cancel := context.WithTimeout(ctx, fs.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "Unlink", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + err = fs.fs.Unlink(ctx, filename) if err != nil { fs.log.Error("Failed to stat", "filename", filename, "error", err) } @@ -79,24 +167,51 @@ func (fs *LogFS) Unlink(ctx context.Context, filename string) error { } type LogFile struct { - f File - log *slog.Logger + filename string + f File + + log *slog.Logger + timeout time.Duration +} + +// Name implements File. +func (f *LogFile) Name() string { + return f.f.Name() +} + +// Type implements File. +func (f *LogFile) Type() fs.FileMode { + return f.f.Type() } var _ File = (*LogFile)(nil) -func WrapLogFile(f File, filename string, log *slog.Logger) *LogFile { +func WrapLogFile(f File, filename string, log *slog.Logger, timeout time.Duration) *LogFile { return &LogFile{ - f: f, - log: log.With("filename", filename), + filename: filename, + f: f, + log: log.With("filename", filename), + timeout: timeout, } } // Close implements File. -func (f *LogFile) Close(ctx context.Context) error { - err := f.f.Close(ctx) +func (f *LogFile) Close(ctx context.Context) (err error) { + ctx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "Close", + trace.WithAttributes(attribute.String("filename", f.filename)), + ) + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + + err = f.f.Close(ctx) if err != nil { - f.log.Error("Failed to close", "error", err) + f.log.ErrorContext(ctx, "Failed to close", "error", err) } return err } @@ -108,6 +223,22 @@ func (f *LogFile) IsDir() bool { // Read implements File. func (f *LogFile) Read(ctx context.Context, p []byte) (n int, err error) { + ctx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "Read", + trace.WithAttributes( + attribute.String("filename", f.filename), + attribute.Int("length", len(p)), + ), + ) + defer func() { + span.SetAttributes(attribute.Int("read", n)) + if err != nil { + span.RecordError(err) + } + span.End() + }() + n, err = f.f.Read(ctx, p) if err != nil { f.log.Error("Failed to read", "error", err) @@ -117,6 +248,22 @@ func (f *LogFile) Read(ctx context.Context, p []byte) (n int, err error) { // ReadAt implements File. func (f *LogFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + ctx, cancel := context.WithTimeout(ctx, f.timeout) + defer cancel() + ctx, span := tracer.Start(ctx, "ReadAt", + trace.WithAttributes( + attribute.String("filename", f.filename), + attribute.Int("length", len(p)), + ), + ) + defer func() { + span.SetAttributes(attribute.Int("read", n)) + if err != nil { + span.RecordError(err) + } + span.End() + }() + n, err = f.f.ReadAt(ctx, p, off) if err != nil { f.log.Error("Failed to read", "offset", off, "error", err) @@ -130,8 +277,8 @@ func (f *LogFile) Size() int64 { } // Stat implements File. -func (f *LogFile) Stat() (fs.FileInfo, error) { - info, err := f.f.Stat() +func (f *LogFile) Info() (fs.FileInfo, error) { + info, err := f.f.Info() if err != nil { f.log.Error("Failed to read", "error", err) } diff --git a/src/host/vfs/memory.go b/src/host/vfs/memory.go index 79d2f0e..8f4f84f 100644 --- a/src/host/vfs/memory.go +++ b/src/host/vfs/memory.go @@ -5,15 +5,41 @@ import ( "context" "io/fs" "path" + "time" ) -var _ Filesystem = &MemoryFs{} - type MemoryFs struct { name string files map[string]*MemoryFile } +var _ Filesystem = (*MemoryFs)(nil) + +// ModTime implements Filesystem. +func (mfs *MemoryFs) ModTime() time.Time { + return time.Time{} +} + +// Mode implements Filesystem. +func (mfs *MemoryFs) Mode() fs.FileMode { + return fs.ModeDir +} + +// Size implements Filesystem. +func (fs *MemoryFs) Size() int64 { + return 0 +} + +// Sys implements Filesystem. +func (fs *MemoryFs) Sys() any { + return nil +} + +// FsKind implements Filesystem. +func (fs *MemoryFs) FsName() string { + return "memoryfs" +} + // Info implements Filesystem. func (fs *MemoryFs) Info() (fs.FileInfo, error) { return newDirInfo(fs.name), nil @@ -77,7 +103,17 @@ func NewMemoryFile(name string, data []byte) *MemoryFile { } } -func (d *MemoryFile) Stat() (fs.FileInfo, error) { +// Name implements File. +func (d *MemoryFile) Name() string { + return d.name +} + +// Type implements File. +func (d *MemoryFile) Type() fs.FileMode { + return roMode +} + +func (d *MemoryFile) Info() (fs.FileInfo, error) { return newFileInfo(d.name, int64(d.data.Len())), nil } diff --git a/src/host/vfs/os.go b/src/host/vfs/os.go index 18daf11..27e8f1a 100644 --- a/src/host/vfs/os.go +++ b/src/host/vfs/os.go @@ -12,13 +12,19 @@ type OsFS struct { hostDir string } +var _ Filesystem = (*OsFS)(nil) + // Stat implements Filesystem. func (fs *OsFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { if path.Clean(filename) == Separator { return newDirInfo(Separator), nil } - return os.Stat(path.Join(fs.hostDir, filename)) + info, err := os.Stat(path.Join(fs.hostDir, filename)) + if err != nil { + return nil, err + } + return info, nil } // Unlink implements Filesystem. @@ -28,11 +34,11 @@ func (fs *OsFS) Unlink(ctx context.Context, filename string) error { // Open implements Filesystem. func (fs *OsFS) Open(ctx context.Context, filename string) (File, error) { - if path.Clean(filename) == Separator { - return NewDir(filename), nil + if isRoot(filename) { + return newDirFile(fs.Name()), nil } - return NewLazyOsFile(path.Join(fs.hostDir, filename)), nil + return NewLazyOsFile(path.Join(fs.hostDir, filename)) } // ReadDir implements Filesystem. @@ -42,7 +48,7 @@ func (o *OsFS) ReadDir(ctx context.Context, dir string) ([]fs.DirEntry, error) { // Info implements Filesystem. func (fs *OsFS) Info() (fs.FileInfo, error) { - return newDirInfo(path.Base(fs.hostDir)), nil + return newDirInfo(fs.Name()), nil } // IsDir implements Filesystem. @@ -68,56 +74,16 @@ func NewOsFs(osDir string) *OsFS { var _ Filesystem = &OsFS{} -type OsFile struct { - f *os.File -} - -func NewOsFile(f *os.File) *OsFile { - return &OsFile{f: f} -} - -var _ File = &OsFile{} - -// Info implements File. -func (f *OsFile) Info() (fs.FileInfo, error) { - return f.f.Stat() -} - -// Close implements File. -func (f *OsFile) Close(ctx context.Context) error { - return f.f.Close() -} - -// Read implements File. -func (f *OsFile) Read(ctx context.Context, p []byte) (n int, err error) { - return f.f.Read(p) -} - -// ReadAt implements File. -func (f *OsFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { - return f.f.ReadAt(p, off) -} - -func (f *OsFile) Stat() (fs.FileInfo, error) { - return f.f.Stat() -} - -// Size implements File. -func (f *OsFile) Size() int64 { - stat, err := f.Stat() +func NewLazyOsFile(path string) (*LazyOsFile, error) { + info, err := os.Stat(path) if err != nil { - return 0 + return nil, err } - return stat.Size() -} -// IsDir implements File. -func (f *OsFile) IsDir() bool { - stat, err := f.Stat() - if err != nil { - return false - } - return stat.IsDir() + return &LazyOsFile{ + path: path, + info: info, + }, nil } type LazyOsFile struct { @@ -125,15 +91,10 @@ type LazyOsFile struct { path string file *os.File - // cached field info fs.FileInfo } -func NewLazyOsFile(path string) *LazyOsFile { - return &LazyOsFile{path: path} -} - -var _ File = &OsFile{} +var _ File = (*LazyOsFile)(nil) func (f *LazyOsFile) open() error { f.m.Lock() @@ -151,6 +112,16 @@ func (f *LazyOsFile) open() error { return nil } +// Name implements File. +func (f *LazyOsFile) Name() string { + return path.Base(f.path) +} + +// Type implements File. +func (f *LazyOsFile) Type() fs.FileMode { + return f.info.Mode() +} + // Close implements File. func (f *LazyOsFile) Close(ctx context.Context) error { if f.file == nil { @@ -177,41 +148,17 @@ func (f *LazyOsFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, er return f.file.ReadAt(p, off) } -func (f *LazyOsFile) Stat() (fs.FileInfo, error) { - f.m.Lock() - defer f.m.Unlock() - if f.info == nil { - if f.file == nil { - info, err := os.Stat(f.path) - if err != nil { - return nil, err - } - f.info = info - } else { - info, err := f.file.Stat() - if err != nil { - return nil, err - } - f.info = info - } - } +func (f *LazyOsFile) Info() (fs.FileInfo, error) { + return f.info, nil } // Size implements File. func (f *LazyOsFile) Size() int64 { - stat, err := f.Stat() - if err != nil { - return 0 - } - return stat.Size() + return f.info.Size() } // IsDir implements File. func (f *LazyOsFile) IsDir() bool { - stat, err := f.Stat() - if err != nil { - return false - } - return stat.IsDir() + return f.info.IsDir() } diff --git a/src/host/vfs/os_test.go b/src/host/vfs/os_test.go new file mode 100644 index 0000000..06f26e3 --- /dev/null +++ b/src/host/vfs/os_test.go @@ -0,0 +1,75 @@ +package vfs_test + +import ( + "context" + "os" + "testing" + + "git.kmsign.ru/royalcat/tstor/src/host/vfs" + "github.com/stretchr/testify/require" +) + +func TestOsFs(t *testing.T) { + const testDir = "/tmp/tstor-test" + require := require.New(t) + ctx := context.Background() + + err := os.RemoveAll(testDir) + require.NotErrorIs(err, os.ErrNotExist) + defer func() { + err = os.RemoveAll(testDir) + require.NotErrorIs(err, os.ErrNotExist) + }() + + err = os.MkdirAll(testDir, os.ModePerm) + require.NoError(err) + + err = os.MkdirAll(testDir+"/dir1", os.ModePerm) + require.NoError(err) + err = os.MkdirAll(testDir+"/dir1/dir2", os.ModePerm) + require.NoError(err) + err = os.MkdirAll(testDir+"/dir1/dir3", os.ModePerm) + require.NoError(err) + osfile, err := os.Create(testDir + "/dir1/dir2/file") + require.NoError(err) + err = osfile.Close() + require.NoError(err) + + fs := vfs.NewOsFs(testDir) + + dirs := []string{"/", "/.", "/dir1", "/dir1/dir2"} + + for _, dir := range dirs { + file, err := fs.Open(ctx, dir) + require.NoError(err) + require.True(file.IsDir()) + stat, err := file.Info() + require.NoError(err) + require.True(stat.IsDir()) + require.NoError(file.Close(ctx)) + + info, err := fs.Stat(ctx, dir) + require.NoError(err) + require.True(info.IsDir()) + + entries, err := fs.ReadDir(ctx, dir) + require.NoError(err) + + for _, e := range entries { + switch e.Name() { + case "dir2", "dir1", "dir3": + require.False(e.Type().IsRegular()) + require.True(e.Type().IsDir()) + require.True(e.IsDir()) + case "file": + require.True(e.Type().IsRegular()) + require.False(e.Type().IsDir()) + require.False(e.IsDir()) + } + } + } + + file, err := fs.Open(ctx, "/dir1/dir2/file") + require.NoError(err) + require.False(file.IsDir()) +} diff --git a/src/host/vfs/resolver.go b/src/host/vfs/resolver.go index d434099..6573ab5 100644 --- a/src/host/vfs/resolver.go +++ b/src/host/vfs/resolver.go @@ -2,28 +2,84 @@ package vfs import ( "context" + "errors" "fmt" "io/fs" + "log/slog" "path" + "reflect" "slices" "strings" "sync" + "time" + + "git.kmsign.ru/royalcat/tstor/pkg/rlog" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/exp/maps" ) type ResolverFS struct { rootFS Filesystem resolver *resolver + + log *slog.Logger } func NewResolveFS(rootFs Filesystem, factories map[string]FsFactory) *ResolverFS { return &ResolverFS{ rootFS: rootFs, resolver: newResolver(factories), + log: rlog.ComponentLog("fs/resolverfs"), } } +// ModTime implements Filesystem. +func (r *ResolverFS) ModTime() time.Time { + return time.Time{} +} + +// Mode implements Filesystem. +func (r *ResolverFS) Mode() fs.FileMode { + return fs.ModeDir +} + +// Size implements Filesystem. +func (r *ResolverFS) Size() int64 { + return 0 +} + +// Sys implements Filesystem. +func (r *ResolverFS) Sys() any { + return nil +} + +// FsName implements Filesystem. +func (r *ResolverFS) FsName() string { + return "resolverfs" +} + +func (fs *ResolverFS) traceAttrs(add ...attribute.KeyValue) trace.SpanStartOption { + return trace.WithAttributes(append([]attribute.KeyValue{ + attribute.String("fs", fs.FsName()), + }, add...)...) +} + +func (r *ResolverFS) ResolvablesExtensions() []string { + return maps.Keys(r.resolver.factories) +} + // Open implements Filesystem. func (r *ResolverFS) Open(ctx context.Context, filename string) (File, error) { + ctx, span := tracer.Start(ctx, "Open", + r.traceAttrs(attribute.String("filename", filename)), + ) + defer span.End() + + if path.Clean(filename) == Separator { + return newDirFile(r.Name()), nil + } + fsPath, nestedFs, nestedFsPath, err := r.resolver.resolvePath(ctx, filename, r.rootFS.Open) if err != nil { return nil, err @@ -37,6 +93,11 @@ func (r *ResolverFS) Open(ctx context.Context, filename string) (File, error) { // ReadDir implements Filesystem. func (r *ResolverFS) ReadDir(ctx context.Context, dir string) ([]fs.DirEntry, error) { + ctx, span := tracer.Start(ctx, "ReadDir", + r.traceAttrs(attribute.String("name", dir)), + ) + defer span.End() + fsPath, nestedFs, nestedFsPath, err := r.resolver.resolvePath(ctx, dir, r.rootFS.Open) if err != nil { return nil, err @@ -57,8 +118,14 @@ func (r *ResolverFS) ReadDir(ctx context.Context, dir string) ([]fs.DirEntry, er if err != nil { return nil, err } + defer file.Close(ctx) nestedfs, err := r.resolver.nestedFs(ctx, filepath, file) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + r.log.ErrorContext(ctx, "creating fs timed out", "filename", e.Name()) + continue + } + return nil, err } @@ -72,11 +139,23 @@ func (r *ResolverFS) ReadDir(ctx context.Context, dir string) ([]fs.DirEntry, er // Stat implements Filesystem. func (r *ResolverFS) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { + ctx, span := tracer.Start(ctx, "Stat", + r.traceAttrs(attribute.String("filename", filename)), + ) + defer span.End() + + if isRoot(filename) { + return r, nil + } + fsPath, nestedFs, nestedFsPath, err := r.resolver.resolvePath(ctx, filename, r.rootFS.Open) if err != nil { return nil, err } + span.SetAttributes(attribute.String("fsPath", fsPath), attribute.String("nestedFsPath", nestedFsPath)) + if nestedFs != nil { + span.AddEvent("calling nested fs") return nestedFs.Stat(ctx, nestedFsPath) } @@ -98,7 +177,7 @@ func (r *ResolverFS) Unlink(ctx context.Context, filename string) error { // Info implements Filesystem. func (r *ResolverFS) Info() (fs.FileInfo, error) { - return newDirInfo(r.rootFS.Name()), nil + return r, nil } // IsDir implements Filesystem. @@ -108,7 +187,7 @@ func (r *ResolverFS) IsDir() bool { // Name implements Filesystem. func (r *ResolverFS) Name() string { - return r.Name() + return r.rootFS.Name() } // Type implements Filesystem. @@ -120,8 +199,6 @@ var _ Filesystem = &ResolverFS{} type FsFactory func(ctx context.Context, f File) (Filesystem, error) -const Separator = "/" - func newResolver(factories map[string]FsFactory) *resolver { return &resolver{ factories: factories, @@ -171,6 +248,9 @@ func (r *resolver) nestedFs(ctx context.Context, fsPath string, file File) (File // open requeue raw open, without resolver call func (r *resolver) resolvePath(ctx context.Context, name string, rawOpen openFile) (fsPath string, nestedFs Filesystem, nestedFsPath string, err error) { + ctx, span := tracer.Start(ctx, "resolvePath") + defer span.End() + name = path.Clean(name) name = strings.TrimPrefix(name, Separator) parts := strings.Split(name, Separator) @@ -205,8 +285,12 @@ PARTS_LOOP: defer r.m.Unlock() if nestedFs, ok := r.fsmap[fsPath]; ok { + span.AddEvent("fs loaded from cache", trace.WithAttributes(attribute.String("nestedFs", reflect.TypeOf(nestedFs).Name()))) return fsPath, nestedFs, nestedFsPath, nil } else { + ctx, span := tracer.Start(ctx, "CreateFS") + defer span.End() + fsFile, err := rawOpen(ctx, fsPath) if err != nil { return "", nil, "", fmt.Errorf("error opening filesystem file: %s with error: %w", fsPath, err) @@ -217,6 +301,8 @@ PARTS_LOOP: } r.fsmap[fsPath] = nestedFs + span.AddEvent("fs created", trace.WithAttributes(attribute.String("nestedFs", reflect.TypeOf(nestedFs).Name()))) + return fsPath, nestedFs, nestedFsPath, nil } @@ -226,7 +312,7 @@ var ErrNotExist = fs.ErrNotExist func getFile[F File](m map[string]F, name string) (File, error) { if name == Separator { - return NewDir(name), nil + return newDirFile(name), nil } f, ok := m[name] @@ -236,7 +322,7 @@ func getFile[F File](m map[string]F, name string) (File, error) { for p := range m { if strings.HasPrefix(p, name) { - return NewDir(name), nil + return newDirFile(name), nil } } diff --git a/src/host/vfs/resolver_test.go b/src/host/vfs/resolver_test.go index ab801f9..4b927fd 100644 --- a/src/host/vfs/resolver_test.go +++ b/src/host/vfs/resolver_test.go @@ -1,235 +1,237 @@ -package vfs +package vfs_test import ( + "archive/zip" + "bytes" "context" - "io/fs" - "os" - "path" "testing" + "git.kmsign.ru/royalcat/tstor/src/host/vfs" "github.com/stretchr/testify/require" ) -type Dummy struct { - name string -} +func createZip(files map[string][]byte) ([]byte, error) { + buf := bytes.NewBuffer(nil) + zw := zip.NewWriter(buf) -// Stat implements File. -func (d *Dummy) Stat() (fs.FileInfo, error) { - return newFileInfo(d.name, 0), nil -} + for name, data := range files { + fw, err := zw.Create(name) + if err != nil { + return nil, err + } -func (d *Dummy) Size() int64 { - return 0 -} - -func (d *Dummy) IsDir() bool { - return false -} - -func (d *Dummy) Close(ctx context.Context) error { - return nil -} - -func (d *Dummy) Read(ctx context.Context, p []byte) (n int, err error) { - return 0, nil -} - -func (d *Dummy) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { - return 0, nil -} - -var _ File = &Dummy{} - -type DummyFs struct { - name string -} - -// Stat implements Filesystem. -func (*DummyFs) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { - return newFileInfo(path.Base(filename), 0), nil // TODO -} - -func (d *DummyFs) Open(ctx context.Context, filename string) (File, error) { - return &Dummy{}, nil -} - -func (d *DummyFs) Unlink(ctx context.Context, filename string) error { - return ErrNotImplemented -} - -func (d *DummyFs) ReadDir(ctx context.Context, path string) ([]fs.DirEntry, error) { - if path == "/dir/here" { - return []fs.DirEntry{ - newFileInfo("file1.txt", 0), - newFileInfo("file2.txt", 0), - }, nil + _, err = fw.Write(data) + if err != nil { + return nil, err + } + } + err := zw.Flush() + if err != nil { + return nil, err } - return nil, os.ErrNotExist + err = zw.Close() + if err != nil { + return nil, err + } + + return buf.Bytes(), nil } -// Info implements Filesystem. -func (d *DummyFs) Info() (fs.FileInfo, error) { - return newDirInfo(d.name), nil -} - -// IsDir implements Filesystem. -func (d *DummyFs) IsDir() bool { - return true -} - -// Name implements Filesystem. -func (d *DummyFs) Name() string { - return d.name -} - -// Type implements Filesystem. -func (d *DummyFs) Type() fs.FileMode { - return fs.ModeDir -} - -var _ Filesystem = &DummyFs{} - -func TestResolver(t *testing.T) { +func TestResolverFs(t *testing.T) { t.Parallel() - resolver := newResolver(ArchiveFactories) ctx := context.Background() - t.Run("nested fs", func(t *testing.T) { - t.Parallel() - require := require.New(t) - - fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "/f1.rar/f2.rar", func(_ context.Context, path string) (File, error) { - require.Equal("/f1.rar", path) - return &Dummy{}, nil - }) - require.NoError(err) - require.Equal("/f1.rar", fsPath) - require.Equal("/f2.rar", nestedFsPath) - require.IsType(&ArchiveFS{}, nestedFs) + testZip, err := createZip(map[string][]byte{ + "123.txt": []byte("123"), + "files/321.txt": []byte("321"), }) - t.Run("root", func(t *testing.T) { + require.NoError(t, err) + + fs := vfs.NewResolveFS(vfs.NewMemoryFS("/", map[string]*vfs.MemoryFile{ + "/data/123.zip": vfs.NewMemoryFile("123.zip", testZip), + }), vfs.ArchiveFactories) + + t.Run("dir", func(t *testing.T) { t.Parallel() require := require.New(t) - fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "/", func(_ context.Context, path string) (File, error) { - require.Equal("/", path) - return &Dummy{}, nil - }) - require.NoError(err) - require.Nil(nestedFs) - require.Equal("/", fsPath) - require.Equal("", nestedFsPath) - }) + dirs := []string{ + "/data", "/", "/.", + "/data/123.zip", "/data/123.zip/files", "/data/123.zip/files/.", + } - t.Run("root dirty", func(t *testing.T) { - t.Parallel() - require := require.New(t) + for _, dir := range dirs { + file, err := fs.Open(ctx, dir) + require.NoError(err) + require.True(file.IsDir()) - fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "//.//", func(_ context.Context, path string) (File, error) { - require.Equal("/", path) - return &Dummy{}, nil - }) - require.NoError(err) - require.Nil(nestedFs) - require.Equal("/", fsPath) - require.Equal("", nestedFsPath) - }) - t.Run("fs dirty", func(t *testing.T) { - t.Parallel() - require := require.New(t) + stat, err := file.Info() + require.NoError(err) + require.True(stat.IsDir()) + } - fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "//.//f1.rar", func(_ context.Context, path string) (File, error) { - require.Equal("/f1.rar", path) - return &Dummy{}, nil - }) + entries, err := fs.ReadDir(ctx, "/data") require.NoError(err) - require.Equal("/f1.rar", fsPath) - require.Equal("/", nestedFsPath) - require.IsType(&ArchiveFS{}, nestedFs) - }) - t.Run("inside folder", func(t *testing.T) { - t.Parallel() - require := require.New(t) + require.Len(entries, 1) - fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "//test1/f1.rar", func(_ context.Context, path string) (File, error) { - require.Equal("/test1/f1.rar", path) - return &Dummy{}, nil - }) + for _, e := range entries { + switch e.Name() { + case "123.zip": + require.True(e.IsDir()) + require.IsType(&vfs.ArchiveFS{}, e) + } + } + + entries, err = fs.ReadDir(ctx, "/data/123.zip/files") require.NoError(err) - require.IsType(&ArchiveFS{}, nestedFs) - require.Equal("/test1/f1.rar", fsPath) - require.Equal("/", nestedFsPath) + require.Len(entries, 1) + + entries, err = fs.ReadDir(ctx, "/data/123.zip") + require.NoError(err) + require.Len(entries, 3) + + for _, e := range entries { + switch e.Name() { + case "files": + require.True(e.IsDir()) + case "123.txt": + require.False(e.IsDir()) + } + } }) } -func TestArchiveFactories(t *testing.T) { - t.Parallel() +// func TestResolver(t *testing.T) { +// t.Parallel() +// resolver := newResolver(ArchiveFactories) +// ctx := context.Background() - ctx := context.Background() +// t.Run("nested fs", func(t *testing.T) { +// t.Parallel() +// require := require.New(t) - require := require.New(t) +// fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "/f1.rar/f2.rar", func(_ context.Context, path string) (File, error) { +// require.Equal("/f1.rar", path) +// return &vfs.Dummy{}, nil +// }) +// require.NoError(err) +// require.Equal("/f1.rar", fsPath) +// require.Equal("/f2.rar", nestedFsPath) +// require.IsType(&vfs.ArchiveFS{}, nestedFs) +// }) +// t.Run("root", func(t *testing.T) { +// t.Parallel() +// require := require.New(t) - require.Contains(ArchiveFactories, ".zip") - require.Contains(ArchiveFactories, ".rar") - require.Contains(ArchiveFactories, ".7z") +// fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "/", func(_ context.Context, path string) (File, error) { +// require.Equal("/", path) +// return &Dummy{}, nil +// }) +// require.NoError(err) +// require.Nil(nestedFs) +// require.Equal("/", fsPath) +// require.Equal("", nestedFsPath) +// }) - fs, err := ArchiveFactories[".zip"](ctx, &Dummy{}) - require.NoError(err) - require.NotNil(fs) +// t.Run("root dirty", func(t *testing.T) { +// t.Parallel() +// require := require.New(t) - fs, err = ArchiveFactories[".rar"](ctx, &Dummy{}) - require.NoError(err) - require.NotNil(fs) +// fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "//.//", func(_ context.Context, path string) (File, error) { +// require.Equal("/", path) +// return &Dummy{}, nil +// }) +// require.NoError(err) +// require.Nil(nestedFs) +// require.Equal("/", fsPath) +// require.Equal("", nestedFsPath) +// }) - fs, err = ArchiveFactories[".7z"](ctx, &Dummy{}) - require.NoError(err) - require.NotNil(fs) -} +// t.Run("root dirty 2", func(t *testing.T) { +// t.Parallel() +// require := require.New(t) -func TestFiles(t *testing.T) { - t.Parallel() - require := require.New(t) +// fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "/.", func(_ context.Context, path string) (File, error) { +// require.Equal("/", path) +// return &Dummy{}, nil +// }) +// require.NoError(err) +// require.Nil(nestedFs) +// require.Equal("/", fsPath) +// require.Equal("", nestedFsPath) +// }) - files := map[string]*Dummy{ - "/test/file.txt": &Dummy{}, - "/test/file2.txt": &Dummy{}, - "/test1/file.txt": &Dummy{}, - } - { - file, err := getFile(files, "/test") - require.NoError(err) - require.Equal(&dir{name: "test"}, file) - } - { - file, err := getFile(files, "/test/file.txt") - require.NoError(err) - require.Equal(&Dummy{}, file) - } - { - out, err := listDirFromFiles(files, "/test") - require.NoError(err) - require.Len(out, 2) - require.Equal("file.txt", out[0].Name()) - require.Equal("file2.txt", out[1].Name()) - require.False(out[0].IsDir()) - require.False(out[1].IsDir()) - } - { - out, err := listDirFromFiles(files, "/test1") - require.NoError(err) - require.Len(out, 1) - require.Equal("file.txt", out[0].Name()) - require.False(out[0].IsDir()) - } - { - out, err := listDirFromFiles(files, "/") - require.NoError(err) - require.Len(out, 2) - require.Equal("test", out[0].Name()) - require.Equal("test1", out[1].Name()) - require.True(out[0].IsDir()) - require.True(out[1].IsDir()) - } -} +// t.Run("fs dirty", func(t *testing.T) { +// t.Parallel() +// require := require.New(t) + +// fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "//.//f1.rar", func(_ context.Context, path string) (File, error) { +// require.Equal("/f1.rar", path) +// return &Dummy{}, nil +// }) +// require.NoError(err) +// require.Equal("/f1.rar", fsPath) +// require.Equal("/", nestedFsPath) +// require.IsType(&ArchiveFS{}, nestedFs) +// }) +// t.Run("inside folder", func(t *testing.T) { +// t.Parallel() +// require := require.New(t) + +// fsPath, nestedFs, nestedFsPath, err := resolver.resolvePath(ctx, "//test1/f1.rar", func(_ context.Context, path string) (File, error) { +// require.Equal("/test1/f1.rar", path) +// return &Dummy{}, nil +// }) +// require.NoError(err) +// require.IsType(&ArchiveFS{}, nestedFs) +// require.Equal("/test1/f1.rar", fsPath) +// require.Equal("/", nestedFsPath) +// }) +// } + +// func TestFiles(t *testing.T) { +// t.Parallel() +// require := require.New(t) + +// files := map[string]*vfs.DummyFile{ +// "/test/file.txt": &vfs.DummyFile{}, +// "/test/file2.txt": &vfs.DummyFile{}, +// "/test1/file.txt": &vfs.DummyFile{}, +// } +// { +// file, err := getFile(files, "/test") +// require.NoError(err) +// require.Equal(&dir{name: "test"}, file) +// } +// { +// file, err := getFile(files, "/test/file.txt") +// require.NoError(err) +// require.Equal(&Dummy{}, file) +// } +// { +// out, err := listDirFromFiles(files, "/test") +// require.NoError(err) +// require.Len(out, 2) +// require.Equal("file.txt", out[0].Name()) +// require.Equal("file2.txt", out[1].Name()) +// require.False(out[0].IsDir()) +// require.False(out[1].IsDir()) +// } +// { +// out, err := listDirFromFiles(files, "/test1") +// require.NoError(err) +// require.Len(out, 1) +// require.Equal("file.txt", out[0].Name()) +// require.False(out[0].IsDir()) +// } +// { +// out, err := listDirFromFiles(files, "/") +// require.NoError(err) +// require.Len(out, 2) +// require.Equal("test", out[0].Name()) +// require.Equal("test1", out[1].Name()) +// require.True(out[0].IsDir()) +// require.True(out[1].IsDir()) +// } +// } diff --git a/src/host/vfs/torrent.go b/src/host/vfs/torrent.go index 7f4545b..f33c326 100644 --- a/src/host/vfs/torrent.go +++ b/src/host/vfs/torrent.go @@ -8,15 +8,15 @@ import ( "slices" "strings" "sync" + "time" - "git.kmsign.ru/royalcat/tstor/pkg/ctxio" "git.kmsign.ru/royalcat/tstor/src/host/controller" "github.com/anacrolix/torrent" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "golang.org/x/exp/maps" ) -var _ Filesystem = &TorrentFs{} - type TorrentFs struct { name string @@ -28,6 +28,8 @@ type TorrentFs struct { resolver *resolver } +var _ Filesystem = (*TorrentFs)(nil) + func NewTorrentFs(name string, c *controller.Torrent) *TorrentFs { return &TorrentFs{ name: name, @@ -45,7 +47,7 @@ func (tfs *TorrentFs) Name() string { // Info implements fs.DirEntry. func (tfs *TorrentFs) Info() (fs.FileInfo, error) { - return newDirInfo(tfs.name), nil + return tfs, nil } // IsDir implements fs.DirEntry. @@ -58,6 +60,31 @@ func (tfs *TorrentFs) Type() fs.FileMode { return fs.ModeDir } +// ModTime implements fs.FileInfo. +func (tfs *TorrentFs) ModTime() time.Time { + return time.Time{} +} + +// Mode implements fs.FileInfo. +func (tfs *TorrentFs) Mode() fs.FileMode { + return fs.ModeDir +} + +// Size implements fs.FileInfo. +func (tfs *TorrentFs) Size() int64 { + return 0 +} + +// Sys implements fs.FileInfo. +func (tfs *TorrentFs) Sys() any { + return nil +} + +// FsName implements Filesystem. +func (tfs *TorrentFs) FsName() string { + return "torrentfs" +} + func (fs *TorrentFs) files(ctx context.Context) (map[string]File, error) { fs.mu.Lock() defer fs.mu.Unlock() @@ -66,14 +93,17 @@ func (fs *TorrentFs) files(ctx context.Context) (map[string]File, error) { return fs.filesCache, nil } - files, err := fs.Torrent.Files(context.Background()) + ctx, span := tracer.Start(ctx, "files", fs.traceAttrs()) + defer span.End() + + files, err := fs.Torrent.Files(ctx) if err != nil { return nil, err } fs.filesCache = make(map[string]File) for _, file := range files { - file.Download() + file.SetPriority(torrent.PiecePriorityNormal) p := AbsPath(file.Path()) tf, err := openTorrentFile(ctx, path.Base(p), file) if err != nil { @@ -93,7 +123,7 @@ func (fs *TorrentFs) files(ctx context.Context) (map[string]File, error) { if nestedFs == nil { goto DEFAULT_DIR // FIXME } - fs.filesCache, err = listFilesRecursive(ctx, nestedFs, "/") + fs.filesCache, err = fs.listFilesRecursive(ctx, nestedFs, "/") if err != nil { return nil, err } @@ -141,7 +171,12 @@ DEFAULT_DIR: // return true // } -func listFilesRecursive(ctx context.Context, vfs Filesystem, start string) (map[string]File, error) { +func (fs *TorrentFs) listFilesRecursive(ctx context.Context, vfs Filesystem, start string) (map[string]File, error) { + ctx, span := tracer.Start(ctx, "listFilesRecursive", + fs.traceAttrs(attribute.String("start", start)), + ) + defer span.End() + out := make(map[string]File, 0) entries, err := vfs.ReadDir(ctx, start) if err != nil { @@ -150,7 +185,7 @@ func listFilesRecursive(ctx context.Context, vfs Filesystem, start string) (map[ for _, entry := range entries { filename := path.Join(start, entry.Name()) if entry.IsDir() { - rec, err := listFilesRecursive(ctx, vfs, filename) + rec, err := fs.listFilesRecursive(ctx, vfs, filename) if err != nil { return nil, err } @@ -167,16 +202,31 @@ func listFilesRecursive(ctx context.Context, vfs Filesystem, start string) (map[ return out, nil } -func (fs *TorrentFs) rawOpen(ctx context.Context, path string) (File, error) { +func (fs *TorrentFs) rawOpen(ctx context.Context, filename string) (file File, err error) { + ctx, span := tracer.Start(ctx, "rawOpen", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer func() { + if err != nil { + span.RecordError(err) + } + span.End() + }() + files, err := fs.files(ctx) if err != nil { return nil, err } - file, err := getFile(files, path) + file, err = getFile(files, filename) return file, err } func (fs *TorrentFs) rawStat(ctx context.Context, filename string) (fs.FileInfo, error) { + ctx, span := tracer.Start(ctx, "rawStat", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer span.End() + files, err := fs.files(ctx) if err != nil { return nil, err @@ -185,13 +235,26 @@ func (fs *TorrentFs) rawStat(ctx context.Context, filename string) (fs.FileInfo, if err != nil { return nil, err } - return file.Stat() + return file.Info() +} + +func (fs *TorrentFs) traceAttrs(add ...attribute.KeyValue) trace.SpanStartOption { + return trace.WithAttributes(append([]attribute.KeyValue{ + attribute.String("fs", fs.FsName()), + attribute.String("torrent", fs.Torrent.Name()), + attribute.String("infohash", fs.Torrent.InfoHash()), + }, add...)...) } // Stat implements Filesystem. func (fs *TorrentFs) Stat(ctx context.Context, filename string) (fs.FileInfo, error) { - if filename == Separator { - return newDirInfo(filename), nil + ctx, span := tracer.Start(ctx, "Stat", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer span.End() + + if path.Clean(filename) == Separator { + return fs, nil } fsPath, nestedFs, nestedFsPath, err := fs.resolver.resolvePath(ctx, filename, fs.rawOpen) @@ -206,6 +269,15 @@ func (fs *TorrentFs) Stat(ctx context.Context, filename string) (fs.FileInfo, er } func (fs *TorrentFs) Open(ctx context.Context, filename string) (File, error) { + ctx, span := tracer.Start(ctx, "Open", + fs.traceAttrs(attribute.String("filename", filename)), + ) + defer span.End() + + if path.Clean(filename) == Separator { + return newDirFile(fs.name), nil + } + fsPath, nestedFs, nestedFsPath, err := fs.resolver.resolvePath(ctx, filename, fs.rawOpen) if err != nil { return nil, err @@ -218,6 +290,11 @@ func (fs *TorrentFs) Open(ctx context.Context, filename string) (File, error) { } func (fs *TorrentFs) ReadDir(ctx context.Context, name string) ([]fs.DirEntry, error) { + ctx, span := tracer.Start(ctx, "ReadDir", + fs.traceAttrs(attribute.String("name", name)), + ) + defer span.End() + fsPath, nestedFs, nestedFsPath, err := fs.resolver.resolvePath(ctx, name, fs.rawOpen) if err != nil { return nil, err @@ -234,6 +311,11 @@ func (fs *TorrentFs) ReadDir(ctx context.Context, name string) ([]fs.DirEntry, e } func (fs *TorrentFs) Unlink(ctx context.Context, name string) error { + ctx, span := tracer.Start(ctx, "Unlink", + fs.traceAttrs(attribute.String("name", name)), + ) + defer span.End() + name = AbsPath(name) fs.mu.Lock() @@ -256,10 +338,10 @@ func (fs *TorrentFs) Unlink(ctx context.Context, name string) error { return ErrNotImplemented } - return fs.Torrent.ExcludeFile(context.Background(), tfile.file) + return fs.Torrent.ExcludeFile(ctx, tfile.file) } -var _ File = &torrentFile{} +var _ File = (*torrentFile)(nil) type torrentFile struct { name string @@ -268,20 +350,24 @@ type torrentFile struct { tr torrent.Reader + lastReadTimeout time.Time + file *torrent.File } +const secondaryTimeout = time.Hour + func openTorrentFile(ctx context.Context, name string, file *torrent.File) (*torrentFile, error) { - select { - case <-file.Torrent().GotInfo(): - break - case <-ctx.Done(): - return nil, ctx.Err() - } + // select { + // case <-file.Torrent().GotInfo(): + // break + // case <-ctx.Done(): + // return nil, ctx.Err() + // } r := file.NewReader() - r.SetReadahead(4096) // TODO configurable - r.SetResponsive() + r.SetReadahead(1024 * 1024 * 16) // TODO configurable + // r.SetResponsive() return &torrentFile{ name: name, @@ -290,7 +376,17 @@ func openTorrentFile(ctx context.Context, name string, file *torrent.File) (*tor }, nil } -func (tf *torrentFile) Stat() (fs.FileInfo, error) { +// Name implements File. +func (tf *torrentFile) Name() string { + return tf.name +} + +// Type implements File. +func (tf *torrentFile) Type() fs.FileMode { + return roMode | fs.ModeDir +} + +func (tf *torrentFile) Info() (fs.FileInfo, error) { return newFileInfo(tf.name, tf.file.Length()), nil } @@ -311,32 +407,80 @@ func (rw *torrentFile) Close(ctx context.Context) error { // Read implements ctxio.Reader. func (tf *torrentFile) Read(ctx context.Context, p []byte) (n int, err error) { + ctx, span := tracer.Start(ctx, "Read", + trace.WithAttributes(attribute.Int("length", len(p))), + ) + defer func() { + span.SetAttributes(attribute.Int("read", n)) + span.End() + }() + tf.mu.Lock() defer tf.mu.Unlock() + if time.Since(tf.lastReadTimeout) < secondaryTimeout { // make short timeout for already faliled files + span.SetAttributes(attribute.Bool("short_timeout", true)) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Millisecond) + defer cancel() + } + defer func() { + if err == context.DeadlineExceeded { + tf.lastReadTimeout = time.Now() + } + }() + return tf.tr.ReadContext(ctx, p) } -func (yf *torrentFile) ReadAt(ctx context.Context, p []byte, off int64) (int, error) { - yf.mu.Lock() - defer yf.mu.Unlock() +func (tf *torrentFile) ReadAt(ctx context.Context, p []byte, off int64) (n int, err error) { + ctx, span := tracer.Start(ctx, "ReadAt", + trace.WithAttributes(attribute.Int("length", len(p)), attribute.Int64("offset", off)), + ) + defer func() { + span.SetAttributes(attribute.Int("read", n)) + span.End() + }() - _, err := yf.tr.Seek(off, io.SeekStart) + tf.mu.Lock() + defer tf.mu.Unlock() + + if time.Since(tf.lastReadTimeout) < secondaryTimeout { // make short timeout for already faliled files + span.SetAttributes(attribute.Bool("short_timeout", true)) + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Millisecond) + defer cancel() + } + defer func() { + if err == context.DeadlineExceeded { + tf.lastReadTimeout = time.Now() + } + }() + + _, err = tf.tr.Seek(off, io.SeekStart) if err != nil { return 0, err } - return readAtLeast(ctx, yf, p, len(p)) + // return tf.tr.ReadContext(ctx, p) + n, err = readAtLeast(ctx, tf.tr, p, len(p)) + + _, err = tf.tr.Seek(0, io.SeekStart) + if err != nil { + return 0, err + } + + return n, err } -func readAtLeast(ctx context.Context, r ctxio.Reader, buf []byte, min int) (n int, err error) { +func readAtLeast(ctx context.Context, r torrent.Reader, buf []byte, min int) (n int, err error) { if len(buf) < min { return 0, io.ErrShortBuffer } for n < min && err == nil { var nn int - nn, err = r.Read(ctx, buf[n:]) + nn, err = r.ReadContext(ctx, buf[n:]) n += nn } if n >= min { diff --git a/src/host/vfs/torrent_test.go b/src/host/vfs/torrent_test.go index 7de12ac..fcac812 100644 --- a/src/host/vfs/torrent_test.go +++ b/src/host/vfs/torrent_test.go @@ -1,13 +1,10 @@ package vfs import ( - "context" "os" "testing" "github.com/anacrolix/torrent" - - "github.com/stretchr/testify/require" ) const testMagnet = "magnet:?xt=urn:btih:a88fda5954e89178c372716a6a78b8180ed4dad3&dn=The+WIRED+CD+-+Rip.+Sample.+Mash.+Share&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.fastcast.nz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com&ws=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2F&xs=https%3A%2F%2Fwebtorrent.io%2Ftorrents%2Fwired-cd.torrent" @@ -85,61 +82,61 @@ func TestMain(m *testing.M) { // require.NoError(f.Close()) // } -func TestReadAtTorrent(t *testing.T) { - t.Parallel() +// func TestReadAtTorrent(t *testing.T) { +// t.Parallel() - ctx := context.Background() +// ctx := context.Background() - require := require.New(t) +// require := require.New(t) - to, err := Cli.AddMagnet(testMagnet) - require.NoError(err) +// to, err := Cli.AddMagnet(testMagnet) +// require.NoError(err) - <-to.GotInfo() - torrFile := to.Files()[0] +// <-to.GotInfo() +// torrFile := to.Files()[0] - tf := torrentFile{ - file: torrFile, - } +// tf, err := openTorrentFile(ctx, "torr", torrFile) +// require.NoError(err) - defer tf.Close(ctx) +// defer tf.Close(ctx) - toRead := make([]byte, 5) - n, err := tf.ReadAt(ctx, toRead, 6) - require.NoError(err) - require.Equal(5, n) - require.Equal([]byte{0x0, 0x0, 0x1f, 0x76, 0x54}, toRead) +// toRead := make([]byte, 5) +// n, err := tf.ReadAt(ctx, toRead, 6) +// require.NoError(err) +// require.Equal(5, n) +// require.Equal([]byte{0x0, 0x0, 0x1f, 0x76, 0x54}, toRead) - n, err = tf.ReadAt(ctx, toRead, 0) - require.NoError(err) - require.Equal(5, n) - require.Equal([]byte{0x49, 0x44, 0x33, 0x3, 0x0}, toRead) -} +// n, err = tf.ReadAt(ctx, toRead, 0) +// require.NoError(err) +// require.Equal(5, n) +// require.Equal([]byte{0x49, 0x44, 0x33, 0x3, 0x0}, toRead) +// } -func TestReadAtWrapper(t *testing.T) { - t.Parallel() +// func TestReadAtWrapper(t *testing.T) { +// t.Parallel() - ctx := context.Background() +// ctx := context.Background() - require := require.New(t) +// require := require.New(t) - to, err := Cli.AddMagnet(testMagnet) - require.NoError(err) +// to, err := Cli.AddMagnet(testMagnet) +// require.NoError(err) - <-to.GotInfo() - torrFile := to.Files()[0] +// <-to.GotInfo() +// torrFile := to.Files()[0] - r, err := openTorrentFile(ctx, "file", torrFile) - defer r.Close(ctx) +// r, err := openTorrentFile(ctx, "file", torrFile) +// require.NoError(err) +// defer r.Close(ctx) - toRead := make([]byte, 5) - n, err := r.ReadAt(ctx, toRead, 6) - require.NoError(err) - require.Equal(5, n) - require.Equal([]byte{0x0, 0x0, 0x1f, 0x76, 0x54}, toRead) +// toRead := make([]byte, 5) +// n, err := r.ReadAt(ctx, toRead, 6) +// require.NoError(err) +// require.Equal(5, n) +// require.Equal([]byte{0x0, 0x0, 0x1f, 0x76, 0x54}, toRead) - n, err = r.ReadAt(ctx, toRead, 0) - require.NoError(err) - require.Equal(5, n) - require.Equal([]byte{0x49, 0x44, 0x33, 0x3, 0x0}, toRead) -} +// n, err = r.ReadAt(ctx, toRead, 0) +// require.NoError(err) +// require.Equal(5, n) +// require.Equal([]byte{0x49, 0x44, 0x33, 0x3, 0x0}, toRead) +// } diff --git a/src/host/vfs/utils.go b/src/host/vfs/utils.go index 799ed6f..0cfca90 100644 --- a/src/host/vfs/utils.go +++ b/src/host/vfs/utils.go @@ -1,10 +1,17 @@ package vfs import ( + "path" "strings" "sync" ) +const Separator = "/" + +func isRoot(filename string) bool { + return path.Clean(filename) == Separator +} + func trimRelPath(p, t string) string { return strings.Trim(strings.TrimPrefix(p, t), "/") } diff --git a/src/log/nfs.go b/src/log/nfs.go index b48b46c..fb08422 100644 --- a/src/log/nfs.go +++ b/src/log/nfs.go @@ -5,22 +5,20 @@ import ( "log" "log/slog" - nfs "github.com/willscott/go-nfs" + nfs "git.kmsign.ru/royalcat/tstor/pkg/go-nfs" ) var _ nfs.Logger = (*NFSLog)(nil) type NFSLog struct { level nfs.LogLevel - // r *slog.Logger - l *slog.Logger + l *slog.Logger } -func NewNFSLog(r *slog.Logger) nfs.Logger { +func NewNFSLog(r *slog.Logger) *NFSLog { return &NFSLog{ level: nfs.DebugLevel, - // l: r.Level(zerolog.DebugLevel), - l: r, + l: r, } }