diff --git a/.golangci.yml b/.golangci.yml index aa8344c4bf9e..aebe6fb99b80 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -11,8 +11,6 @@ run: - sdks - ui - vendor - skip-files: - - server/static/files.go build-tags: - api - cli diff --git a/Makefile b/Makefile index 40da27f2e8e8..dd992e0767b0 100644 --- a/Makefile +++ b/Makefile @@ -164,22 +164,6 @@ ui/dist/app/index.html: $(shell find ui/src -type f && find ui -maxdepth 1 -type # `yarn build` is slow, so we guard it with a up-to-date check. JOBS=max yarn --cwd ui build -$(GOPATH)/bin/staticfiles: -# update this in Nix when updating it here -ifneq ($(USE_NIX), true) - go install bou.ke/staticfiles@dd04075 -endif - -ifeq ($(STATIC_FILES),true) -server/static/files.go: $(GOPATH)/bin/staticfiles ui/dist/app/index.html - # Pack UI into a Go file - $(GOPATH)/bin/staticfiles -o server/static/files.go ui/dist/app -else -server/static/files.go: - # Building without static files - cp ./server/static/files.go.stub ./server/static/files.go -endif - dist/argo-linux-amd64: GOARGS = GOOS=linux GOARCH=amd64 dist/argo-linux-arm64: GOARGS = GOOS=linux GOARCH=arm64 dist/argo-linux-ppc64le: GOARGS = GOOS=linux GOARCH=ppc64le @@ -191,16 +175,16 @@ dist/argo-windows-amd64: GOARGS = GOOS=windows GOARCH=amd64 dist/argo-windows-%.gz: dist/argo-windows-% gzip --force --keep dist/argo-windows-$*.exe -dist/argo-windows-%: server/static/files.go $(CLI_PKGS) go.sum +dist/argo-windows-%: ui/dist/app/index.html $(CLI_PKGS) go.sum CGO_ENABLED=0 $(GOARGS) go build -v -gcflags '${GCFLAGS}' -ldflags '${LDFLAGS} -extldflags -static' -o $@.exe ./cmd/argo dist/argo-%.gz: dist/argo-% gzip --force --keep dist/argo-$* -dist/argo-%: server/static/files.go $(CLI_PKGS) go.sum +dist/argo-%: ui/dist/app/index.html $(CLI_PKGS) go.sum CGO_ENABLED=0 $(GOARGS) go build -v -gcflags '${GCFLAGS}' -ldflags '${LDFLAGS} -extldflags -static' -o $@ ./cmd/argo -dist/argo: server/static/files.go $(CLI_PKGS) go.sum +dist/argo: ui/dist/app/index.html $(CLI_PKGS) go.sum ifeq ($(shell uname -s),Darwin) # if local, then build fast: use CGO and dynamic-linking go build -v -gcflags '${GCFLAGS}' -ldflags '${LDFLAGS}' -o $@ ./cmd/argo @@ -444,7 +428,7 @@ $(GOPATH)/bin/golangci-lint: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b `go env GOPATH`/bin v1.52.2 .PHONY: lint -lint: server/static/files.go $(GOPATH)/bin/golangci-lint +lint: $(GOPATH)/bin/golangci-lint rm -Rf v3 vendor # If you're using `woc.wf.Spec` or `woc.execWf.Status` your code probably won't work with WorkflowTemplate. # * Change `woc.wf.Spec` to `woc.execWf.Spec`. @@ -461,7 +445,7 @@ lint: server/static/files.go $(GOPATH)/bin/golangci-lint # for local we have a faster target that prints to stdout, does not use json, and can cache because it has no coverage .PHONY: test -test: server/static/files.go +test: ui/dist/app go build ./... env KUBECONFIG=/dev/null $(GOTEST) ./... # marker file, based on it's modification time, we know how long ago this target was run @@ -670,9 +654,13 @@ docs/fields.md: api/openapi-spec/swagger.json $(shell find examples -type f) hac env ARGO_SECURE=false ARGO_INSECURE_SKIP_VERIFY=false ARGO_SERVER= ARGO_INSTANCEID= go run ./hack docgen # generates several other files -docs/cli/argo.md: $(CLI_PKGS) go.sum server/static/files.go hack/cli/main.go +docs/cli/argo.md: $(CLI_PKGS) go.sum ui/dist/app hack/cli/main.go go run ./hack/cli +ui/dist/app: + @mkdir -p ui/dist/app + touch ui/dist/app/json.worker.js + # docs /usr/local/bin/mdspell: diff --git a/dev/nix/conf.nix b/dev/nix/conf.nix index 68ca78dff0aa..ffbdf090344c 100644 --- a/dev/nix/conf.nix +++ b/dev/nix/conf.nix @@ -5,7 +5,6 @@ # Even then the buildFlags are not passed into Go, meaning you won't see the correct version info yet. # This is only intended for quick developing at the moment, gradually more functionality will be pushed here. rec { - staticFiles = false; # not acted upon version = "latest"; env = { DEFAULT_REQUEUE_TIME = "1s"; diff --git a/dev/nix/flake.lock b/dev/nix/flake.lock index 7319e7638ee3..495b66c5c3cc 100644 --- a/dev/nix/flake.lock +++ b/dev/nix/flake.lock @@ -8,11 +8,11 @@ "pre-commit-hooks": "pre-commit-hooks" }, "locked": { - "lastModified": 1682353433, - "narHash": "sha256-pTz7KZ7RlWIP9EiTdUIHzOuozGoga0FIFUjm0rtQP60=", + "lastModified": 1693179221, + "narHash": "sha256-vfndyVSFhfWwO5b7d0j92YJ06obEaHsJZAa3MI0sYvc=", "owner": "cachix", "repo": "devenv", - "rev": "b454e31b73e1ce987e721e0a7a43044253d6b91a", + "rev": "68ea687ed567d578543d89b47281119a3511ac08", "type": "github" }, "original": { @@ -44,11 +44,11 @@ ] }, "locked": { - "lastModified": 1680392223, - "narHash": "sha256-n3g7QFr85lDODKt250rkZj2IFS3i4/8HBU2yKHO3tqw=", + "lastModified": 1690933134, + "narHash": "sha256-ab989mN63fQZBFrkk4Q8bYxQCktuHmBIBqUG1jl6/FQ=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "dcc36e45d054d7bb554c9cdab69093debd91a0b5", + "rev": "59cf3f1447cfc75087e7273b04b31e689a8599fb", "type": "github" }, "original": { @@ -58,12 +58,15 @@ } }, "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1667395993, - "narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=", + "lastModified": 1685518550, + "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", "owner": "numtide", "repo": "flake-utils", - "rev": "5aed5285a952e0b949eb3ba02c12fa4fcfef535f", + "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", "type": "github" }, "original": { @@ -136,11 +139,11 @@ }, "nix-filter": { "locked": { - "lastModified": 1681154353, - "narHash": "sha256-MCJ5FHOlbfQRFwN0brqPbCunLEVw05D/3sRVoNVt2tI=", + "lastModified": 1687178632, + "narHash": "sha256-HS7YR5erss0JCaUijPeyg2XrisEb959FIct3n2TMGbE=", "owner": "numtide", "repo": "nix-filter", - "rev": "f529f42792ade8e32c4be274af6b6d60857fbee7", + "rev": "d90c75e8319d0dd9be67d933d8eb9d0894ec9174", "type": "github" }, "original": { @@ -183,27 +186,27 @@ }, "nixpkgs-stable": { "locked": { - "lastModified": 1673800717, - "narHash": "sha256-SFHraUqLSu5cC6IxTprex/nTsI81ZQAtDvlBvGDWfnA=", + "lastModified": 1685801374, + "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2f9fd351ec37f5d479556cd48be4ca340da59b8f", + "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", "type": "github" }, "original": { "owner": "NixOS", - "ref": "nixos-22.11", + "ref": "nixos-23.05", "repo": "nixpkgs", "type": "github" } }, "nixpkgs_2": { "locked": { - "lastModified": 1690835256, - "narHash": "sha256-SZy/Nvwbf6CorhEsvmjqgjoYNLnRfaKVZMfSnpUDPnc=", + "lastModified": 1693183237, + "narHash": "sha256-c7OtyBkZ/vZE/WosBpRGRtkbWZjDHGJP7fg1FyB9Dsc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "b7cde1c47b7316f6138a2b36ef6627f3d16d645c", + "rev": "ea5234e7073d5f44728c499192544a84244bf35a", "type": "github" }, "original": { @@ -215,11 +218,11 @@ }, "nixpkgs_3": { "locked": { - "lastModified": 1680945546, - "narHash": "sha256-8FuaH5t/aVi/pR1XxnF0qi4WwMYC+YxlfdsA0V+TEuQ=", + "lastModified": 1691654369, + "narHash": "sha256-gSILTEx1jRaJjwZxRlnu3ZwMn1FVNk80qlwiCX8kmpo=", "owner": "nixos", "repo": "nixpkgs", - "rev": "d9f759f2ea8d265d974a6e1259bd510ac5844c5d", + "rev": "ce5e4a6ef2e59d89a971bc434ca8ca222b9c7f5e", "type": "github" }, "original": { @@ -244,11 +247,11 @@ "nixpkgs-stable": "nixpkgs-stable" }, "locked": { - "lastModified": 1678376203, - "narHash": "sha256-3tyYGyC8h7fBwncLZy5nCUjTJPrHbmNwp47LlNLOHSM=", + "lastModified": 1688056373, + "narHash": "sha256-2+SDlNRTKsgo3LBRiMUcoEUb6sDViRNQhzJquZ4koOI=", "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "1a20b9708962096ec2481eeb2ddca29ed747770a", + "rev": "5843cf069272d92b60c3ed9e55b7a8989c01d4c7", "type": "github" }, "original": { @@ -266,16 +269,31 @@ "treefmt-nix": "treefmt-nix" } }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, "treefmt-nix": { "inputs": { "nixpkgs": "nixpkgs_3" }, "locked": { - "lastModified": 1681486253, - "narHash": "sha256-EjiQZvXQH9tUPCyLC6lQpfGnoq4+kI9v59bDJWPicYo=", + "lastModified": 1693247164, + "narHash": "sha256-M6qZo8H8fBFnipCy6q6RlpSXF3sDvfTEtyFwdAP7juM=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "b25d1a3c2c7554d0462ab1dfddf2f13128638b90", + "rev": "6befd3b6b8544952e0261f054cf16769294bacba", "type": "github" }, "original": { diff --git a/dev/nix/flake.nix b/dev/nix/flake.nix index ad89dd71e381..be5e21446ff3 100644 --- a/dev/nix/flake.nix +++ b/dev/nix/flake.nix @@ -307,17 +307,6 @@ doCheck = false; }; - staticfiles = pkgs.buildGoPackage rec { - name = "staticfiles"; - src = pkgs.fetchFromGitHub { - owner = "bouk"; - repo = "staticfiles"; - rev = "827d7f6389cd410d0aa3f3d472a4838557bf53dd"; - sha256 = "0xarhmsqypl8036w96ssdzjv3k098p2d4mkmw5f6hkp1m3j67j61"; - }; - - goPackagePath = "bou.ke/staticfiles"; - }; default = config.packages.${package.name}; }; @@ -338,7 +327,6 @@ config.packages.k8sio-tools config.packages.goreman config.packages.stern - config.packages.staticfiles config.packages.${package.name} nodePackages.shell.nodeDependencies gopls @@ -368,7 +356,6 @@ config.packages.k8sio-tools config.packages.goreman config.packages.stern - config.packages.staticfiles config.packages.${package.name} nodePackages.shell.nodeDependencies gopls diff --git a/server/apiserver/argoserver.go b/server/apiserver/argoserver.go index 03507e05f078..034d78a44d77 100644 --- a/server/apiserver/argoserver.go +++ b/server/apiserver/argoserver.go @@ -56,6 +56,7 @@ import ( "github.com/argoproj/argo-workflows/v3/server/workflow" "github.com/argoproj/argo-workflows/v3/server/workflowarchive" "github.com/argoproj/argo-workflows/v3/server/workflowtemplate" + "github.com/argoproj/argo-workflows/v3/ui" grpcutil "github.com/argoproj/argo-workflows/v3/util/grpc" "github.com/argoproj/argo-workflows/v3/util/instanceid" "github.com/argoproj/argo-workflows/v3/util/json" @@ -407,7 +408,7 @@ func (as *argoServer) newHTTPServer(ctx context.Context, port int, artifactServe }) // we only enable HTST if we are secure mode, otherwise you would never be able access the UI - mux.HandleFunc("/", static.NewFilesServer(as.baseHRef, as.tlsConfig != nil && as.hsts, as.xframeOptions, as.accessControlAllowOrigin).ServerFiles) + mux.HandleFunc("/", static.NewFilesServer(as.baseHRef, as.tlsConfig != nil && as.hsts, as.xframeOptions, as.accessControlAllowOrigin, ui.Embedded).ServerFiles) return &httpServer } diff --git a/server/static/files.go b/server/static/files.go deleted file mode 100644 index 7179551ae2c9..000000000000 --- a/server/static/files.go +++ /dev/null @@ -1,8 +0,0 @@ -// File built without static files -package static - -import "net/http" - -func ServeHTTP(http.ResponseWriter, *http.Request) {} - -func Hash(string) string { return "" } diff --git a/server/static/files.go.stub b/server/static/files.go.stub deleted file mode 100644 index 7179551ae2c9..000000000000 --- a/server/static/files.go.stub +++ /dev/null @@ -1,8 +0,0 @@ -// File built without static files -package static - -import "net/http" - -func ServeHTTP(http.ResponseWriter, *http.Request) {} - -func Hash(string) string { return "" } diff --git a/server/static/response-rewriter.go b/server/static/response-rewriter.go deleted file mode 100644 index b824143ff7a7..000000000000 --- a/server/static/response-rewriter.go +++ /dev/null @@ -1,20 +0,0 @@ -package static - -import ( - "bytes" - "net/http" - "strconv" -) - -type responseRewriter struct { - http.ResponseWriter - old []byte - new []byte -} - -func (w *responseRewriter) Write(a []byte) (int, error) { - b := bytes.Replace(a, w.old, w.new, 1) - // status code and headers are printed out when we write data - w.Header().Set("Content-Length", strconv.Itoa(len(b))) - return w.ResponseWriter.Write(b) -} diff --git a/server/static/static.go b/server/static/static.go index f2df6d60b6f2..53daf27a9528 100644 --- a/server/static/static.go +++ b/server/static/static.go @@ -1,9 +1,16 @@ package static import ( + "embed" "fmt" "net/http" + "regexp" "strings" + "time" + + "github.com/argoproj/argo-workflows/v3" + "github.com/argoproj/argo-workflows/v3/ui" + "github.com/argoproj/argo-workflows/v3/util/io" ) type FilesServer struct { @@ -11,23 +18,16 @@ type FilesServer struct { hsts bool xframeOpts string corsAllowOrigin string + staticAssets embed.FS } -func NewFilesServer(baseHRef string, hsts bool, xframeOpts string, corsAllowOrigin string) *FilesServer { - return &FilesServer{baseHRef, hsts, xframeOpts, corsAllowOrigin} +var baseHRefRegex = regexp.MustCompile(``) + +func NewFilesServer(baseHRef string, hsts bool, xframeOpts string, corsAllowOrigin string, staticAssets embed.FS) *FilesServer { + return &FilesServer{baseHRef, hsts, xframeOpts, corsAllowOrigin, staticAssets} } func (s *FilesServer) ServerFiles(w http.ResponseWriter, r *http.Request) { - // If there is no stored static file, we'll redirect to the js app - if Hash(strings.TrimLeft(r.URL.Path, "/")) == "" { - r.URL.Path = "index.html" - } - - if r.URL.Path == "index.html" { - // hack to prevent ServerHTTP from giving us gzipped content which we can do our search-and-replace on - r.Header.Del("Accept-Encoding") - w = &responseRewriter{ResponseWriter: w, old: []byte(``), new: []byte(fmt.Sprintf(``, s.baseHRef))} - } if s.xframeOpts != "" { w.Header().Set("X-Frame-Options", s.xframeOpts) @@ -50,6 +50,49 @@ func (s *FilesServer) ServerFiles(w http.ResponseWriter, r *http.Request) { w.Header().Set("Strict-Transport-Security", "max-age=31536000") } - // in my IDE (IntelliJ) the next line is red for some reason - but this is fine - ServeHTTP(w, r) + if r.URL.Path == "/" || !s.uiAssetExists(r.URL.Path) { + data, err := s.getIndexData() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + modTime, err := time.Parse(argo.GetVersion().BuildDate, time.RFC3339) + if err != nil { + modTime = time.Now() + } + http.ServeContent(w, r, "index.html", modTime, io.NewByteReadSeeker(data)) + } else { + staticFS := io.NewSubDirFS(ui.EMBED_PATH, s.staticAssets) + http.FileServer(http.FS(staticFS)).ServeHTTP(w, r) + } +} + +func (s *FilesServer) getIndexData() ([]byte, error) { + data, err := s.staticAssets.ReadFile(ui.EMBED_PATH + "/index.html") + if err != nil { + return data, err + } + if s.baseHRef != "/" && s.baseHRef != "" { + data = []byte(replaceBaseHRef(string(data), fmt.Sprintf(``, strings.Trim(s.baseHRef, "/")))) + } + + return data, nil +} + +func (s *FilesServer) uiAssetExists(filename string) bool { + f, err := s.staticAssets.Open(ui.EMBED_PATH + filename) + if err != nil { + return false + } + defer f.Close() + stat, err := f.Stat() + if err != nil { + return false + } + return !stat.IsDir() +} + +func replaceBaseHRef(data string, replaceWith string) string { + return baseHRefRegex.ReplaceAllString(data, replaceWith) } diff --git a/server/static/static_test.go b/server/static/static_test.go new file mode 100644 index 000000000000..e9682dbfe2c3 --- /dev/null +++ b/server/static/static_test.go @@ -0,0 +1,95 @@ +package static + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestReplaceBaseHRef(t *testing.T) { + testCases := []struct { + name string + data string + expected string + replaceWith string + }{ + { + name: "non-root basepath", + data: ` + + + + Argo + + + + + + + + +
+ +`, + expected: ` + + + + Argo + + + + + + + + +
+ +`, + replaceWith: ``, + }, + { + name: "root basepath", + data: ` + + + + Argo + + + + + + + + +
+ +`, + expected: ` + + + + Argo + + + + + + + + +
+ +`, + replaceWith: ``, + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + result := replaceBaseHRef(testCase.data, testCase.replaceWith) + assert.Equal(t, testCase.expected, result) + }) + } +} diff --git a/ui/embed.go b/ui/embed.go new file mode 100644 index 000000000000..5c9792fa48d7 --- /dev/null +++ b/ui/embed.go @@ -0,0 +1,10 @@ +package ui + +import "embed" + +const EMBED_PATH = "dist/app" + +// Embedded contains embedded UI resources +// +//go:embed dist/app +var Embedded embed.FS diff --git a/util/io/bytereadseeker.go b/util/io/bytereadseeker.go new file mode 100644 index 000000000000..43c246c2eced --- /dev/null +++ b/util/io/bytereadseeker.go @@ -0,0 +1,38 @@ +package io + +import ( + "io" + "io/fs" +) + +func NewByteReadSeeker(data []byte) *byteReadSeeker { + return &byteReadSeeker{data: data} +} + +type byteReadSeeker struct { + data []byte + offset int64 +} + +func (f *byteReadSeeker) Read(b []byte) (int, error) { + if f.offset >= int64(len(f.data)) { + return 0, io.EOF + } + n := copy(b, f.data[f.offset:]) + f.offset += int64(n) + return n, nil +} + +func (f *byteReadSeeker) Seek(offset int64, whence int) (int64, error) { + switch whence { + case 1: + offset += f.offset + case 2: + offset += int64(len(f.data)) + } + if offset < 0 || offset > int64(len(f.data)) { + return 0, &fs.PathError{Op: "seek", Err: fs.ErrInvalid} + } + f.offset = offset + return offset, nil +} diff --git a/util/io/bytereadseeker_test.go b/util/io/bytereadseeker_test.go new file mode 100644 index 000000000000..d3496bcb81c6 --- /dev/null +++ b/util/io/bytereadseeker_test.go @@ -0,0 +1,72 @@ +package io + +import ( + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestByteReadSeeker_Read(t *testing.T) { + inString := "hello world" + reader := NewByteReadSeeker([]byte(inString)) + var bytes = make([]byte, 11) + n, err := reader.Read(bytes) + require.NoError(t, err) + assert.Equal(t, len(inString), n) + assert.Equal(t, inString, string(bytes)) + _, err = reader.Read(bytes) + assert.ErrorIs(t, err, io.EOF) +} + +func TestByteReadSeeker_Seek_Start(t *testing.T) { + inString := "hello world" + reader := NewByteReadSeeker([]byte(inString)) + offset, err := reader.Seek(6, io.SeekStart) + require.NoError(t, err) + assert.Equal(t, int64(6), offset) + var bytes = make([]byte, 5) + n, err := reader.Read(bytes) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, "world", string(bytes)) +} + +func TestByteReadSeeker_Seek_Current(t *testing.T) { + inString := "hello world" + reader := NewByteReadSeeker([]byte(inString)) + offset, err := reader.Seek(3, io.SeekCurrent) + require.NoError(t, err) + assert.Equal(t, int64(3), offset) + offset, err = reader.Seek(3, io.SeekCurrent) + require.NoError(t, err) + assert.Equal(t, int64(6), offset) + var bytes = make([]byte, 5) + n, err := reader.Read(bytes) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, "world", string(bytes)) +} + +func TestByteReadSeeker_Seek_End(t *testing.T) { + inString := "hello world" + reader := NewByteReadSeeker([]byte(inString)) + offset, err := reader.Seek(-5, io.SeekEnd) + require.NoError(t, err) + assert.Equal(t, int64(6), offset) + var bytes = make([]byte, 5) + n, err := reader.Read(bytes) + require.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, "world", string(bytes)) +} + +func TestByteReadSeeker_Seek_OutOfBounds(t *testing.T) { + inString := "hello world" + reader := NewByteReadSeeker([]byte(inString)) + _, err := reader.Seek(12, io.SeekStart) + assert.Error(t, err) + _, err = reader.Seek(-1, io.SeekStart) + assert.Error(t, err) +} diff --git a/util/io/subdirfs.go b/util/io/subdirfs.go new file mode 100644 index 000000000000..93b0ab7a6e14 --- /dev/null +++ b/util/io/subdirfs.go @@ -0,0 +1,20 @@ +package io + +import ( + "io/fs" + "path/filepath" +) + +type subDirFs struct { + dir string + fs fs.FS +} + +func (s subDirFs) Open(name string) (fs.File, error) { + return s.fs.Open(filepath.Join(s.dir, name)) +} + +// NewSubDirFS returns file system that represents sub-directory in a wrapped file system +func NewSubDirFS(dir string, fs fs.FS) *subDirFs { + return &subDirFs{dir: dir, fs: fs} +}