From 883969daafa999611c61a45419c5ee90429670b9 Mon Sep 17 00:00:00 2001 From: Erik Rasmussen Date: Sun, 12 Jan 2025 21:35:26 -0600 Subject: [PATCH] Copy over all the stuff from the other repo --- .github/renovate.json | 6 + .github/workflows/ci.yml | 33 + .gitignore | 8 + .versions/devctl | 1 + .vscode/extensions.json | 5 + Makefile | 49 ++ context.go | 30 + context/accessor.go | 93 ++ context/accessor_test.go | 332 ++++++++ context/adapter.go | 73 ++ context/adapter_test.go | 298 +++++++ context/context.go | 58 ++ context/context_suite_test.go | 13 + context/discard.go | 71 ++ context/discard_test.go | 792 ++++++++++++++++++ context/fs.go | 105 +++ context/setter.go | 95 +++ context/setter_test.go | 406 +++++++++ copy.go | 10 + copy_test.go | 98 +++ docker/docker_suite_test.go | 54 ++ docker/file.go | 225 +++++ docker/fileinfo.go | 42 + docker/fs.go | 121 +++ docker/fs_test.go | 65 ++ docker/internal/exec.go | 71 ++ docker/internal/exec_test.go | 64 ++ docker/internal/internal_suite_test.go | 13 + docker/op.go | 22 + filter/file.go | 98 +++ filter/filter_suite_test.go | 13 + filter/fs.go | 175 ++++ filter/fs_test.go | 261 ++++++ first.go | 75 ++ first_test.go | 147 ++++ fold.go | 21 + fold_test.go | 31 + fs.go | 34 + fs_suite_test.go | 13 + github/fs.go | 62 ++ github/ghpath/ghpath_suite_test.go | 13 + github/ghpath/path.go | 356 ++++++++ github/ghpath/path_test.go | 578 +++++++++++++ github/github_suite_test.go | 13 + github/internal/client.go | 18 + github/internal/constraint.go | 17 + github/internal/context.go | 17 + github/internal/internal_suite_test.go | 13 + github/internal/readonly.go | 36 + .../repository/content/content_suite_test.go | 25 + github/repository/content/directory.go | 78 ++ github/repository/content/directoryinfo.go | 48 ++ github/repository/content/file.go | 79 ++ github/repository/content/fileinfo.go | 43 + github/repository/content/fs.go | 118 +++ github/repository/content/fs_test.go | 44 + github/repository/file.go | 93 ++ github/repository/file_test.go | 22 + github/repository/fileinfo.go | 43 + github/repository/fs.go | 143 ++++ github/repository/fs_test.go | 31 + .../release/asset/asset_suite_test.go | 21 + github/repository/release/asset/file.go | 145 ++++ github/repository/release/asset/file_test.go | 22 + github/repository/release/asset/fileinfo.go | 44 + github/repository/release/asset/fs.go | 208 +++++ github/repository/release/asset/fs_test.go | 48 ++ github/repository/release/file.go | 101 +++ github/repository/release/file_test.go | 22 + github/repository/release/fileinfo.go | 43 + github/repository/release/fs.go | 163 ++++ github/repository/release/fs_test.go | 28 + .../repository/release/release_suite_test.go | 21 + github/repository/repository_suite_test.go | 21 + github/user/file.go | 90 ++ github/user/file_test.go | 60 ++ github/user/fileinfo.go | 44 + github/user/fs.go | 96 +++ github/user/fs_test.go | 64 ++ github/user/user_suite_test.go | 21 + go.mod | 86 ++ go.sum | 239 ++++++ hack/example.envrc | 4 + ignore/fs.go | 58 ++ ignore/fs_test.go | 152 ++++ ignore/ignore_suite_test.go | 13 + internal/copy.go | 30 + internal/copy_test.go | 121 +++ internal/internal_suite_test.go | 13 + iter.go | 63 ++ iter_test.go | 68 ++ single.go | 87 ++ single_test.go | 147 ++++ testing/context.go | 139 +++ testing/file.go | 142 ++++ testing/fileinfo.go | 47 ++ testing/fs.go | 138 +++ writer/file.go | 81 ++ writer/file_test.go | 35 + writer/fileinfo.go | 43 + writer/fs.go | 37 + writer/fs_test.go | 24 + writer/writer_suite_test.go | 13 + 103 files changed, 9053 insertions(+) create mode 100644 .github/renovate.json create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .versions/devctl create mode 100644 .vscode/extensions.json create mode 100644 Makefile create mode 100644 context.go create mode 100644 context/accessor.go create mode 100644 context/accessor_test.go create mode 100644 context/adapter.go create mode 100644 context/adapter_test.go create mode 100644 context/context.go create mode 100644 context/context_suite_test.go create mode 100644 context/discard.go create mode 100644 context/discard_test.go create mode 100644 context/fs.go create mode 100644 context/setter.go create mode 100644 context/setter_test.go create mode 100644 copy.go create mode 100644 copy_test.go create mode 100644 docker/docker_suite_test.go create mode 100644 docker/file.go create mode 100644 docker/fileinfo.go create mode 100644 docker/fs.go create mode 100644 docker/fs_test.go create mode 100644 docker/internal/exec.go create mode 100644 docker/internal/exec_test.go create mode 100644 docker/internal/internal_suite_test.go create mode 100644 docker/op.go create mode 100644 filter/file.go create mode 100644 filter/filter_suite_test.go create mode 100644 filter/fs.go create mode 100644 filter/fs_test.go create mode 100644 first.go create mode 100644 first_test.go create mode 100644 fold.go create mode 100644 fold_test.go create mode 100644 fs.go create mode 100644 fs_suite_test.go create mode 100644 github/fs.go create mode 100644 github/ghpath/ghpath_suite_test.go create mode 100644 github/ghpath/path.go create mode 100644 github/ghpath/path_test.go create mode 100644 github/github_suite_test.go create mode 100644 github/internal/client.go create mode 100644 github/internal/constraint.go create mode 100644 github/internal/context.go create mode 100644 github/internal/internal_suite_test.go create mode 100644 github/internal/readonly.go create mode 100644 github/repository/content/content_suite_test.go create mode 100644 github/repository/content/directory.go create mode 100644 github/repository/content/directoryinfo.go create mode 100644 github/repository/content/file.go create mode 100644 github/repository/content/fileinfo.go create mode 100644 github/repository/content/fs.go create mode 100644 github/repository/content/fs_test.go create mode 100644 github/repository/file.go create mode 100644 github/repository/file_test.go create mode 100644 github/repository/fileinfo.go create mode 100644 github/repository/fs.go create mode 100644 github/repository/fs_test.go create mode 100644 github/repository/release/asset/asset_suite_test.go create mode 100644 github/repository/release/asset/file.go create mode 100644 github/repository/release/asset/file_test.go create mode 100644 github/repository/release/asset/fileinfo.go create mode 100644 github/repository/release/asset/fs.go create mode 100644 github/repository/release/asset/fs_test.go create mode 100644 github/repository/release/file.go create mode 100644 github/repository/release/file_test.go create mode 100644 github/repository/release/fileinfo.go create mode 100644 github/repository/release/fs.go create mode 100644 github/repository/release/fs_test.go create mode 100644 github/repository/release/release_suite_test.go create mode 100644 github/repository/repository_suite_test.go create mode 100644 github/user/file.go create mode 100644 github/user/file_test.go create mode 100644 github/user/fileinfo.go create mode 100644 github/user/fs.go create mode 100644 github/user/fs_test.go create mode 100644 github/user/user_suite_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 hack/example.envrc create mode 100644 ignore/fs.go create mode 100644 ignore/fs_test.go create mode 100644 ignore/ignore_suite_test.go create mode 100644 internal/copy.go create mode 100644 internal/copy_test.go create mode 100644 internal/internal_suite_test.go create mode 100644 iter.go create mode 100644 iter_test.go create mode 100644 single.go create mode 100644 single_test.go create mode 100644 testing/context.go create mode 100644 testing/file.go create mode 100644 testing/fileinfo.go create mode 100644 testing/fs.go create mode 100644 writer/file.go create mode 100644 writer/file_test.go create mode 100644 writer/fileinfo.go create mode 100644 writer/fs.go create mode 100644 writer/fs_test.go create mode 100644 writer/writer_suite_test.go diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..2b42fb1 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..986adfe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + # For code coverage reports + branches: [main] + pull_request: + branches: [main] + +permissions: + id-token: write + contents: read + +jobs: + build: + name: Build and Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - run: make build + - run: make test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + use_oidc: true + files: cover.profile diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a37b62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +bin/ +report.json +cover.profile +go.work* +.vscode/settings.json +.make/ +*.test +.envrc diff --git a/.versions/devctl b/.versions/devctl new file mode 100644 index 0000000..d917d3e --- /dev/null +++ b/.versions/devctl @@ -0,0 +1 @@ +0.1.2 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..1314c7a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "golang.go" + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c8523b7 --- /dev/null +++ b/Makefile @@ -0,0 +1,49 @@ +_ := $(shell mkdir -p .make bin) + +WORKING_DIR := $(shell pwd) +LOCALBIN := ${WORKING_DIR}/bin + +export GOBIN := ${LOCALBIN} + +DEVCTL := ${LOCALBIN}/devctl +GINKGO := ${LOCALBIN}/ginkgo + +ifeq ($(CI),) +TEST_FLAGS := --label-filter !E2E +else +TEST_FLAGS := --github-output --race --trace --coverprofile=cover.profile +endif + +build: .make/build +test: .make/test +tidy: go.sum + +test_all: + $(GINKGO) run -r ./ + +go.sum: go.mod $(shell $(DEVCTL) list --go) | bin/devctl + go mod tidy + +%_suite_test.go: | bin/ginkgo + cd $(dir $@) && $(GINKGO) bootstrap + +%_test.go: | bin/ginkgo + cd $(dir $@) && $(GINKGO) generate $(notdir $*) + +bin/ginkgo: go.mod + go install github.com/onsi/ginkgo/v2/ginkgo + +bin/devctl: .versions/devctl + go install github.com/unmango/devctl/cmd@v$(shell cat $<) + mv ${LOCALBIN}/cmd $@ + +.envrc: hack/example.envrc + cp $< $@ + +.make/build: $(shell $(DEVCTL) list --go --exclude-tests) | bin/devctl + go build ./... + @touch $@ + +.make/test: $(shell $(DEVCTL) list --go) | bin/ginkgo bin/devctl + $(GINKGO) run ${TEST_FLAGS} $(sort $(dir $?)) + @touch $@ diff --git a/context.go b/context.go new file mode 100644 index 0000000..27be9eb --- /dev/null +++ b/context.go @@ -0,0 +1,30 @@ +package aferox + +import ( + "fmt" + + "github.com/spf13/afero" + "github.com/unmango/aferox/context" +) + +type contextKey struct{} + +var defaultContextFs = afero.NewOsFs() + +func SetContext(fs afero.Fs, ctx context.Context) error { + if ctxfs, ok := fs.(context.Setter); !ok { + return fmt.Errorf("context not supported: %s", fs.Name()) + } else { + ctxfs.SetContext(ctx) + } + + return nil +} + +func FromContext(ctx context.Context) afero.Fs { + if fs := ctx.Value(contextKey{}); fs != nil { + return fs.(afero.Fs) + } else { + return defaultContextFs + } +} diff --git a/context/accessor.go b/context/accessor.go new file mode 100644 index 0000000..00a7546 --- /dev/null +++ b/context/accessor.go @@ -0,0 +1,93 @@ +package context + +import ( + "fmt" + "io/fs" + "time" + + "github.com/spf13/afero" +) + +type AccessorFunc func() Context + +func (fn AccessorFunc) Context() Context { + return fn() +} + +func ToAccessor[T ~func() Context](fn T) Accessor { + return AccessorFunc(fn) +} + +// AccessorFs adapts an [Fs] to an [afero.Fs] by using the given [ContextAccessor] +// to source the [context.Context] for each operation +type AccessorFs struct { + Accessor + Fs Fs +} + +// Chmod implements afero.Fs. +func (a *AccessorFs) Chmod(name string, mode fs.FileMode) error { + return a.Fs.Chmod(a.Context(), name, mode) +} + +// Chown implements afero.Fs. +func (a *AccessorFs) Chown(name string, uid int, gid int) error { + return a.Fs.Chown(a.Context(), name, uid, gid) +} + +// Chtimes implements afero.Fs. +func (a *AccessorFs) Chtimes(name string, atime time.Time, mtime time.Time) error { + return a.Fs.Chtimes(a.Context(), name, atime, mtime) +} + +// Create implements afero.Fs. +func (a *AccessorFs) Create(name string) (afero.File, error) { + return a.Fs.Create(a.Context(), name) +} + +// Mkdir implements afero.Fs. +func (a *AccessorFs) Mkdir(name string, perm fs.FileMode) error { + return a.Fs.Mkdir(a.Context(), name, perm) +} + +// MkdirAll implements afero.Fs. +func (a *AccessorFs) MkdirAll(path string, perm fs.FileMode) error { + return a.Fs.MkdirAll(a.Context(), path, perm) +} + +// Name implements afero.Fs. +func (a *AccessorFs) Name() string { + return fmt.Sprintf("Context: %s", a.Fs.Name()) +} + +// Open implements afero.Fs. +func (a *AccessorFs) Open(name string) (afero.File, error) { + return a.Fs.Open(a.Context(), name) +} + +// OpenFile implements afero.Fs. +func (a *AccessorFs) OpenFile(name string, flag int, perm fs.FileMode) (afero.File, error) { + return a.Fs.OpenFile(a.Context(), name, flag, perm) +} + +// Remove implements afero.Fs. +func (a *AccessorFs) Remove(name string) error { + return a.Fs.Remove(a.Context(), name) +} + +// RemoveAll implements afero.Fs. +func (a *AccessorFs) RemoveAll(path string) error { + return a.Fs.RemoveAll(a.Context(), path) +} + +// Rename implements afero.Fs. +func (a *AccessorFs) Rename(oldname string, newname string) error { + return a.Fs.Rename(a.Context(), oldname, newname) +} + +// Stat implements afero.Fs. +func (a *AccessorFs) Stat(name string) (fs.FileInfo, error) { + return a.Fs.Stat(a.Context(), name) +} + +var _ afero.Fs = (*AccessorFs)(nil) diff --git a/context/accessor_test.go b/context/accessor_test.go new file mode 100644 index 0000000..a42fd50 --- /dev/null +++ b/context/accessor_test.go @@ -0,0 +1,332 @@ +package context_test + +import ( + "errors" + "io/fs" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/context" + "github.com/unmango/aferox/testing" +) + +type getter struct { + Invoked bool + Value context.Context +} + +func (g *getter) Context() context.Context { + g.Invoked = true + return g.Value +} + +var _ = Describe("Accessor", func() { + var base *testing.ContextFs + + BeforeEach(func() { + base = &testing.ContextFs{} + }) + + It("should call base Chmod", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.ChmodFunc = func(ctx context.Context, s string, fm fs.FileMode) error { + actualCtx = ctx + actualName = s + actualMode = fm + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.Chmod("bleh", os.ModePerm) + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should call base Chown", func(ctx context.Context) { + var ( + actualCtx context.Context + actualUid int + actualGid int + expectedErr = errors.New("sentinel") + ) + base.ChownFunc = func(ctx context.Context, s string, i1, i2 int) error { + actualCtx = ctx + actualUid = i1 + actualGid = i2 + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.Chown("bleh", 420, 69) + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualUid).To(Equal(420)) + Expect(actualGid).To(Equal(69)) + }) + + It("should call base Chtimes", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualAtime time.Time + actualMtime time.Time + expectedErr = errors.New("sentinel") + ) + base.ChtimesFunc = func(ctx context.Context, s string, t1, t2 time.Time) error { + actualCtx = ctx + actualName = s + actualAtime = t1 + actualMtime = t2 + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.Chtimes("bleh", time.Unix(69, 420), time.Unix(420, 69)) + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualAtime).To(Equal(time.Unix(69, 420))) + Expect(actualMtime).To(Equal(time.Unix(420, 69))) + }) + + It("should call base Create", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.CreateFunc = func(ctx context.Context, s string) (afero.File, error) { + actualCtx = ctx + actualName = s + return expectedFile, expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + f, err := fs.Create("bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base MkdirAll", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.MkdirAllFunc = func(ctx context.Context, s string, fm fs.FileMode) error { + actualCtx = ctx + actualName = s + actualMode = fm + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.MkdirAll("bleh", os.ModeDir) + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should call base Mkdir", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.MkdirFunc = func(ctx context.Context, s string, fm fs.FileMode) error { + actualCtx = ctx + actualName = s + actualMode = fm + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.Mkdir("bleh", os.ModeDir) + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should call base Open", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.OpenFunc = func(ctx context.Context, s string) (afero.File, error) { + actualCtx = ctx + actualName = s + return expectedFile, expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + f, err := fs.Open("bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base OpenFile", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualFlag int + actualMode fs.FileMode + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.OpenFileFunc = func(ctx context.Context, s string, i int, fm fs.FileMode) (afero.File, error) { + actualCtx = ctx + actualName = s + actualFlag = i + actualMode = fm + return expectedFile, expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + f, err := fs.OpenFile("bleh", 69, os.ModePerm) + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualName).To(Equal("bleh")) + Expect(actualFlag).To(Equal(69)) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should call base RemoveAll", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedErr = errors.New("sentinel") + ) + base.RemoveAllFunc = func(ctx context.Context, s string) error { + actualCtx = ctx + actualName = s + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.RemoveAll("bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base Remove", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedErr = errors.New("sentinel") + ) + base.RemoveFunc = func(ctx context.Context, s string) error { + actualCtx = ctx + actualName = s + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.Remove("bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base Rename", func(ctx context.Context) { + var ( + actualCtx context.Context + actualOld string + actualNew string + expectedErr = errors.New("sentinel") + ) + base.RenameFunc = func(ctx context.Context, s1, s2 string) error { + actualCtx = ctx + actualOld = s1 + actualNew = s2 + return expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + err := fs.Rename("bleh", "blah") + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualOld).To(Equal("bleh")) + Expect(actualNew).To(Equal("blah")) + }) + + It("should call base Stat", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedInfo = &testing.FileInfo{} + expectedErr = errors.New("sentinel") + ) + base.StatFunc = func(ctx context.Context, s string) (fs.FileInfo, error) { + actualCtx = ctx + actualName = s + return expectedInfo, expectedErr + } + g := &getter{Value: ctx} + fs := context.NewFs(base, g) + + i, err := fs.Stat("bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(g.Invoked).To(BeTrueBecause("the getter was invoked")) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(i).To(BeIdenticalTo(expectedInfo)) + }) +}) diff --git a/context/adapter.go b/context/adapter.go new file mode 100644 index 0000000..ade8507 --- /dev/null +++ b/context/adapter.go @@ -0,0 +1,73 @@ +package context + +import ( + "io/fs" + "time" + + "github.com/spf13/afero" +) + +// AdapterFs adapts an [Fs] to a partial [AferoFs] structure. +// It is intended to be embedded as a utility type to satisfy +// an [AferoFs] interface with an [Fs] +type AdapterFs struct{ Fs Fs } + +// ChmodContext implements AferoFs. +func (a *AdapterFs) ChmodContext(ctx Context, name string, mode fs.FileMode) error { + return a.Fs.Chmod(ctx, name, mode) +} + +// ChownContext implements AferoFs. +func (a *AdapterFs) ChownContext(ctx Context, name string, uid int, gid int) error { + return a.Fs.Chown(ctx, name, uid, gid) +} + +// ChtimesContext implements AferoFs. +func (a *AdapterFs) ChtimesContext(ctx Context, name string, atime time.Time, mtime time.Time) error { + return a.Fs.Chtimes(ctx, name, atime, mtime) +} + +// CreateContext implements AferoFs. +func (a *AdapterFs) CreateContext(ctx Context, name string) (afero.File, error) { + return a.Fs.Create(ctx, name) +} + +// MkdirAllContext implements AferoFs. +func (a *AdapterFs) MkdirAllContext(ctx Context, path string, perm fs.FileMode) error { + return a.Fs.MkdirAll(ctx, path, perm) +} + +// MkdirContext implements AferoFs. +func (a *AdapterFs) MkdirContext(ctx Context, name string, perm fs.FileMode) error { + return a.Fs.Mkdir(ctx, name, perm) +} + +// OpenContext implements AferoFs. +func (a *AdapterFs) OpenContext(ctx Context, name string) (afero.File, error) { + return a.Fs.Open(ctx, name) +} + +// OpenFileContext implements AferoFs. +func (a *AdapterFs) OpenFileContext(ctx Context, name string, flag int, perm fs.FileMode) (afero.File, error) { + return a.Fs.OpenFile(ctx, name, flag, perm) +} + +// RemoveAllContext implements AferoFs. +func (a *AdapterFs) RemoveAllContext(ctx Context, path string) error { + return a.Fs.RemoveAll(ctx, path) +} + +// RemoveContext implements AferoFs. +func (a *AdapterFs) RemoveContext(ctx Context, name string) error { + return a.Fs.Remove(ctx, name) +} + +// RenameContext implements AferoFs. +func (a *AdapterFs) RenameContext(ctx Context, oldname string, newname string) error { + return a.Fs.Rename(ctx, oldname, newname) +} + +// StatContext implements AferoFs. +func (a *AdapterFs) StatContext(ctx Context, name string) (fs.FileInfo, error) { + return a.Fs.Stat(ctx, name) +} diff --git a/context/adapter_test.go b/context/adapter_test.go new file mode 100644 index 0000000..a78d549 --- /dev/null +++ b/context/adapter_test.go @@ -0,0 +1,298 @@ +package context_test + +import ( + "errors" + "io/fs" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/context" + "github.com/unmango/aferox/testing" +) + +var _ = Describe("Adapter", func() { + var base *testing.ContextFs + + BeforeEach(func() { + base = &testing.ContextFs{} + }) + + It("should call base Chmod", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.ChmodFunc = func(ctx context.Context, s string, fm fs.FileMode) error { + actualCtx = ctx + actualName = s + actualMode = fm + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.ChmodContext(ctx, "bleh", os.ModePerm) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should call base Chown", func(ctx context.Context) { + var ( + actualCtx context.Context + actualUid int + actualGid int + expectedErr = errors.New("sentinel") + ) + base.ChownFunc = func(ctx context.Context, s string, i1, i2 int) error { + actualCtx = ctx + actualUid = i1 + actualGid = i2 + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.ChownContext(ctx, "bleh", 420, 69) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualUid).To(Equal(420)) + Expect(actualGid).To(Equal(69)) + }) + + It("should call base Chtimes", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualAtime time.Time + actualMtime time.Time + expectedErr = errors.New("sentinel") + ) + base.ChtimesFunc = func(ctx context.Context, s string, t1, t2 time.Time) error { + actualCtx = ctx + actualName = s + actualAtime = t1 + actualMtime = t2 + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.ChtimesContext(ctx, "bleh", time.Unix(69, 420), time.Unix(420, 69)) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualAtime).To(Equal(time.Unix(69, 420))) + Expect(actualMtime).To(Equal(time.Unix(420, 69))) + }) + + It("should call base Create", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.CreateFunc = func(ctx context.Context, s string) (afero.File, error) { + actualCtx = ctx + actualName = s + return expectedFile, expectedErr + } + fs := context.AdapterFs{base} + + f, err := fs.CreateContext(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base MkdirAll", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.MkdirAllFunc = func(ctx context.Context, s string, fm fs.FileMode) error { + actualCtx = ctx + actualName = s + actualMode = fm + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.MkdirAllContext(ctx, "bleh", os.ModeDir) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should call base Mkdir", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.MkdirFunc = func(ctx context.Context, s string, fm fs.FileMode) error { + actualCtx = ctx + actualName = s + actualMode = fm + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.MkdirContext(ctx, "bleh", os.ModeDir) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should call base Open", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.OpenFunc = func(ctx context.Context, s string) (afero.File, error) { + actualCtx = ctx + actualName = s + return expectedFile, expectedErr + } + fs := context.AdapterFs{base} + + f, err := fs.OpenContext(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base OpenFile", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + actualFlag int + actualMode fs.FileMode + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.OpenFileFunc = func(ctx context.Context, s string, i int, fm fs.FileMode) (afero.File, error) { + actualCtx = ctx + actualName = s + actualFlag = i + actualMode = fm + return expectedFile, expectedErr + } + fs := context.AdapterFs{base} + + f, err := fs.OpenFileContext(ctx, "bleh", 69, os.ModePerm) + + Expect(err).To(MatchError(expectedErr)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(actualFlag).To(Equal(69)) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should call base RemoveAll", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedErr = errors.New("sentinel") + ) + base.RemoveAllFunc = func(ctx context.Context, s string) error { + actualCtx = ctx + actualName = s + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.RemoveAllContext(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base Remove", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedErr = errors.New("sentinel") + ) + base.RemoveFunc = func(ctx context.Context, s string) error { + actualCtx = ctx + actualName = s + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.RemoveContext(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should call base Rename", func(ctx context.Context) { + var ( + actualCtx context.Context + actualOld string + actualNew string + expectedErr = errors.New("sentinel") + ) + base.RenameFunc = func(ctx context.Context, s1, s2 string) error { + actualCtx = ctx + actualOld = s1 + actualNew = s2 + return expectedErr + } + fs := context.AdapterFs{base} + + err := fs.RenameContext(ctx, "bleh", "blah") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualOld).To(Equal("bleh")) + Expect(actualNew).To(Equal("blah")) + }) + + It("should call base Stat", func(ctx context.Context) { + var ( + actualCtx context.Context + actualName string + expectedInfo = &testing.FileInfo{} + expectedErr = errors.New("sentinel") + ) + base.StatFunc = func(ctx context.Context, s string) (fs.FileInfo, error) { + actualCtx = ctx + actualName = s + return expectedInfo, expectedErr + } + fs := context.AdapterFs{base} + + i, err := fs.StatContext(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualCtx).To(BeIdenticalTo(ctx)) + Expect(actualName).To(Equal("bleh")) + Expect(i).To(BeIdenticalTo(expectedInfo)) + }) +}) diff --git a/context/context.go b/context/context.go new file mode 100644 index 0000000..49cf215 --- /dev/null +++ b/context/context.go @@ -0,0 +1,58 @@ +package context + +import ( + "context" + + "github.com/spf13/afero" +) + +type Setter interface { + SetContext(context.Context) +} + +type Accessor interface { + Context() context.Context +} + +type Context = context.Context + +var ( + Background = context.Background + TODO = context.TODO + WithCancel = context.WithCancel + WithCancelCause = context.WithCancelCause + WithDeadline = context.WithDeadline + WithDeadlineCause = context.WithDeadlineCause + WithValue = context.WithValue + WithTimeout = context.WithTimeout + WithTimeoutCause = context.WithTimeoutCause + WithoutCancel = context.WithoutCancel +) + +type union struct { + afero.Fs + AdapterFs +} + +func NewFs(base Fs, accessor Accessor) afero.Fs { + return &AccessorFs{accessor, base} +} + +func BackgroundFs(base Fs) afero.Fs { + return NewFs(base, AccessorFunc(Background)) +} + +func TodoFs(base Fs) afero.Fs { + return NewFs(base, AccessorFunc(TODO)) +} + +func Discard(fs afero.Fs) AferoFs { + return &DiscardFs{fs} +} + +func Adapt(fs Fs, accessor Accessor) AferoFs { + return &union{ + Fs: NewFs(fs, accessor), + AdapterFs: AdapterFs{fs}, + } +} diff --git a/context/context_suite_test.go b/context/context_suite_test.go new file mode 100644 index 0000000..683a82e --- /dev/null +++ b/context/context_suite_test.go @@ -0,0 +1,13 @@ +package context_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestContext(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Context Suite") +} diff --git a/context/discard.go b/context/discard.go new file mode 100644 index 0000000..294bf56 --- /dev/null +++ b/context/discard.go @@ -0,0 +1,71 @@ +package context + +import ( + "io/fs" + "time" + + "github.com/spf13/afero" +) + +// DiscardFs adapts an [afero.Fs] to an [AferoFs] by ignoring the [context.Context] argument. +type DiscardFs struct{ afero.Fs } + +// ChmodContext implements AferoFs. +func (a *DiscardFs) ChmodContext(ctx Context, name string, mode fs.FileMode) error { + return a.Chmod(name, mode) +} + +// ChownContext implements AferoFs. +func (a *DiscardFs) ChownContext(ctx Context, name string, uid int, gid int) error { + return a.Chown(name, uid, gid) +} + +// ChtimesContext implements AferoFs. +func (a *DiscardFs) ChtimesContext(ctx Context, name string, atime time.Time, mtime time.Time) error { + return a.Chtimes(name, atime, mtime) +} + +// CreateContext implements AferoFs. +func (a *DiscardFs) CreateContext(ctx Context, name string) (afero.File, error) { + return a.Create(name) +} + +// MkdirAllContext implements AferoFs. +func (a *DiscardFs) MkdirAllContext(ctx Context, path string, perm fs.FileMode) error { + return a.MkdirAll(path, perm) +} + +// MkdirContext implements AferoFs. +func (a *DiscardFs) MkdirContext(ctx Context, name string, perm fs.FileMode) error { + return a.Mkdir(name, perm) +} + +// OpenContext implements AferoFs. +func (a *DiscardFs) OpenContext(ctx Context, name string) (afero.File, error) { + return a.Open(name) +} + +// OpenFileContext implements AferoFs. +func (a *DiscardFs) OpenFileContext(ctx Context, name string, flag int, perm fs.FileMode) (afero.File, error) { + return a.OpenFile(name, flag, perm) +} + +// RemoveAllContext implements AferoFs. +func (a *DiscardFs) RemoveAllContext(ctx Context, path string) error { + return a.RemoveAll(path) +} + +// RemoveContext implements AferoFs. +func (a *DiscardFs) RemoveContext(ctx Context, name string) error { + return a.Remove(name) +} + +// RenameContext implements AferoFs. +func (a *DiscardFs) RenameContext(ctx Context, oldname string, newname string) error { + return a.Rename(oldname, newname) +} + +// StatContext implements AferoFs. +func (a *DiscardFs) StatContext(ctx Context, name string) (fs.FileInfo, error) { + return a.Stat(name) +} diff --git a/context/discard_test.go b/context/discard_test.go new file mode 100644 index 0000000..82da56c --- /dev/null +++ b/context/discard_test.go @@ -0,0 +1,792 @@ +package context_test + +import ( + "errors" + "io/fs" + "os" + "syscall" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/context" + "github.com/unmango/aferox/testing" +) + +var _ = Describe("Discard", func() { + var base *testing.Fs + + BeforeEach(func() { + base = &testing.Fs{} + }) + + Describe("Chmod", func() { + It("should call base", func() { + var ( + actualName string + actualMode fs.FileMode + ) + base.ChmodFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return nil + } + fs := context.Discard(base) + + err := fs.Chmod("blah", os.ModePerm) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + actualMode fs.FileMode + ) + base.ChmodFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return nil + } + fs := context.Discard(base) + + err := fs.ChmodContext(ctx, "blah", os.ModePerm) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.ChmodFunc = func(s string, fm fs.FileMode) error { + return expected + } + fs := context.Discard(base) + + err := fs.Chmod("blah", os.ModePerm) + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.ChmodFunc = func(s string, fm fs.FileMode) error { + return expected + } + fs := context.Discard(base) + + err := fs.ChmodContext(ctx, "blah", os.ModePerm) + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Chown", func() { + It("should call base", func() { + var ( + actualName string + actualUid int + actualGid int + ) + base.ChownFunc = func(s string, i1, i2 int) error { + actualName = s + actualUid = i1 + actualGid = i2 + return nil + } + fs := context.Discard(base) + + err := fs.Chown("blah", 69, 420) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualUid).To(Equal(69)) + Expect(actualGid).To(Equal(420)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + actualUid int + actualGid int + ) + base.ChownFunc = func(s string, i1, i2 int) error { + actualName = s + actualUid = i1 + actualGid = i2 + return nil + } + fs := context.Discard(base) + + err := fs.ChownContext(ctx, "blah", 69, 420) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualUid).To(Equal(69)) + Expect(actualGid).To(Equal(420)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.ChownFunc = func(s string, i1, i2 int) error { + return expected + } + fs := context.Discard(base) + + err := fs.Chown("blah", 69, 420) + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.ChownFunc = func(s string, i1, i2 int) error { + return expected + } + fs := context.Discard(base) + + err := fs.ChownContext(ctx, "blah", 69, 420) + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Chtimes", func() { + It("should call base", func() { + var ( + actualName string + actualAtime time.Time + actualMtime time.Time + atime = time.Now() + mtime = time.Now() + ) + base.ChtimesFunc = func(s string, t1, t2 time.Time) error { + actualName = s + actualAtime = t1 + actualMtime = t2 + return nil + } + fs := context.Discard(base) + + err := fs.Chtimes("blah", atime, mtime) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualAtime).To(Equal(atime)) + Expect(actualMtime).To(Equal(mtime)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + actualAtime time.Time + actualMtime time.Time + atime = time.Now() + mtime = time.Now() + ) + base.ChtimesFunc = func(s string, t1, t2 time.Time) error { + actualName = s + actualAtime = t1 + actualMtime = t2 + return nil + } + fs := context.Discard(base) + + err := fs.ChtimesContext(ctx, "blah", atime, mtime) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualAtime).To(Equal(atime)) + Expect(actualMtime).To(Equal(mtime)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.ChtimesFunc = func(s string, t1, t2 time.Time) error { + return expected + } + fs := context.Discard(base) + + err := fs.Chtimes("blah", time.Time{}, time.Time{}) + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.ChtimesFunc = func(s string, t1, t2 time.Time) error { + return expected + } + fs := context.Discard(base) + + err := fs.ChtimesContext(ctx, "blah", time.Time{}, time.Time{}) + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Create", func() { + It("should call base", func() { + var ( + actualName string + expectedFile = &testing.File{} + ) + base.CreateFunc = func(s string) (afero.File, error) { + actualName = s + return expectedFile, nil + } + fs := context.Discard(base) + + f, err := fs.Create("blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(f).To(BeIdenticalTo(expectedFile)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + expectedFile = &testing.File{} + ) + base.CreateFunc = func(s string) (afero.File, error) { + actualName = s + return expectedFile, nil + } + fs := context.Discard(base) + + f, err := fs.CreateContext(ctx, "blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(f).To(BeIdenticalTo(expectedFile)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.CreateFunc = func(s string) (afero.File, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.Create("blah") + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.CreateFunc = func(s string) (afero.File, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.CreateContext(ctx, "blah") + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("MkdirAll", func() { + It("should call base", func() { + var ( + actualName string + actualMode fs.FileMode + ) + base.MkdirAllFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return nil + } + fs := context.Discard(base) + + err := fs.MkdirAll("blah", os.ModeDir) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + actualMode fs.FileMode + ) + base.MkdirAllFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return nil + } + fs := context.Discard(base) + + err := fs.MkdirAllContext(ctx, "blah", os.ModeDir) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.MkdirAllFunc = func(s string, fm fs.FileMode) error { + return expected + } + fs := context.Discard(base) + + err := fs.MkdirAll("blah", os.ModeDir) + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.MkdirAllFunc = func(s string, fm fs.FileMode) error { + return expected + } + fs := context.Discard(base) + + err := fs.MkdirAllContext(ctx, "blah", os.ModeDir) + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Mkdir", func() { + It("should call base", func() { + var ( + actualName string + actualMode fs.FileMode + ) + base.MkdirFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return nil + } + fs := context.Discard(base) + + err := fs.Mkdir("blah", os.ModeDir) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + actualMode fs.FileMode + ) + base.MkdirFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return nil + } + fs := context.Discard(base) + + err := fs.MkdirContext(ctx, "blah", os.ModeDir) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.MkdirFunc = func(s string, fm fs.FileMode) error { + return expected + } + fs := context.Discard(base) + + err := fs.Mkdir("blah", os.ModeDir) + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.MkdirFunc = func(s string, fm fs.FileMode) error { + return expected + } + fs := context.Discard(base) + + err := fs.MkdirContext(ctx, "blah", os.ModeDir) + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Open", func() { + It("should call base", func() { + var ( + actualName string + expectedFile = &testing.File{} + ) + base.OpenFunc = func(s string) (afero.File, error) { + actualName = s + return expectedFile, nil + } + fs := context.Discard(base) + + f, err := fs.Open("blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(f).To(BeIdenticalTo(expectedFile)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + expectedFile = &testing.File{} + ) + base.OpenFunc = func(s string) (afero.File, error) { + actualName = s + return expectedFile, nil + } + fs := context.Discard(base) + + f, err := fs.OpenContext(ctx, "blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(f).To(BeIdenticalTo(expectedFile)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.OpenFunc = func(s string) (afero.File, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.Open("blah") + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.OpenFunc = func(s string) (afero.File, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.OpenContext(ctx, "blah") + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("OpenFile", func() { + It("should call base", func() { + var ( + actualName string + actualFlag int + actualMode fs.FileMode + expectedFile = &testing.File{} + ) + base.OpenFileFunc = func(s string, i int, fm fs.FileMode) (afero.File, error) { + actualName = s + actualFlag = i + actualMode = fm + return expectedFile, nil + } + fs := context.Discard(base) + + f, err := fs.OpenFile("blah", syscall.O_APPEND, os.ModePerm) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualFlag).To(Equal(syscall.O_APPEND)) + Expect(actualMode).To(Equal(os.ModePerm)) + Expect(f).To(BeIdenticalTo(expectedFile)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + actualFlag int + actualMode fs.FileMode + expectedFile = &testing.File{} + ) + base.OpenFileFunc = func(s string, i int, fm fs.FileMode) (afero.File, error) { + actualName = s + actualFlag = i + actualMode = fm + return expectedFile, nil + } + fs := context.Discard(base) + + f, err := fs.OpenFileContext(ctx, "blah", syscall.O_APPEND, os.ModePerm) + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(actualFlag).To(Equal(syscall.O_APPEND)) + Expect(actualMode).To(Equal(os.ModePerm)) + Expect(f).To(BeIdenticalTo(expectedFile)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.OpenFileFunc = func(s string, i int, fm fs.FileMode) (afero.File, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.OpenFile("blah", syscall.O_APPEND, os.ModePerm) + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.OpenFileFunc = func(s string, i int, fm fs.FileMode) (afero.File, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.OpenFileContext(ctx, "blah", syscall.O_APPEND, os.ModePerm) + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Remove", func() { + It("should call base", func() { + var actualName string + base.RemoveFunc = func(s string) error { + actualName = s + return nil + } + fs := context.Discard(base) + + err := fs.Remove("blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + }) + + It("should discard context", func(ctx context.Context) { + var actualName string + base.RemoveFunc = func(s string) error { + actualName = s + return nil + } + fs := context.Discard(base) + + err := fs.RemoveContext(ctx, "blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.RemoveFunc = func(s string) error { + return expected + } + fs := context.Discard(base) + + err := fs.Remove("blah") + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.RemoveFunc = func(s string) error { + return expected + } + fs := context.Discard(base) + + err := fs.RemoveContext(ctx, "blah") + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("RemoveAll", func() { + It("should call base", func() { + var actualName string + base.RemoveAllFunc = func(s string) error { + actualName = s + return nil + } + fs := context.Discard(base) + + err := fs.RemoveAll("blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + }) + + It("should discard context", func(ctx context.Context) { + var actualName string + base.RemoveAllFunc = func(s string) error { + actualName = s + return nil + } + fs := context.Discard(base) + + err := fs.RemoveAllContext(ctx, "blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.RemoveAllFunc = func(s string) error { + return expected + } + fs := context.Discard(base) + + err := fs.RemoveAll("blah") + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.RemoveAllFunc = func(s string) error { + return expected + } + fs := context.Discard(base) + + err := fs.RemoveAllContext(ctx, "blah") + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Rename", func() { + It("should call base", func() { + var ( + actualOld string + actualNew string + ) + base.RenameFunc = func(s1, s2 string) error { + actualOld = s1 + actualNew = s2 + return nil + } + fs := context.Discard(base) + + err := fs.Rename("blah", "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualOld).To(Equal("blah")) + Expect(actualNew).To(Equal("bleh")) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualOld string + actualNew string + ) + base.RenameFunc = func(s1, s2 string) error { + actualOld = s1 + actualNew = s2 + return nil + } + fs := context.Discard(base) + + err := fs.RenameContext(ctx, "blah", "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualOld).To(Equal("blah")) + Expect(actualNew).To(Equal("bleh")) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.RenameFunc = func(s1, s2 string) error { + return expected + } + fs := context.Discard(base) + + err := fs.Rename("blah", "bleh") + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.RenameFunc = func(s1, s2 string) error { + return expected + } + fs := context.Discard(base) + + err := fs.RenameContext(ctx, "blah", "bleh") + + Expect(err).To(MatchError(expected)) + }) + }) + + Describe("Stat", func() { + It("should call base", func() { + var ( + actualName string + expectedInfo = &testing.FileInfo{} + ) + base.StatFunc = func(s string) (fs.FileInfo, error) { + actualName = s + return expectedInfo, nil + } + fs := context.Discard(base) + + i, err := fs.Stat("blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(i).To(BeIdenticalTo(expectedInfo)) + }) + + It("should discard context", func(ctx context.Context) { + var ( + actualName string + expectedInfo = &testing.FileInfo{} + ) + base.StatFunc = func(s string) (fs.FileInfo, error) { + actualName = s + return expectedInfo, nil + } + fs := context.Discard(base) + + i, err := fs.StatContext(ctx, "blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(actualName).To(Equal("blah")) + Expect(i).To(BeIdenticalTo(expectedInfo)) + }) + + It("should return the error returned by base", func() { + expected := errors.New("sentinel") + base.StatFunc = func(s string) (fs.FileInfo, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.Stat("blah") + + Expect(err).To(MatchError(expected)) + }) + + It("should return the error returned by base with context", func(ctx context.Context) { + expected := errors.New("sentinel") + base.StatFunc = func(s string) (fs.FileInfo, error) { + return nil, expected + } + fs := context.Discard(base) + + _, err := fs.StatContext(ctx, "blah") + + Expect(err).To(MatchError(expected)) + }) + }) +}) diff --git a/context/fs.go b/context/fs.go new file mode 100644 index 0000000..a31ba57 --- /dev/null +++ b/context/fs.go @@ -0,0 +1,105 @@ +package context + +import ( + "os" + "time" + + "github.com/spf13/afero" +) + +type File = afero.File + +// Fs is a filesystem interface. +type Fs interface { + // Create creates a file in the filesystem, returning the file and an + // error, if any happens. + Create(ctx Context, name string) (File, error) + + // Mkdir creates a directory in the filesystem, return an error if any + // happens. + Mkdir(ctx Context, name string, perm os.FileMode) error + + // MkdirAll creates a directory path and all parents that does not exist + // yet. + MkdirAll(ctx Context, path string, perm os.FileMode) error + + // Open opens a file, returning it or an error, if any happens. + Open(ctx Context, name string) (File, error) + + // OpenFile opens a file using the given flags and the given mode. + OpenFile(ctx Context, name string, flag int, perm os.FileMode) (File, error) + + // Remove removes a file identified by name, returning an error, if any + // happens. + Remove(ctx Context, name string) error + + // RemoveAll removes a directory path and any children it contains. It + // does not fail if the path does not exist (return nil). + RemoveAll(ctx Context, path string) error + + // Rename renames a file. + Rename(ctx Context, oldname, newname string) error + + // Stat returns a FileInfo describing the named file, or an error, if any + // happens. + Stat(ctx Context, name string) (os.FileInfo, error) + + // The name of this FileSystem + Name() string + + // Chmod changes the mode of the named file to mode. + Chmod(ctx Context, name string, mode os.FileMode) error + + // Chown changes the uid and gid of the named file. + Chown(ctx Context, name string, uid, gid int) error + + // Chtimes changes the access and modification times of the named file + Chtimes(ctx Context, name string, atime time.Time, mtime time.Time) error +} + +// AferoFs is a filesystem interface. +type AferoFs interface { + afero.Fs + + // CreateContext creates a file in the filesystem, returning the file and an + // error, if any happens. + CreateContext(ctx Context, name string) (File, error) + + // MkdirContext creates a directory in the filesystem, return an error if any + // happens. + MkdirContext(ctx Context, name string, perm os.FileMode) error + + // MkdirAllContext creates a directory path and all parents that does not exist + // yet. + MkdirAllContext(ctx Context, path string, perm os.FileMode) error + + // OpenContext opens a file, returning it or an error, if any happens. + OpenContext(ctx Context, name string) (File, error) + + // OpenFileContext opens a file using the given flags and the given mode. + OpenFileContext(ctx Context, name string, flag int, perm os.FileMode) (File, error) + + // RemoveContext removes a file identified by name, returning an error, if any + // happens. + RemoveContext(ctx Context, name string) error + + // RemoveAllContext removes a directory path and any children it contains. It + // does not fail if the path does not exist (return nil). + RemoveAllContext(ctx Context, path string) error + + // RenameContext renames a file. + RenameContext(ctx Context, oldname, newname string) error + + // StatContext returns a FileInfo describing the named file, or an error, if any + // happens. + StatContext(ctx Context, name string) (os.FileInfo, error) + + // ChmodContext changes the mode of the named file to mode. + ChmodContext(ctx Context, name string, mode os.FileMode) error + + // ChownContext changes the uid and gid of the named file. + ChownContext(ctx Context, name string, uid, gid int) error + + // ChtimesContext changes the access and modification times of the named file + ChtimesContext(ctx Context, name string, atime time.Time, mtime time.Time) error +} diff --git a/context/setter.go b/context/setter.go new file mode 100644 index 0000000..4ade265 --- /dev/null +++ b/context/setter.go @@ -0,0 +1,95 @@ +package context + +import ( + "fmt" + "io/fs" + "time" + + "github.com/spf13/afero" +) + +// WithSetterFs adapts an [afero.Fs] to an [Fs] by calling [SetContext] on the given +// [ContextSetter] before forwarding each operation to the given [afero.Fs] +type WithSetterFs struct { + Setter + Fs afero.Fs +} + +// Chmod implements Fs. +func (s *WithSetterFs) Chmod(ctx Context, name string, mode fs.FileMode) error { + s.SetContext(ctx) + return s.Fs.Chmod(name, mode) +} + +// Chown implements Fs. +func (s *WithSetterFs) Chown(ctx Context, name string, uid int, gid int) error { + s.SetContext(ctx) + return s.Fs.Chown(name, uid, gid) +} + +// Chtimes implements Fs. +func (s *WithSetterFs) Chtimes(ctx Context, name string, atime time.Time, mtime time.Time) error { + s.SetContext(ctx) + return s.Fs.Chtimes(name, atime, mtime) +} + +// Create implements Fs. +func (s *WithSetterFs) Create(ctx Context, name string) (afero.File, error) { + s.SetContext(ctx) + return s.Fs.Create(name) +} + +// Mkdir implements Fs. +func (s *WithSetterFs) Mkdir(ctx Context, name string, perm fs.FileMode) error { + s.SetContext(ctx) + return s.Fs.Mkdir(name, perm) +} + +// MkdirAll implements Fs. +func (s *WithSetterFs) MkdirAll(ctx Context, path string, perm fs.FileMode) error { + s.SetContext(ctx) + return s.Fs.MkdirAll(path, perm) +} + +// Name implements Fs. +func (s *WithSetterFs) Name() string { + return fmt.Sprintf("Scoped: %s", s.Fs.Name()) +} + +// Open implements Fs. +func (s *WithSetterFs) Open(ctx Context, name string) (afero.File, error) { + s.SetContext(ctx) + return s.Fs.Open(name) +} + +// OpenFile implements Fs. +func (s *WithSetterFs) OpenFile(ctx Context, name string, flag int, perm fs.FileMode) (afero.File, error) { + s.SetContext(ctx) + return s.Fs.OpenFile(name, flag, perm) +} + +// Remove implements Fs. +func (s *WithSetterFs) Remove(ctx Context, name string) error { + s.SetContext(ctx) + return s.Fs.Remove(name) +} + +// RemoveAll implements Fs. +func (s *WithSetterFs) RemoveAll(ctx Context, path string) error { + s.SetContext(ctx) + return s.Fs.RemoveAll(path) +} + +// Rename implements Fs. +func (s *WithSetterFs) Rename(ctx Context, oldname string, newname string) error { + s.SetContext(ctx) + return s.Fs.Rename(oldname, newname) +} + +// Stat implements Fs. +func (s *WithSetterFs) Stat(ctx Context, name string) (fs.FileInfo, error) { + s.SetContext(ctx) + return s.Fs.Stat(name) +} + +var _ Fs = (*WithSetterFs)(nil) diff --git a/context/setter_test.go b/context/setter_test.go new file mode 100644 index 0000000..821e9ab --- /dev/null +++ b/context/setter_test.go @@ -0,0 +1,406 @@ +package context_test + +import ( + "errors" + "io/fs" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/context" + "github.com/unmango/aferox/testing" +) + +type noopSetter struct{} + +func (noopSetter) SetContext(context.Context) {} + +type setter struct { + Ctx context.Context +} + +func (c *setter) SetContext(ctx context.Context) { + c.Ctx = ctx +} + +var _ = Describe("Setter", func() { + var base *testing.Fs + + BeforeEach(func() { + base = &testing.Fs{} + }) + + It("should call base Chmod", func(ctx context.Context) { + var ( + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.ChmodFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.Chmod(ctx, "bleh", os.ModePerm) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should set the context when calling Chmod", func(ctx context.Context) { + base.ChmodFunc = func(string, fs.FileMode) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.Chmod(ctx, "bleh", os.ModePerm) + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Chown", func(ctx context.Context) { + var ( + actualUid int + actualGid int + expectedErr = errors.New("sentinel") + ) + base.ChownFunc = func(s string, i1, i2 int) error { + actualUid = i1 + actualGid = i2 + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.Chown(ctx, "bleh", 420, 69) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualUid).To(Equal(420)) + Expect(actualGid).To(Equal(69)) + }) + + It("should set the context when calling Chown", func(ctx context.Context) { + base.ChownFunc = func(s string, i1, i2 int) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.Chown(ctx, "bleh", 69, 420) + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Chtimes", func(ctx context.Context) { + var ( + actualName string + actualAtime time.Time + actualMtime time.Time + expectedErr = errors.New("sentinel") + ) + base.ChtimesFunc = func(s string, t1, t2 time.Time) error { + actualName = s + actualAtime = t1 + actualMtime = t2 + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.Chtimes(ctx, "bleh", time.Unix(69, 420), time.Unix(420, 69)) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + Expect(actualAtime).To(Equal(time.Unix(69, 420))) + Expect(actualMtime).To(Equal(time.Unix(420, 69))) + }) + + It("should set the context when calling Chtimes", func(ctx context.Context) { + base.ChtimesFunc = func(s string, t1, t2 time.Time) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.Chtimes(ctx, "bleh", time.Time{}, time.Time{}) + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Create", func(ctx context.Context) { + var ( + actualName string + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.CreateFunc = func(s string) (afero.File, error) { + actualName = s + return expectedFile, expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + f, err := fs.Create(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should set the context when calling Create", func(ctx context.Context) { + base.CreateFunc = func(string) (afero.File, error) { return nil, nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + _, err := fs.Create(ctx, "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base MkdirAll", func(ctx context.Context) { + var ( + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.MkdirAllFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.MkdirAll(ctx, "bleh", os.ModeDir) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should set the context when calling MkdirAll", func(ctx context.Context) { + base.MkdirAllFunc = func(string, fs.FileMode) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.MkdirAll(ctx, "bleh", 0) + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Mkdir", func(ctx context.Context) { + var ( + actualName string + actualMode fs.FileMode + expectedErr = errors.New("sentinel") + ) + base.MkdirFunc = func(s string, fm fs.FileMode) error { + actualName = s + actualMode = fm + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.Mkdir(ctx, "bleh", os.ModeDir) + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + Expect(actualMode).To(Equal(os.ModeDir)) + }) + + It("should set the context when calling Mkdir", func(ctx context.Context) { + base.MkdirFunc = func(string, fs.FileMode) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.Mkdir(ctx, "bleh", 0) + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Open", func(ctx context.Context) { + var ( + actualName string + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.OpenFunc = func(s string) (afero.File, error) { + actualName = s + return expectedFile, expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + f, err := fs.Open(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should set the context when calling Open", func(ctx context.Context) { + base.OpenFunc = func(string) (afero.File, error) { return nil, nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + _, err := fs.Open(ctx, "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base OpenFile", func(ctx context.Context) { + var ( + actualName string + actualFlag int + actualMode fs.FileMode + expectedFile = &testing.File{} + expectedErr = errors.New("sentinel") + ) + base.OpenFileFunc = func(s string, i int, fm fs.FileMode) (afero.File, error) { + actualName = s + actualFlag = i + actualMode = fm + return expectedFile, expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + f, err := fs.OpenFile(ctx, "bleh", 69, os.ModePerm) + + Expect(err).To(MatchError(expectedErr)) + Expect(f).To(BeIdenticalTo(expectedFile)) + Expect(actualName).To(Equal("bleh")) + Expect(actualFlag).To(Equal(69)) + Expect(actualMode).To(Equal(os.ModePerm)) + }) + + It("should set the context when calling OpenFile", func(ctx context.Context) { + base.OpenFileFunc = func(string, int, fs.FileMode) (afero.File, error) { return nil, nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + _, err := fs.OpenFile(ctx, "bleh", 0, 0) + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base RemoveAll", func(ctx context.Context) { + var ( + actualName string + expectedErr = errors.New("sentinel") + ) + base.RemoveAllFunc = func(s string) error { + actualName = s + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.RemoveAll(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should set the context when calling RemoveAll", func(ctx context.Context) { + base.RemoveAllFunc = func(string) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.RemoveAll(ctx, "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Remove", func(ctx context.Context) { + var ( + actualName string + expectedErr = errors.New("sentinel") + ) + base.RemoveFunc = func(s string) error { + actualName = s + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.Remove(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + }) + + It("should set the context when calling Remove", func(ctx context.Context) { + base.RemoveFunc = func(string) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.Remove(ctx, "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Rename", func(ctx context.Context) { + var ( + actualOld string + actualNew string + expectedErr = errors.New("sentinel") + ) + base.RenameFunc = func(s1, s2 string) error { + actualOld = s1 + actualNew = s2 + return expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + err := fs.Rename(ctx, "bleh", "blah") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualOld).To(Equal("bleh")) + Expect(actualNew).To(Equal("blah")) + }) + + It("should set the context when calling Rename", func(ctx context.Context) { + base.RenameFunc = func(s1, s2 string) error { return nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + err := fs.Rename(ctx, "bleh", "blah") + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) + + It("should call base Stat", func(ctx context.Context) { + var ( + actualName string + expectedInfo = &testing.FileInfo{} + expectedErr = errors.New("sentinel") + ) + base.StatFunc = func(s string) (fs.FileInfo, error) { + actualName = s + return expectedInfo, expectedErr + } + fs := context.WithSetterFs{noopSetter{}, base} + + i, err := fs.Stat(ctx, "bleh") + + Expect(err).To(MatchError(expectedErr)) + Expect(actualName).To(Equal("bleh")) + Expect(i).To(BeIdenticalTo(expectedInfo)) + }) + + It("should set the context when calling Stat", func(ctx context.Context) { + base.StatFunc = func(string) (fs.FileInfo, error) { return nil, nil } + s := &setter{} + fs := context.WithSetterFs{s, base} + + _, err := fs.Stat(ctx, "bleh") + + Expect(err).NotTo(HaveOccurred()) + Expect(s.Ctx).To(BeIdenticalTo(ctx)) + }) +}) diff --git a/copy.go b/copy.go new file mode 100644 index 0000000..85b59ae --- /dev/null +++ b/copy.go @@ -0,0 +1,10 @@ +package aferox + +import ( + "github.com/spf13/afero" + "github.com/unmango/aferox/internal" +) + +func Copy(src, dest afero.Fs) error { + return internal.Copy(src, dest) +} diff --git a/copy_test.go b/copy_test.go new file mode 100644 index 0000000..8cd1024 --- /dev/null +++ b/copy_test.go @@ -0,0 +1,98 @@ +package aferox_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + aferox "github.com/unmango/aferox" + "github.com/unmango/go/testing/gfs" +) + +var _ = Describe("Copy", func() { + It("should copy files", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = aferox.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFileWithBytes("test.txt", []byte("testing"))) + }) + + It("should copy directories", func() { + src := afero.NewMemMapFs() + err := src.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = aferox.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + stat, err := dest.Stat("test") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the directory is created")) + }) + + It("should copy directories with files", func() { + src := afero.NewMemMapFs() + err := src.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test/test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = aferox.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + stat, err := dest.Stat("test") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the directory is created")) + Expect(dest).To(gfs.ContainFileWithBytes("test/test.txt", []byte("testing"))) + }) + + It("should copy multiple files", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test2.txt", []byte("testing2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = aferox.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFileWithBytes("test.txt", []byte("testing"))) + Expect(dest).To(gfs.ContainFileWithBytes("test2.txt", []byte("testing2"))) + }) + + It("should copy a directory structure", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = src.MkdirAll("test/other", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test/test2.txt", []byte("testing2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test/other/test3.txt", []byte("testing3"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = aferox.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFileWithBytes("test.txt", []byte("testing"))) + stat, err := dest.Stat("test") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the first directory is created")) + Expect(dest).To(gfs.ContainFileWithBytes("test/test2.txt", []byte("testing2"))) + stat, err = dest.Stat("test/other") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the second directory is created")) + Expect(dest).To(gfs.ContainFileWithBytes("test/other/test3.txt", []byte("testing3"))) + }) +}) diff --git a/docker/docker_suite_test.go b/docker/docker_suite_test.go new file mode 100644 index 0000000..1a5aada --- /dev/null +++ b/docker/docker_suite_test.go @@ -0,0 +1,54 @@ +package docker_test + +import ( + "context" + "testing" + + "github.com/docker/docker/client" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/testcontainers/testcontainers-go" +) + +type logger struct{} + +func (l logger) Printf(format string, v ...interface{}) { + GinkgoWriter.Printf(format+"\n", v) +} + +var ( + testclient client.ContainerAPIClient + ctr testcontainers.Container +) + +func TestDocker(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Docker Suite", Label("E2E")) +} + +var _ = BeforeSuite(func(ctx context.Context) { + var err error + testclient, err = client.NewClientWithOpts( + client.WithAPIVersionNegotiation(), + ) + Expect(err).NotTo(HaveOccurred()) + + req := testcontainers.ContainerRequest{ + Image: "ubuntu", + Cmd: []string{"sleep", "infinity"}, + } + + ctr, err = testcontainers.GenericContainer(ctx, + testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + Logger: logger{}, + }, + ) + Expect(err).NotTo(HaveOccurred()) +}) + +var _ = AfterSuite(func() { + err := testcontainers.TerminateContainer(ctr) + Expect(err).NotTo(HaveOccurred()) +}) diff --git a/docker/file.go b/docker/file.go new file mode 100644 index 0000000..627ad67 --- /dev/null +++ b/docker/file.go @@ -0,0 +1,225 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "io/fs" + "path/filepath" + "strings" + "syscall" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/unmango/aferox/docker/internal" +) + +type File struct { + client client.ContainerAPIClient + container string + name string + + close func() error + stat container.PathStat + reader io.Reader +} + +// Close implements afero.File. +func (f *File) Close() error { + if f.close != nil { + return f.close() + } + + return nil +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.name +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if err = f.ensure(); err != nil { + return + } + + return f.reader.Read(p) +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + if ra, ok := f.reader.(io.ReaderAt); ok { + return ra.ReadAt(p, off) + } + + return 0, syscall.EPERM +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + ctx := context.TODO() + buf := &bytes.Buffer{} + err := f.execo(ctx, internal.ExecOptions{ + Cmd: []string{"dir", "-x1", f.name}, + Stdout: buf, + }) + if err != nil { + return nil, err + } + + cleaned := strings.TrimSpace(buf.String()) + paths := strings.Split(cleaned, "\n") + length := min(len(paths), count) + infos := make([]fs.FileInfo, length) + + for i := 0; i < length; i++ { + stat, err := Stat(ctx, f.client, f.container, paths[i]) + if err != nil { + return nil, err + } + + infos[i] = stat + } + + return infos, nil +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + infos, err := f.Readdir(n) + if err != nil { + return nil, err + } + + length := min(n, len(infos)) + names := make([]string, length) + for i, info := range infos { + names[i] = info.Name() + } + + return names, nil +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + panic("unimplemented") +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return Stat(context.TODO(), f.client, f.container, f.name) +} + +// Sync implements afero.File. +func (f *File) Sync() error { + // TODO: Memory sync vs container fs sync? + return f.exec(context.TODO(), "sync", f.name) +} + +// Truncate implements afero.File. +func (f *File) Truncate(size int64) error { + return f.exec(context.TODO(), + "truncate", fmt.Sprintf("--size=%d", size), f.name, + ) +} + +// Write implements afero.File. +func (f *File) Write(p []byte) (n int, err error) { + // TODO: This only works in one go + content := &bytes.Buffer{} + w := tar.NewWriter(content) + err = w.WriteHeader(&tar.Header{ + Name: filepath.Base(f.name), + Size: int64(len(p)), + }) + if err != nil { + return + } + + err = f.client.CopyToContainer(context.TODO(), + f.container, + f.name, + bytes.NewBuffer(p), + container.CopyToContainerOptions{}, + ) + if err != nil { + return + } + + n = len(p) + return +} + +// WriteAt implements afero.File. +func (f *File) WriteAt(p []byte, off int64) (n int, err error) { + panic("unimplemented") +} + +// WriteString implements afero.File. +func (f *File) WriteString(s string) (ret int, err error) { + content := &bytes.Buffer{} + w := tar.NewWriter(content) + err = w.WriteHeader(&tar.Header{ + Name: filepath.Base(f.name), + Size: int64(len(s)), + }) + if err != nil { + return + } + + ret, err = io.WriteString(w, s) + if err != nil { + return + } + + err = f.client.CopyToContainer(context.TODO(), + f.container, + filepath.Dir(f.name), + content, + container.CopyToContainerOptions{}, + ) + if err != nil { + return + } + + return +} + +func (f *File) ensure() error { + if f.reader != nil { + return nil + } + + ctx := context.TODO() + reader, stat, err := f.client.CopyFromContainer(ctx, + f.container, + f.name, + ) + if err != nil { + return err + } + + tar := tar.NewReader(reader) + if _, err = tar.Next(); err != nil { + return err + } + + f.reader = tar + f.stat = stat + f.close = reader.Close + + return nil +} + +func (f *File) exec(ctx context.Context, cmd ...string) error { + return f.execo(ctx, internal.ExecOptions{ + Cmd: cmd, + }) +} + +func (f *File) execo(ctx context.Context, options internal.ExecOptions) error { + return internal.Exec(ctx, f.client, f.container, options) +} diff --git a/docker/fileinfo.go b/docker/fileinfo.go new file mode 100644 index 0000000..96b024d --- /dev/null +++ b/docker/fileinfo.go @@ -0,0 +1,42 @@ +package docker + +import ( + "io/fs" + "time" + + "github.com/docker/docker/api/types/container" +) + +type FileInfo struct { + stat container.PathStat +} + +// IsDir implements fs.FileInfo. +func (f FileInfo) IsDir() bool { + return f.stat.Mode.IsDir() +} + +// ModTime implements fs.FileInfo. +func (f FileInfo) ModTime() time.Time { + return f.stat.Mtime +} + +// Mode implements fs.FileInfo. +func (f FileInfo) Mode() fs.FileMode { + return f.stat.Mode +} + +// Name implements fs.FileInfo. +func (f FileInfo) Name() string { + return f.stat.Name +} + +// Size implements fs.FileInfo. +func (f FileInfo) Size() int64 { + return f.stat.Size +} + +// Sys implements fs.FileInfo. +func (f FileInfo) Sys() any { + return f.stat +} diff --git a/docker/fs.go b/docker/fs.go new file mode 100644 index 0000000..c8401c5 --- /dev/null +++ b/docker/fs.go @@ -0,0 +1,121 @@ +package docker + +import ( + "fmt" + "io/fs" + "time" + + "github.com/docker/docker/client" + "github.com/spf13/afero" + "github.com/unmango/aferox/context" + "github.com/unmango/aferox/docker/internal" +) + +type Fs struct { + client client.ContainerAPIClient + container string +} + +// Chmod implements afero.Fs. +func (f Fs) Chmod(ctx context.Context, name string, mode fs.FileMode) error { + return f.exec(ctx, "chmod", mode.String(), name) +} + +// Chown implements afero.Fs. +func (f Fs) Chown(ctx context.Context, name string, uid int, gid int) error { + return f.exec(ctx, + "chown", fmt.Sprintf("%d:%d", uid, gid), name, + ) +} + +// Chtimes implements afero.Fs. +func (f Fs) Chtimes(ctx context.Context, name string, atime time.Time, mtime time.Time) error { + panic("unimplemented") +} + +// Create implements afero.Fs. +func (f Fs) Create(ctx context.Context, name string) (afero.File, error) { + err := f.exec(ctx, "touch", name) + if err != nil { + return nil, err + } + + // TODO: Less lazy? + return &File{ + client: f.client, + container: f.container, + name: name, + }, nil +} + +// Mkdir implements afero.Fs. +func (f Fs) Mkdir(ctx context.Context, name string, perm fs.FileMode) error { + return f.exec(ctx, + "mkdir", fmt.Sprintf("--mode=%d", perm), name, + ) +} + +// MkdirAll implements afero.Fs. +func (f Fs) MkdirAll(ctx context.Context, path string, perm fs.FileMode) error { + return f.exec(ctx, + "mkdir", "--parents", fmt.Sprintf("--mode=%d", perm), path, + ) +} + +// Name implements afero.Fs. +func (f Fs) Name() string { + return f.container +} + +// Open implements afero.Fs. +func (f Fs) Open(ctx context.Context, name string) (afero.File, error) { + // TODO: Less lazy? + return &File{ + client: f.client, + container: f.container, + name: name, + }, nil +} + +// OpenFile implements afero.Fs. +func (f Fs) OpenFile(ctx context.Context, name string, flag int, perm fs.FileMode) (afero.File, error) { + // TODO: Actual implementation + // TODO: Less lazy? + return &File{ + client: f.client, + container: f.container, + name: name, + }, nil +} + +// Remove implements afero.Fs. +func (f Fs) Remove(ctx context.Context, name string) error { + return f.exec(ctx, "rm", name) +} + +// RemoveAll implements afero.Fs. +func (f Fs) RemoveAll(ctx context.Context, path string) error { + return f.exec(ctx, "rm", "--recursive", path) +} + +// Rename implements afero.Fs. +func (f Fs) Rename(ctx context.Context, oldname string, newname string) error { + return f.exec(ctx, "mv", oldname, newname) +} + +// Stat implements afero.Fs. +func (f Fs) Stat(ctx context.Context, name string) (fs.FileInfo, error) { + return Stat(ctx, f.client, f.container, name) +} + +func (f Fs) exec(ctx context.Context, cmd ...string) error { + return internal.Exec(ctx, f.client, f.container, + internal.ExecOptions{ + Cmd: cmd, + }, + ) +} + +func NewFs(client client.ContainerAPIClient, container string) context.Fs { + return Fs{client, container} +} diff --git a/docker/fs_test.go b/docker/fs_test.go new file mode 100644 index 0000000..8c26044 --- /dev/null +++ b/docker/fs_test.go @@ -0,0 +1,65 @@ +package docker_test + +import ( + "context" + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/docker" +) + +var _ = Describe("Fs", func() { + It("should list directories", func(ctx context.Context) { + fs := docker.NewFs(testclient, ctr.GetContainerID()) + dir, err := fs.Open(ctx, "/") + Expect(err).NotTo(HaveOccurred()) + + infos, err := dir.Readdir(69) + + Expect(err).NotTo(HaveOccurred()) + names := make([]string, len(infos)) + for i, f := range infos { + names[i] = f.Name() + } + Expect(names).To(ContainElements("root", "var", "bin")) + }) + + It("should read file contents", func(ctx context.Context) { + fs := docker.NewFs(testclient, ctr.GetContainerID()) + file, err := fs.Create(ctx, "test-read.txt") + Expect(err).NotTo(HaveOccurred()) + _, err = io.WriteString(file, "bleh") + Expect(err).NotTo(HaveOccurred()) + Expect(file.Close()).To(Succeed()) + + file, err = fs.Open(ctx, "test-read.txt") + + Expect(err).NotTo(HaveOccurred()) + data, err := io.ReadAll(file) + Expect(err).NotTo(HaveOccurred()) + Expect(string(data)).To(Equal("bleh")) + }) + + Describe("Create", func() { + It("should create a file", func(ctx context.Context) { + fsys := docker.NewFs(testclient, ctr.GetContainerID()) + + file, err := fsys.Create(ctx, "test.txt") + + Expect(err).NotTo(HaveOccurred()) + Expect(file).NotTo(BeNil()) + }) + + It("should create a writable file", func(ctx context.Context) { + fsys := docker.NewFs(testclient, ctr.GetContainerID()) + + file, err := fsys.Create(ctx, "writable.txt") + + Expect(err).NotTo(HaveOccurred()) + _, err = file.WriteString("blahblahblah") + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/docker/internal/exec.go b/docker/internal/exec.go new file mode 100644 index 0000000..dd85f97 --- /dev/null +++ b/docker/internal/exec.go @@ -0,0 +1,71 @@ +package internal + +import ( + "context" + "fmt" + "io" + "time" + + ctr "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" +) + +type ExecOptions struct { + Cmd []string + Stdout io.Writer + Stderr io.Writer +} + +func Exec( + ctx context.Context, + client client.ContainerAPIClient, + container string, + options ExecOptions, +) error { + id, err := client.ContainerExecCreate(ctx, container, ctr.ExecOptions{ + Cmd: options.Cmd, + AttachStdout: options.Stdout != nil, + AttachStderr: options.Stderr != nil, + }) + if err != nil { + return fmt.Errorf("creating exec: %w", err) + } + + conn, err := client.ContainerExecAttach(ctx, id.ID, ctr.ExecStartOptions{}) + if err != nil { + return fmt.Errorf("attaching to exec process: %w", err) + } + defer conn.Close() + + err = client.ContainerExecStart(ctx, id.ID, ctr.ExecStartOptions{}) + if err != nil { + return fmt.Errorf("starting exec: %w", err) + } + + var stat ctr.ExecInspect + ctx, cancel := context.WithTimeout(ctx, time.Second*15) + defer cancel() + + for { + stat, err = client.ContainerExecInspect(ctx, id.ID) + if err != nil { + return err + } + if !stat.Running { + break + } + } + if stat.ExitCode != 0 { + return fmt.Errorf("exec returned non-zero exit code: %d", stat.ExitCode) + } + + if options.Stdout != nil { + _, err = stdcopy.StdCopy(options.Stdout, options.Stderr, conn.Reader) + if err != nil { + return fmt.Errorf("copying exec output: %w", err) + } + } + + return nil +} diff --git a/docker/internal/exec_test.go b/docker/internal/exec_test.go new file mode 100644 index 0000000..3f25c4f --- /dev/null +++ b/docker/internal/exec_test.go @@ -0,0 +1,64 @@ +package internal_test + +import ( + "bytes" + "context" + + "github.com/docker/docker/client" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/testcontainers/testcontainers-go" + + "github.com/unmango/aferox/docker/internal" +) + +type logger struct{} + +func (l logger) Printf(format string, v ...interface{}) { + GinkgoWriter.Printf(format+"\n", v) +} + +var _ = Describe("Exec", Label("E2E"), func() { + var ( + ctr testcontainers.Container + docker client.APIClient + ) + + BeforeEach(func(ctx context.Context) { + req := testcontainers.ContainerRequest{ + Image: "ubuntu", + Cmd: []string{"sleep", "infinity"}, + } + + var err error + ctr, err = testcontainers.GenericContainer(ctx, + testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + Logger: logger{}, + }, + ) + Expect(err).NotTo(HaveOccurred()) + + docker, err = client.NewClientWithOpts( + client.WithAPIVersionNegotiation(), + ) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterEach(func() { + err := testcontainers.TerminateContainer(ctr) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should work", func(ctx context.Context) { + buf := &bytes.Buffer{} + err := internal.Exec(ctx, docker, ctr.GetContainerID(), internal.ExecOptions{ + Cmd: []string{"echo", "testing"}, + Stdout: buf, + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(buf.String()).To(Equal("testing\n")) + }) +}) diff --git a/docker/internal/internal_suite_test.go b/docker/internal/internal_suite_test.go new file mode 100644 index 0000000..c82b65a --- /dev/null +++ b/docker/internal/internal_suite_test.go @@ -0,0 +1,13 @@ +package internal_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestInternal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal Suite") +} diff --git a/docker/op.go b/docker/op.go new file mode 100644 index 0000000..2579f35 --- /dev/null +++ b/docker/op.go @@ -0,0 +1,22 @@ +package docker + +import ( + "context" + + ctr "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +func Stat( + ctx context.Context, + client client.ContainerAPIClient, + container, path string, +) (info FileInfo, err error) { + var stat ctr.PathStat + stat, err = client.ContainerStatPath(ctx, container, path) + if err != nil { + return + } + + return FileInfo{stat}, nil +} diff --git a/filter/file.go b/filter/file.go new file mode 100644 index 0000000..78c505a --- /dev/null +++ b/filter/file.go @@ -0,0 +1,98 @@ +package filter + +import ( + "io/fs" + + "github.com/spf13/afero" +) + +type File struct { + file afero.File + pred Predicate +} + +// Close implements afero.File. +func (f *File) Close() error { + return f.file.Close() +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.file.Name() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + return f.file.Read(p) +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + return f.file.ReadAt(p, off) +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) (res []fs.FileInfo, err error) { + var infos []fs.FileInfo + infos, err = f.file.Readdir(count) + if err != nil { + return nil, err + } + + for _, i := range infos { + if i.IsDir() || f.pred(i.Name()) { + res = append(res, i) + } + } + + return res, nil +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) (names []string, err error) { + infos, err := f.Readdir(n) + if err != nil { + return nil, err + } + + for _, i := range infos { + names = append(names, i.Name()) + } + + return names, nil +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + return f.file.Seek(offset, whence) +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return f.file.Stat() +} + +// Sync implements afero.File. +func (f *File) Sync() error { + return f.file.Sync() +} + +// Truncate implements afero.File. +func (f *File) Truncate(size int64) error { + return f.file.Truncate(size) +} + +// Write implements afero.File. +func (f *File) Write(p []byte) (n int, err error) { + return f.file.Write(p) +} + +// WriteAt implements afero.File. +func (f *File) WriteAt(p []byte, off int64) (n int, err error) { + return f.file.WriteAt(p, off) +} + +// WriteString implements afero.File. +func (f *File) WriteString(s string) (ret int, err error) { + return f.file.WriteString(s) +} diff --git a/filter/filter_suite_test.go b/filter/filter_suite_test.go new file mode 100644 index 0000000..f850b06 --- /dev/null +++ b/filter/filter_suite_test.go @@ -0,0 +1,13 @@ +package filter_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFilter(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Filter Suite") +} diff --git a/filter/fs.go b/filter/fs.go new file mode 100644 index 0000000..74d625b --- /dev/null +++ b/filter/fs.go @@ -0,0 +1,175 @@ +package filter + +import ( + "fmt" + "io/fs" + "syscall" + "time" + + "github.com/spf13/afero" +) + +type Predicate func(string) bool + +type Fs struct { + src afero.Fs + pred Predicate +} + +// Chmod implements afero.Fs. +func (f *Fs) Chmod(name string, mode fs.FileMode) error { + if err := f.dirOrMatches(name); err != nil { + return err + } + + return f.src.Chmod(name, mode) +} + +// Chown implements afero.Fs. +func (f *Fs) Chown(name string, uid int, gid int) error { + if err := f.dirOrMatches(name); err != nil { + return err + } + + return f.src.Chown(name, uid, gid) +} + +// Chtimes implements afero.Fs. +func (f *Fs) Chtimes(name string, atime time.Time, mtime time.Time) error { + if err := f.dirOrMatches(name); err != nil { + return err + } + + return f.src.Chtimes(name, atime, mtime) +} + +// Create implements afero.Fs. +func (f *Fs) Create(name string) (afero.File, error) { + if err := f.matchesName(name); err != nil { + return nil, err + } + + return f.src.Create(name) +} + +// Mkdir implements afero.Fs. +func (f *Fs) Mkdir(name string, perm fs.FileMode) error { + return f.src.Mkdir(name, perm) +} + +// MkdirAll implements afero.Fs. +func (f *Fs) MkdirAll(path string, perm fs.FileMode) error { + return f.src.Mkdir(path, perm) +} + +// Name implements afero.Fs. +func (f *Fs) Name() string { + return fmt.Sprintf("Filter: %s", f.src.Name()) +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + dir, err := afero.IsDir(f.src, name) + if err != nil { + return nil, err + } + if !dir { + if err := f.matchesName(name); err != nil { + return nil, err + } + } + + file, err := f.src.Open(name) + if err != nil { + return nil, err + } + + return &File{ + file: file, + pred: f.pred, + }, nil +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, flag int, perm fs.FileMode) (afero.File, error) { + if err := f.dirOrMatches(name); err != nil { + return nil, err + } + + return f.src.OpenFile(name, flag, perm) +} + +// Remove implements afero.Fs. +func (f *Fs) Remove(name string) error { + if err := f.dirOrMatches(name); err != nil { + return err + } + return f.src.Remove(name) +} + +// RemoveAll implements afero.Fs. +func (f *Fs) RemoveAll(path string) error { + dir, err := afero.IsDir(f.src, path) + if err != nil { + return err + } + if !dir { + if err = f.matchesName(path); err != nil { + return err + } + } + + return f.src.RemoveAll(path) +} + +// Rename implements afero.Fs. +func (f *Fs) Rename(oldname string, newname string) error { + dir, err := afero.IsDir(f.src, oldname) + if err != nil { + return err + } + if dir { + return nil + } + if err = f.matchesName(oldname); err != nil { + return err + } + if err = f.matchesName(newname); err != nil { + return err + } + + return f.src.Rename(oldname, newname) +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + if err := f.dirOrMatches(name); err != nil { + return nil, err + } + + return f.src.Stat(name) +} + +func (f *Fs) dirOrMatches(name string) error { + dir, err := afero.IsDir(f.src, name) + if err != nil { + return err + } + if dir { + return nil + } + + return f.matchesName(name) +} + +func (f *Fs) matchesName(name string) error { + if f.pred == nil || f.pred(name) { + return nil + } else { + return syscall.ENOENT + } +} + +func NewFs(src afero.Fs, predicate Predicate) afero.Fs { + return &Fs{src: src, pred: predicate} +} diff --git a/filter/fs_test.go b/filter/fs_test.go new file mode 100644 index 0000000..68d45c1 --- /dev/null +++ b/filter/fs_test.go @@ -0,0 +1,261 @@ +package filter_test + +import ( + "io/fs" + "os" + "path/filepath" + "syscall" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/filter" +) + +var _ = Describe("Fs", func() { + It("should not allow chmod-ing a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + err = filtered.Chmod("test.txt", os.ModeAppend) + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow chmod-ing a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + err = filtered.Chmod("test.txt", os.ModeAppend) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not allow chown-ing a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + err = filtered.Chown("test.txt", 1001, 1001) + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow chown-ing a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + err = filtered.Chown("test.txt", 1001, 1001) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not allow chtimes-ing a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + err = filtered.Chtimes("test.txt", time.Now(), time.Now()) + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow chtimes-ing a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + err = filtered.Chtimes("test.txt", time.Now(), time.Now()) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not allow creating a filtered file", func() { + fs := afero.NewMemMapFs() + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + _, err := filtered.Create("test.txt") + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow creating a non-filtered file", func() { + fs := afero.NewMemMapFs() + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + _, err := filtered.Create("test.txt") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should include the source filesystem name", func() { + fs := filter.NewFs(afero.NewMemMapFs(), func(s string) bool { + return true + }) + + Expect(fs.Name()).To(Equal("Filter: MemMapFS")) + }) + + It("should not allow opening a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + _, err = filtered.Open("test.txt") + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow opening a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + _, err = filtered.Open("test.txt") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not allow open-file-ing a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + _, err = filtered.OpenFile("test.txt", 69, os.ModeAppend) + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow open-file-ing a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + _, err = filtered.OpenFile("test.txt", 69, os.ModeAppend) + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not allow removing a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + err = filtered.Remove("test.txt") + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow removing a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + err = filtered.Remove("test.txt") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow removing a directory", func() { + fs := afero.NewMemMapFs() + err := fs.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + err = filtered.Remove("test") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should not allow stat-ing a filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "not-test.txt" + }) + + _, err = filtered.Stat("test.txt") + + Expect(err).To(MatchError(syscall.ENOENT)) + }) + + It("should allow stat-ing a non-filtered file", func() { + fs := afero.NewMemMapFs() + err := afero.WriteFile(fs, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(fs, func(name string) bool { + return name == "test.txt" + }) + + _, err = filtered.Stat("test.txt") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should walk properly", func() { + base := afero.NewMemMapFs() + Expect(base.Mkdir("test", os.ModePerm)).To(Succeed()) + err := afero.WriteFile(base, "test/file.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + filtered := filter.NewFs(base, func(s string) bool { + return filepath.Ext(s) != ".txt" + }) + paths := []string{} + + err = afero.Walk(filtered, "", func(path string, info fs.FileInfo, err error) error { + paths = append(paths, path) + return err + }) + + Expect(err).NotTo(HaveOccurred()) + Expect(paths).To(ConsistOf("", "test")) + }) +}) diff --git a/first.go b/first.go new file mode 100644 index 0000000..834ac20 --- /dev/null +++ b/first.go @@ -0,0 +1,75 @@ +package aferox + +import ( + "errors" + "io/fs" + + "github.com/spf13/afero" + "github.com/unmango/go/option" +) + +func StatFirst(fsys afero.Fs, root string, options ...IterOption) (fs.FileInfo, error) { + opts := &iterOptions{} + option.ApplyAll(opts, options) + + info, err := Fold(fsys, root, + func(path string, info fs.FileInfo, acc fs.FileInfo, err error) (fs.FileInfo, error) { + if err != nil { + return nil, err + } + if path == "." || path == "" { + return nil, nil + } + if info.IsDir() && opts.skipDirs { + return nil, nil + } + + return info, fs.SkipAll + }, + nil, + ) + + if err != nil && !errors.Is(err, fs.SkipAll) { + return nil, err + } + if info == nil { + return nil, errors.New("Fs contains no entries") + } + + return info, nil +} + +func OpenFirst(fsys afero.Fs, root string, options ...IterOption) (afero.File, error) { + opts := &iterOptions{} + option.ApplyAll(opts, options) + + file, err := Fold(fsys, root, + func(path string, info fs.FileInfo, acc afero.File, err error) (afero.File, error) { + if err != nil { + return nil, err + } + if path == "." || path == "" { + return nil, nil + } + if info.IsDir() && opts.skipDirs { + return nil, nil + } + + if file, err := fsys.Open(path); err != nil { + return nil, err + } else { + return file, fs.SkipAll + } + }, + nil, + ) + + if err != nil && !errors.Is(err, fs.SkipAll) { + return nil, err + } + if file == nil { + return nil, errors.New("Fs contains no entries") + } + + return file, nil +} diff --git a/first_test.go b/first_test.go new file mode 100644 index 0000000..2b4bcd2 --- /dev/null +++ b/first_test.go @@ -0,0 +1,147 @@ +package aferox_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + aferox "github.com/unmango/aferox" +) + +var _ = Describe("Single", func() { + Describe("StatSingle", func() { + It("should stat an Fs with a single file", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.StatFirst(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test.txt")) + }) + + It("should stat an Fs with a single directory", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.StatFirst(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test")) + }) + + It("should not error when Fs contains multiple files", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("oops.txt") + Expect(err).NotTo(HaveOccurred()) + + _, err = aferox.StatFirst(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should error when Fs contains no files", func() { + fsys := afero.NewMemMapFs() + + _, err := aferox.StatFirst(fsys, "") + + Expect(err).To(HaveOccurred()) + }) + + When("SkipDirs is provided", func() { + It("should stat the first file", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("test/test.txt") + + info, err := aferox.StatFirst(fsys, "", aferox.SkipDirs) + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test.txt")) + }) + + It("should error when only directories exist", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + + _, err = aferox.StatFirst(fsys, "", aferox.SkipDirs) + + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("OpenSingle", func() { + It("should open in an Fs with a single file", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.OpenFirst(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test.txt")) + }) + + It("should open in an Fs with a single directory", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.OpenFirst(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test")) + }) + + It("should not error when Fs contains multiple files", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("oops.txt") + Expect(err).NotTo(HaveOccurred()) + + _, err = aferox.OpenFirst(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + }) + + It("should error when Fs contains no files", func() { + fsys := afero.NewMemMapFs() + + _, err := aferox.OpenFirst(fsys, "") + + Expect(err).To(HaveOccurred()) + }) + + When("SkipDirs is provided", func() { + It("should stat the first file", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("test/test.txt") + + info, err := aferox.OpenFirst(fsys, "", aferox.SkipDirs) + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test/test.txt")) + }) + + It("should error when only directories exist", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + + _, err = aferox.OpenFirst(fsys, "", aferox.SkipDirs) + + Expect(err).To(HaveOccurred()) + }) + }) + }) +}) diff --git a/fold.go b/fold.go new file mode 100644 index 0000000..215127e --- /dev/null +++ b/fold.go @@ -0,0 +1,21 @@ +package aferox + +import ( + "io/fs" + + "github.com/spf13/afero" +) + +type FoldFunc[T any] func(string, fs.FileInfo, T, error) (T, error) + +func Fold[T any](fsys afero.Fs, root string, folder FoldFunc[T], initial T) (acc T, err error) { + acc = initial + err = afero.Walk(fsys, root, + func(path string, info fs.FileInfo, err error) error { + acc, err = folder(path, info, acc, err) + return err + }, + ) + + return +} diff --git a/fold_test.go b/fold_test.go new file mode 100644 index 0000000..1c6d124 --- /dev/null +++ b/fold_test.go @@ -0,0 +1,31 @@ +package aferox_test + +import ( + "io/fs" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + aferox "github.com/unmango/aferox" +) + +var _ = Describe("Fold", func() { + It("should work", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + var count int + + res, err := aferox.Fold(fsys, "", + func(path string, info fs.FileInfo, acc int, err error) (int, error) { + return acc + 1, err + }, + count, + ) + + Expect(err).NotTo(HaveOccurred()) + // Includes the "." path + Expect(res).To(Equal(2)) + }) +}) diff --git a/fs.go b/fs.go new file mode 100644 index 0000000..7cc5b77 --- /dev/null +++ b/fs.go @@ -0,0 +1,34 @@ +package aferox + +import ( + "io" + + "github.com/docker/docker/client" + "github.com/spf13/afero" + "github.com/unmango/aferox/context" + "github.com/unmango/aferox/docker" + "github.com/unmango/aferox/github" + "github.com/unmango/aferox/github/repository" + "github.com/unmango/aferox/github/repository/content" + "github.com/unmango/aferox/github/repository/release" + "github.com/unmango/aferox/github/user" + "github.com/unmango/aferox/writer" +) + +type ( + Docker = docker.Fs + GitHub = github.Fs + GitHubRelease = release.Fs + GitHubRepository = repository.Fs + GitHubRepositoryContent = content.Fs + GitHubUser = user.Fs + Writer = writer.Fs +) + +func NewWriter(w io.Writer) afero.Fs { + return writer.NewFs(w) +} + +func NewDocker(client client.ContainerAPIClient, container string) context.Fs { + return docker.NewFs(client, container) +} diff --git a/fs_suite_test.go b/fs_suite_test.go new file mode 100644 index 0000000..a2b3340 --- /dev/null +++ b/fs_suite_test.go @@ -0,0 +1,13 @@ +package aferox_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestFs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Fs Suite") +} diff --git a/github/fs.go b/github/fs.go new file mode 100644 index 0000000..dbc9e72 --- /dev/null +++ b/github/fs.go @@ -0,0 +1,62 @@ +package github + +import ( + "context" + "fmt" + "io/fs" + + "github.com/google/go-github/v68/github" + "github.com/spf13/afero" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/user" +) + +type Client = github.Client + +var NewClient = github.NewClient + +type Fs struct { + internal.ReadOnlyFs + client *github.Client +} + +// Name implements afero.Fs. +func (g *Fs) Name() string { + return "https://github.com" +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + if path, err := ghpath.Parse(name); err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } else { + return user.Open(context.TODO(), f.client, path) + } +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + if path, err := ghpath.Parse(name); err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } else { + return user.Open(context.TODO(), f.client, path) + } +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + if path, err := ghpath.Parse(name); err != nil { + return nil, fmt.Errorf("stat %s: %w", name, err) + } else { + return user.Stat(context.TODO(), f.client, path) + } +} + +func NewFs(gh *github.Client) afero.Fs { + if gh == nil { + gh = internal.DefaultClient() + } + + return &Fs{client: gh} +} diff --git a/github/ghpath/ghpath_suite_test.go b/github/ghpath/ghpath_suite_test.go new file mode 100644 index 0000000..5193c78 --- /dev/null +++ b/github/ghpath/ghpath_suite_test.go @@ -0,0 +1,13 @@ +package ghpath_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGhpath(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Ghpath Suite") +} diff --git a/github/ghpath/path.go b/github/ghpath/path.go new file mode 100644 index 0000000..e17d9e5 --- /dev/null +++ b/github/ghpath/path.go @@ -0,0 +1,356 @@ +package ghpath + +import ( + "errors" + "fmt" + "path" + "slices" + "strings" + + "github.com/charmbracelet/log" + "github.com/goware/urlx" +) + +var knownHosts = []string{ + "github.com", + "api.github.com", + "raw.githubusercontent.com", +} + +type Path interface { + fmt.Stringer + Asset() (string, error) + Branch() (string, error) + Content() []string + Owner() (string, error) + Repository() (string, error) + Release() (string, error) +} + +type Parser interface { + Parse(string) (Path, error) +} + +type OwnerPath struct { + Owner string +} + +func (p OwnerPath) Parse(path string) (Path, error) { + return Parse(p.Owner, path) +} + +func (p OwnerPath) String() string { + return fmt.Sprintf("https://github.com/%s", p.Owner) +} + +type RepositoryPath struct { + OwnerPath + Repository string +} + +func (p RepositoryPath) Parse(path string) (Path, error) { + if HasReleasePrefix(path) || HasBranchPrefix(path) { + return Parse(p.Owner, p.Repository, path) + } else { + return nil, fmt.Errorf("unable to guess path type: %s", path) + } +} + +func (p RepositoryPath) String() string { + return fmt.Sprintf("%s/%s", p.OwnerPath, p.Repository) +} + +type BranchPath struct { + RepositoryPath + Branch string +} + +func (p BranchPath) Parse(path string) (Path, error) { + return Parse(p.Owner, p.Repository, "tree", p.Branch, path) +} + +func (p BranchPath) String() string { + return fmt.Sprintf("%s/tree/%s", p.RepositoryPath, p.Branch) +} + +type ContentPath struct { + BranchPath + Content string +} + +func (p ContentPath) Parse(path string) (Path, error) { + return Parse(p.Owner, p.Repository, "tree", p.Branch, p.Content, path) +} + +func (p ContentPath) String() string { + return fmt.Sprintf("%s/%s", p.BranchPath, p.Content) +} + +type ReleasePath struct { + RepositoryPath + Release string +} + +func (p ReleasePath) Parse(path string) (Path, error) { + return Parse(p.Owner, p.Repository, "releases", "tag", p.Release, path) +} + +func (p ReleasePath) String() string { + return fmt.Sprintf("%s/releases/tag/%s", p.RepositoryPath, p.Release) +} + +type AssetPath struct { + ReleasePath + Asset string +} + +func (p AssetPath) Parse(path string) (Path, error) { + return Parse(p.Owner, p.Repository, p.Release, p.Asset, path) +} + +func (p AssetPath) String() string { + return fmt.Sprintf("%s/download/%s", p.ReleasePath, p.Asset) +} + +func NewOwnerPath(owner string) OwnerPath { + return OwnerPath{Owner: owner} +} + +func NewRepositoryPath(owner, repo string) RepositoryPath { + return RepositoryPath{ + OwnerPath: NewOwnerPath(owner), + Repository: repo, + } +} + +func NewBranchPath(owner, repo, branch string) BranchPath { + return BranchPath{ + RepositoryPath: NewRepositoryPath(owner, repo), + Branch: branch, + } +} + +func NewContentPath(owner, repo, branch, content string) ContentPath { + return ContentPath{ + BranchPath: NewBranchPath(owner, repo, branch), + Content: content, + } +} + +func NewReleasePath(owner, repo, release string) ReleasePath { + return ReleasePath{ + RepositoryPath: NewRepositoryPath(owner, repo), + Release: release, + } +} + +func NewAssetPath(owner, repo, release, asset string) AssetPath { + return AssetPath{ + ReleasePath: NewReleasePath(owner, repo, release), + Asset: asset, + } +} + +type ghpath []string + +func (g ghpath) String() string { + return path.Join(g...) +} + +// Asset implements Path. +func (g ghpath) Asset() (string, error) { + if _, err := g.Release(); err != nil { + return "", errors.New("not a release") + } + + if a, err := g.index(5, "asset"); err != nil { + return "", err + } else if a == "download" { + return g.index(6, "asset") + } else { + return a, nil + } +} + +// Branch implements Path. +func (g ghpath) Branch() (string, error) { + if g.has(2, "tree") { + return g.index(3, "branch") + } + + if g.has(2, "refs") { + return g.index(4, "branch") + } + + return "", errors.New("not a branch") +} + +// Content implements Path. +func (g ghpath) Content() []string { + if g.has(2, "tree") { + return g[4:] + } + + if g.has(2, "refs") { + return g[5:] + } + + return []string{} +} + +// Release implements Path. +func (g ghpath) Release() (string, error) { + // This will change when I decide to support content + if len(g) == 3 { + return g[2], nil + } + + if !g.has(2, "releases") { + return "", errors.New("no release") + } + + if g.has(3, "tag") || g.has(3, "download") { + return g.index(4, "release") + } + + return "", errors.New("no release") +} + +// Owner implements Path. +func (g ghpath) Owner() (string, error) { + return g.index(0, "owner") +} + +// Repository implements Path. +func (g ghpath) Repository() (string, error) { + return g.index(1, "repository") +} + +func (g ghpath) has(i int, name string) bool { + part, err := g.index(i, name) + return err == nil && part == name +} + +func (g ghpath) index(i int, name string) (string, error) { + if len(g) <= i { + return "", fmt.Errorf("no %s", name) + } else { + return g[i], nil + } +} + +func ParseUrl(rawURL string) (Path, error) { + url, err := urlx.Parse(rawURL) + if err != nil { + return nil, err + } + + parts := strings.Split(url.Path, "/") + return Parse(parts...) +} + +func Parse(parts ...string) (Path, error) { + if len(parts) == 0 { + return nil, errors.New("empty path") + } + + path := []string{} + for _, p := range parts { + if p == "" { + continue + } + + url, err := urlx.Parse(p) + if err != nil { + log.Errorf("err: %s, p: %s", err, p) + return nil, err + } + + if slices.Contains(knownHosts, url.Host) { + continue + } + + for _, s := range strings.Split(p, "/") { + if s == "" { + continue + } + + path = append(path, s) + } + } + + return ghpath(path), nil +} + +func ParseOwner(path Path) (owner OwnerPath, err error) { + if owner.Owner, err = path.Owner(); err != nil { + return + } + + return +} + +func ParseRepository(path Path) (repo RepositoryPath, err error) { + if repo.OwnerPath, err = ParseOwner(path); err != nil { + return + } + + if repo.Repository, err = path.Repository(); err != nil { + return + } + + return +} + +func ParseBranch(path Path) (branch BranchPath, err error) { + if branch.RepositoryPath, err = ParseRepository(path); err != nil { + return + } + + if branch.Branch, err = path.Branch(); err != nil { + return + } + + return +} + +func ParseContent(p Path) (content ContentPath, err error) { + if content.BranchPath, err = ParseBranch(p); err != nil { + return + } + + content.Content = path.Join(p.Content()...) + return +} + +func ParseRelease(path Path) (release ReleasePath, err error) { + if release.RepositoryPath, err = ParseRepository(path); err != nil { + return + } + + if release.Release, err = path.Release(); err != nil { + return + } + + return +} + +func ParseAsset(path Path) (asset AssetPath, err error) { + if asset.ReleasePath, err = ParseRelease(path); err != nil { + return + } + + if asset.Asset, err = path.Asset(); err != nil { + return + } + + return +} + +func HasReleasePrefix(s string) bool { + return strings.HasPrefix(s, "releases/tag") +} + +func HasBranchPrefix(s string) bool { + return strings.HasPrefix(s, "tree") || strings.HasPrefix(s, "refs/heads") +} diff --git a/github/ghpath/path_test.go b/github/ghpath/path_test.go new file mode 100644 index 0000000..3ed17ae --- /dev/null +++ b/github/ghpath/path_test.go @@ -0,0 +1,578 @@ +package ghpath_test + +import ( + "fmt" + "testing/quick" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/ghpath" +) + +var _ = Describe("Path", func() { + Describe("Parse", func() { + DescribeTable("Owner URL", + Entry(nil, "https://github.com/unmango", "unmango"), + Entry(nil, "github.com/unmango", "unmango"), + Entry(nil, "https://api.github.com/unmango", "unmango"), + Entry(nil, "api.github.com/unmango", "unmango"), + func(input, name string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Owner()).To(Equal(name)) + }, + ) + + DescribeTable("Owner", + Entry(nil, "unmango", "unmango"), + func(input, name string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Owner()).To(Equal(name)) + }, + ) + + DescribeTable("Repository URL", + Entry(nil, "https://github.com/unmango/go", "go"), + Entry(nil, "github.com/unmango/go", "go"), + Entry(nil, "https://api.github.com/unmango/go", "go"), + Entry(nil, "api.github.com/unmango/go", "go"), + func(input, name string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Repository()).To(Equal(name)) + }, + ) + + DescribeTable("Repository", + Entry(nil, "unmango/go", "go"), + func(input, name string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Repository()).To(Equal(name)) + }, + ) + + DescribeTable("No Repository URL", + Entry(nil, "https://github.com/unmango"), + Entry(nil, "github.com/unmango"), + Entry(nil, "https://api.github.com/unmango"), + Entry(nil, "api.github.com/unmango"), + func(input string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Repository() + Expect(err).To(MatchError("no repository")) + }, + ) + + DescribeTable("No Repository", + Entry(nil, "unmango"), + func(input string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Repository() + Expect(err).To(MatchError("no repository")) + }, + ) + + DescribeTable("Branch URL", + Entry(nil, "https://github.com/unmango/go/tree/main", "main"), + Entry(nil, "https://api.github.com/unmango/go/tree/main", "main"), + Entry(nil, "api.github.com/unmango/go/tree/main", "main"), + Entry(nil, "github.com/unmango/go/tree/main", "main"), + Entry(nil, "https://raw.githubusercontent.com/unmango/go/refs/heads/main/fs/fold.go", "main"), + func(input, name string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Branch()).To(Equal(name)) + }, + ) + + DescribeTable("Branch", + Entry(nil, "unmango/go/tree/main", "main"), + Entry(nil, "unmango/go/tree/main/fs", "main"), + Entry(nil, "unmango/go/tree/main/fs/path_test.go", "main"), + Entry(nil, "unmango/go/tree/feature-name", "feature-name"), + func(input, name string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Branch()).To(Equal(name)) + }, + ) + + DescribeTable("Not a Branch URL", + Entry(nil, "https://github.com/unmango"), + Entry(nil, "github.com/unmango"), + Entry(nil, "https://api.github.com/unmango"), + Entry(nil, "api.github.com/unmango"), + Entry(nil, "https://github.com/unmango/go"), + Entry(nil, "github.com/unmango/go"), + Entry(nil, "https://api.github.com/unmango/go"), + Entry(nil, "api.github.com/unmango/go"), + func(input string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Branch() + Expect(err).To(MatchError("not a branch")) + }, + ) + + DescribeTable("Not a Branch", + Entry(nil, "unmango"), + Entry(nil, "unmango/go"), + func(input string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Branch() + Expect(err).To(MatchError("not a branch")) + }, + ) + + DescribeTable("No Branch URL", + Entry(nil, "https://github.com/unmango/go/tree"), + Entry(nil, "https://api.github.com/unmango/go/tree"), + Entry(nil, "api.github.com/unmango/go/tree"), + Entry(nil, "github.com/unmango/go/tree"), + Entry(nil, "https://raw.githubusercontent.com/unmango/go/refs/heads"), + Entry(nil, "https://raw.githubusercontent.com/unmango/go/refs"), + func(input string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Branch() + Expect(err).To(MatchError("no branch")) + }, + ) + + DescribeTable("Release URL", + Entry(nil, "https://github.com/unmango/go/releases/tag/v0.0.69", "v0.0.69"), + Entry(nil, "https://api.github.com/unmango/go/releases/tag/v0.0.69", "v0.0.69"), + Entry(nil, "api.github.com/unmango/go/releases/tag/v0.0.69", "v0.0.69"), + Entry(nil, "github.com/unmango/go/releases/tag/v0.0.69", "v0.0.69"), + Entry(nil, "https://github.com/unmango/go/releases/download/v0.0.69", "v0.0.69"), + Entry(nil, "https://api.github.com/unmango/go/releases/download/v0.0.69", "v0.0.69"), + Entry(nil, "api.github.com/unmango/go/releases/download/v0.0.69", "v0.0.69"), + Entry(nil, "github.com/unmango/go/releases/download/v0.0.69", "v0.0.69"), + func(input, name string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Release()).To(Equal(name)) + }, + ) + + DescribeTable("Release", + Entry(nil, "unmango/go/releases/tag/v0.0.69", "v0.0.69"), + Entry(nil, "unmango/go/releases/download/v0.0.69", "v0.0.69"), + func(input, name string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Release()).To(Equal(name)) + }, + ) + + DescribeTable("No Release URL", + Entry(nil, "https://github.com/unmango/go/releases/bleh/v0.0.69", "v0.0.69"), + Entry(nil, "https://api.github.com/unmango/go/releases/bleh/v0.0.69", "v0.0.69"), + Entry(nil, "api.github.com/unmango/go/releases/bleh/v0.0.69", "v0.0.69"), + Entry(nil, "github.com/unmango/go/releases/bleh/v0.0.69", "v0.0.69"), + func(input, name string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Release() + Expect(err).To(MatchError("no release")) + }, + ) + + DescribeTable("No Release", + Entry(nil, "unmango/go/releases/bleh/v0.0.69", "v0.0.69"), + func(input, name string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Release() + Expect(err).To(MatchError("no release")) + }, + ) + + DescribeTable("Asset URL", + Entry(nil, "https://github.com/unmango/go/releases/tag/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "https://api.github.com/unmango/go/releases/tag/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "api.github.com/unmango/go/releases/tag/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "github.com/unmango/go/releases/tag/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "https://github.com/unmango/go/releases/download/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "https://api.github.com/unmango/go/releases/download/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "api.github.com/unmango/go/releases/download/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "github.com/unmango/go/releases/download/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + func(input, name string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Asset()).To(Equal(name)) + }, + ) + + DescribeTable("Asset", + Entry(nil, "unmango/go/releases/tag/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + Entry(nil, "unmango/go/releases/download/v0.0.69/my-asset.tar.gz", "my-asset.tar.gz"), + func(input, name string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Asset()).To(Equal(name)) + }, + ) + + DescribeTable("No Asset URL", + Entry(nil, "https://github.com/unmango/go/releases/tag/v0.0.69"), + Entry(nil, "https://api.github.com/unmango/go/releases/tag/v0.0.69"), + Entry(nil, "api.github.com/unmango/go/releases/tag/v0.0.69"), + Entry(nil, "github.com/unmango/go/releases/tag/v0.0.69"), + Entry(nil, "https://github.com/unmango/go/releases/download/v0.0.69"), + Entry(nil, "https://api.github.com/unmango/go/releases/download/v0.0.69"), + Entry(nil, "api.github.com/unmango/go/releases/download/v0.0.69"), + Entry(nil, "github.com/unmango/go/releases/download/v0.0.69"), + func(input string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Asset() + Expect(err).To(MatchError("no asset")) + }, + ) + + DescribeTable("No Asset", + Entry(nil, "unmango/go/releases/tag/v0.0.69"), + Entry(nil, "unmango/go/releases/download/v0.0.69"), + func(input string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + _, err = res.Asset() + Expect(err).To(MatchError("no asset")) + }, + ) + + DescribeTable("Content URL", + Entry(nil, "https://github.com/unmango/go/tree/main", []string{}), + Entry(nil, "https://api.github.com/unmango/go/tree/main", []string{}), + Entry(nil, "api.github.com/unmango/go/tree/main", []string{}), + Entry(nil, "github.com/unmango/go/tree/main", []string{}), + Entry(nil, "https://raw.githubusercontent.com/unmango/go/refs/heads/main/fs/fold.go", []string{"fs", "fold.go"}), + func(input string, parts []string) { + res, err := ghpath.ParseUrl(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Content()).To(Equal(parts)) + }, + ) + + DescribeTable("Content", + Entry(nil, "unmango/go/tree/main", []string{}), + Entry(nil, "unmango/go/tree/main/fs", []string{"fs"}), + Entry(nil, "unmango/go/tree/main/fs/path_test.go", []string{"fs", "path_test.go"}), + func(input string, parts []string) { + res, err := ghpath.Parse(input) + + Expect(err).NotTo(HaveOccurred()) + Expect(res.Content()).To(Equal(parts)) + }, + ) + + DescribeTable("Parts", + Entry(nil, + []string{"unmango", "go"}, + "unmango/go", + ), + Entry(nil, + []string{"unmango", "go", "refs", "heads", "main"}, + "unmango/go/refs/heads/main", + ), + Entry(nil, + []string{"unmango", "go", "releases", "tag", "v0.0.69"}, + "unmango/go/releases/tag/v0.0.69", + ), + func(parts []string, expected string) { + path, err := ghpath.Parse(parts...) + + Expect(err).NotTo(HaveOccurred()) + Expect(path.String()).To(Equal(expected)) + }, + ) + }) + + Describe("OwnerPath", func() { + It("should Parse repo", func() { + p := ghpath.NewOwnerPath("testing") + + r, err := p.Parse("repo-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo-name")) + }) + + It("should assume release when parsing len 2", func() { + p := ghpath.NewOwnerPath("testing") + + r, err := p.Parse("repo/release-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Release()).To(Equal("release-name")) + }) + + It("should Parse release", func() { + p := ghpath.NewOwnerPath("testing") + + r, err := p.Parse("repo/releases/tag/release-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Release()).To(Equal("release-name")) + }) + + It("should assume asset when parsing len 5", func() { + p := ghpath.NewOwnerPath("testing") + + r, err := p.Parse("repo/releases/tag/release-name/asset.tar.gz") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Release()).To(Equal("release-name")) + Expect(r.Asset()).To(Equal("asset.tar.gz")) + }) + + It("should Parse asset", func() { + p := ghpath.NewOwnerPath("testing") + + r, err := p.Parse("repo/releases/tag/release-name/download/asset.tar.gz") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Release()).To(Equal("release-name")) + Expect(r.Asset()).To(Equal("asset.tar.gz")) + }) + + It("should stringify", func() { + fn := func(owner string) bool { + p := ghpath.NewOwnerPath(owner) + + actual := p.String() + + return actual == fmt.Sprintf("https://github.com/%s", owner) + } + + Expect(quick.Check(fn, nil)).To(Succeed()) + }) + }) + + Describe("RepositoryPath", func() { + It("should Parse release", func() { + p := ghpath.NewRepositoryPath("owner", "repo") + + r, err := p.Parse("releases/tag/release-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Release()).To(Equal("release-name")) + }) + + It("should Parse branch from tree", func() { + p := ghpath.NewRepositoryPath("owner", "repo") + + r, err := p.Parse("tree/branch-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner()).To(Equal("owner")) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Branch()).To(Equal("branch-name")) + }) + + It("should Parse branch from ref", func() { + p := ghpath.NewRepositoryPath("owner", "repo") + + r, err := p.Parse("refs/heads/branch-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner()).To(Equal("owner")) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Branch()).To(Equal("branch-name")) + }) + + It("should stringify", func() { + fn := func(owner, repo string) bool { + p := ghpath.NewRepositoryPath(owner, repo) + + actual := p.String() + + return actual == fmt.Sprintf("https://github.com/%s/%s", owner, repo) + } + + Expect(quick.Check(fn, nil)).To(Succeed()) + }) + }) + + Describe("BranchPath", func() { + It("should parse", func() { + p := ghpath.NewBranchPath("owner", "repo", "branch") + + r, err := p.Parse("some-content") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner()).To(Equal("owner")) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Branch()).To(Equal("branch")) + Expect(r.Content()).To(ConsistOf("some-content")) + }) + + It("should stringify", func() { + fn := func(owner, repo, branch string) bool { + p := ghpath.NewBranchPath(owner, repo, branch) + + actual := p.String() + + return actual == fmt.Sprintf( + "https://github.com/%s/%s/tree/%s", + owner, repo, branch, + ) + } + + Expect(quick.Check(fn, nil)).To(Succeed()) + }) + }) + + Describe("ContentPath", func() { + It("should parse", func() { + p := ghpath.NewContentPath("owner", "repo", "branch", "content") + + r, err := p.Parse("other-content") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner()).To(Equal("owner")) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Branch()).To(Equal("branch")) + Expect(r.Content()).To(ConsistOf("content", "other-content")) + }) + + It("should stringify", func() { + fn := func(owner, repo, branch, content string) bool { + p := ghpath.NewContentPath(owner, repo, branch, content) + + actual := p.String() + + return actual == fmt.Sprintf( + "https://github.com/%s/%s/tree/%s/%s", + owner, repo, branch, content, + ) + } + + Expect(quick.Check(fn, nil)).To(Succeed()) + }) + }) + + Describe("ReleasePath", func() { + It("should Parse", func() { + p := ghpath.NewReleasePath("owner", "repo", "release") + + r, err := p.Parse("asset-name") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Repository()).To(Equal("repo")) + Expect(r.Release()).To(Equal("release")) + Expect(r.Asset()).To(Equal("asset-name")) + }) + + It("should stringify", func() { + fn := func(owner, repo, release string) bool { + p := ghpath.NewReleasePath(owner, repo, release) + + actual := p.String() + + return actual == fmt.Sprintf( + "https://github.com/%s/%s/releases/tag/%s", + owner, repo, release, + ) + } + + Expect(quick.Check(fn, nil)).To(Succeed()) + }) + }) + + DescribeTable("ParseOwner", + Entry(nil, "UnstoppableMango"), + Entry(nil, "UnstoppableMango/repo"), + Entry(nil, "UnstoppableMango/repo/releases/tag/tdl"), + Entry(nil, "UnstoppableMango/repo/releases/this-is-wrong/thing"), + Entry(nil, "UnstoppableMango/repo/tree/main"), + func(input string) { + p, err := ghpath.Parse(input) + Expect(err).NotTo(HaveOccurred()) + + r, err := ghpath.ParseOwner(p) + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner).To(Equal("UnstoppableMango")) + }, + ) + + DescribeTable("ParseRepository", + Entry(nil, "UnstoppableMango/repo"), + Entry(nil, "UnstoppableMango/repo/releases/tag/tdl"), + Entry(nil, "UnstoppableMango/repo/releases/this-is-wrong/thing"), + Entry(nil, "UnstoppableMango/repo/tree/main"), + func(input string) { + p, err := ghpath.Parse(input) + Expect(err).NotTo(HaveOccurred()) + + r, err := ghpath.ParseRepository(p) + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner).To(Equal("UnstoppableMango")) + Expect(r.Repository).To(Equal("repo")) + }, + ) + + DescribeTable("ParseRelease", + Entry(nil, "UnstoppableMango/repo/releases/tag/tdl"), + func(input string) { + p, err := ghpath.Parse(input) + Expect(err).NotTo(HaveOccurred()) + + r, err := ghpath.ParseRelease(p) + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner).To(Equal("UnstoppableMango")) + Expect(r.Repository).To(Equal("repo")) + Expect(r.Release).To(Equal("tdl")) + }, + ) + + DescribeTable("ParseAsset", + Entry(nil, "UnstoppableMango/repo/releases/tag/tdl/v0.0.69"), + func(input string) { + p, err := ghpath.Parse(input) + Expect(err).NotTo(HaveOccurred()) + + r, err := ghpath.ParseAsset(p) + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Owner).To(Equal("UnstoppableMango")) + Expect(r.Repository).To(Equal("repo")) + Expect(r.Release).To(Equal("tdl")) + Expect(r.Asset).To(Equal("v0.0.69")) + }, + ) +}) diff --git a/github/github_suite_test.go b/github/github_suite_test.go new file mode 100644 index 0000000..d6ac865 --- /dev/null +++ b/github/github_suite_test.go @@ -0,0 +1,13 @@ +package github_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGithub(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Github Suite") +} diff --git a/github/internal/client.go b/github/internal/client.go new file mode 100644 index 0000000..066171a --- /dev/null +++ b/github/internal/client.go @@ -0,0 +1,18 @@ +package internal + +import ( + "net/http" + "os" + + "github.com/google/go-github/v68/github" +) + +func DefaultClient() *github.Client { + client := github.NewClient(http.DefaultClient) + + if token, ok := os.LookupEnv("GITHUB_TOKEN"); ok { + client = client.WithAuthToken(token) + } + + return client +} diff --git a/github/internal/constraint.go b/github/internal/constraint.go new file mode 100644 index 0000000..004143c --- /dev/null +++ b/github/internal/constraint.go @@ -0,0 +1,17 @@ +package internal + +import "strconv" + +type NameOrId interface { + ~string | ~int64 +} + +func TryGetId[T NameOrId](x T) (int64, bool) { + var value interface{} = x + if id, ok := value.(int64); ok { + return id, true + } + + id, err := strconv.ParseInt(value.(string), 10, 64) + return id, err == nil +} diff --git a/github/internal/context.go b/github/internal/context.go new file mode 100644 index 0000000..20e8acd --- /dev/null +++ b/github/internal/context.go @@ -0,0 +1,17 @@ +package internal + +import "context" + +type ContextAccessor func() context.Context + +func (c ContextAccessor) Context() context.Context { + return c() +} + +func BackgroundContext() ContextAccessor { + return context.Background +} + +func TodoContext() ContextAccessor { + return context.TODO +} diff --git a/github/internal/internal_suite_test.go b/github/internal/internal_suite_test.go new file mode 100644 index 0000000..c82b65a --- /dev/null +++ b/github/internal/internal_suite_test.go @@ -0,0 +1,13 @@ +package internal_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestInternal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal Suite") +} diff --git a/github/internal/readonly.go b/github/internal/readonly.go new file mode 100644 index 0000000..4a64476 --- /dev/null +++ b/github/internal/readonly.go @@ -0,0 +1,36 @@ +package internal + +import ( + "syscall" + + "github.com/spf13/afero" +) + +type ReadOnlyFs = afero.ReadOnlyFs + +type ReadOnlyFile struct{} + +// Sync implements afero.File. +func (ReadOnlyFile) Sync() error { + return nil +} + +// Truncate implements afero.File. +func (ReadOnlyFile) Truncate(int64) error { + return syscall.EROFS +} + +// Write implements afero.File. +func (ReadOnlyFile) Write([]byte) (n int, err error) { + return 0, syscall.EROFS +} + +// WriteAt implements afero.File. +func (ReadOnlyFile) WriteAt([]byte, int64) (n int, err error) { + return 0, syscall.EROFS +} + +// WriteString implements afero.File. +func (ReadOnlyFile) WriteString(string) (ret int, err error) { + return 0, syscall.EROFS +} diff --git a/github/repository/content/content_suite_test.go b/github/repository/content/content_suite_test.go new file mode 100644 index 0000000..5049d3e --- /dev/null +++ b/github/repository/content/content_suite_test.go @@ -0,0 +1,25 @@ +package content_test + +import ( + "os" + "testing" + + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var client *github.Client + +func TestContent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Content Suite") +} + +var _ = BeforeSuite(func() { + client = github.NewClient(nil) + + if token, ok := os.LookupEnv("GITHUB_TOKEN"); ok { + client = client.WithAuthToken(token) + } +}) diff --git a/github/repository/content/directory.go b/github/repository/content/directory.go new file mode 100644 index 0000000..116afc5 --- /dev/null +++ b/github/repository/content/directory.go @@ -0,0 +1,78 @@ +package content + +import ( + "io/fs" + "syscall" + + "github.com/google/go-github/v68/github" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" +) + +type Directory struct { + internal.ReadOnlyFile + ghpath.ContentPath + + client *github.Client + content []*github.RepositoryContent +} + +// Close implements afero.File. +func (d *Directory) Close() error { + return nil +} + +// Name implements afero.File. +func (d *Directory) Name() string { + return d.Content +} + +// Read implements afero.File. +func (d *Directory) Read(p []byte) (n int, err error) { + return 0, syscall.EISDIR +} + +// ReadAt implements afero.File. +func (d *Directory) ReadAt(p []byte, off int64) (n int, err error) { + return 0, syscall.EISDIR +} + +// Readdir implements afero.File. +func (d *Directory) Readdir(count int) ([]fs.FileInfo, error) { + length := min(count, len(d.content)) + files := make([]fs.FileInfo, length) + + for i := 0; i < length; i++ { + files[i] = &FileInfo{content: d.content[i]} + } + + return files, nil +} + +// Readdirnames implements afero.File. +func (d *Directory) Readdirnames(n int) ([]string, error) { + infos, err := d.Readdir(n) + if err != nil { + return nil, err + } + + names := []string{} + for _, info := range infos { + names = append(names, info.Name()) + } + + return names, nil +} + +// Seek implements afero.File. +func (d *Directory) Seek(offset int64, whence int) (int64, error) { + return 0, syscall.EISDIR +} + +// Stat implements afero.File. +func (d *Directory) Stat() (fs.FileInfo, error) { + return &DirectoryInfo{ + name: d.Name(), + content: d.content, + }, nil +} diff --git a/github/repository/content/directoryinfo.go b/github/repository/content/directoryinfo.go new file mode 100644 index 0000000..fddf683 --- /dev/null +++ b/github/repository/content/directoryinfo.go @@ -0,0 +1,48 @@ +package content + +import ( + "io/fs" + "os" + "time" + + "github.com/google/go-github/v68/github" +) + +type DirectoryInfo struct { + name string + content []*github.RepositoryContent +} + +// IsDir implements fs.FileInfo. +func (d *DirectoryInfo) IsDir() bool { + return true +} + +// ModTime implements fs.FileInfo. +func (d *DirectoryInfo) ModTime() time.Time { + panic("unimplemented") +} + +// Mode implements fs.FileInfo. +func (d *DirectoryInfo) Mode() fs.FileMode { + return os.ModeDir +} + +// Name implements fs.FileInfo. +func (d *DirectoryInfo) Name() string { + return d.name +} + +// Size implements fs.FileInfo. +func (d *DirectoryInfo) Size() (s int64) { + for _, c := range d.content { + s += int64(c.GetSize()) + } + + return +} + +// Sys implements fs.FileInfo. +func (d *DirectoryInfo) Sys() any { + return d.content +} diff --git a/github/repository/content/file.go b/github/repository/content/file.go new file mode 100644 index 0000000..5101fb0 --- /dev/null +++ b/github/repository/content/file.go @@ -0,0 +1,79 @@ +package content + +import ( + "bytes" + "io/fs" + "syscall" + + "github.com/google/go-github/v68/github" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" +) + +type File struct { + internal.ReadOnlyFile + ghpath.ContentPath + + client *github.Client + content *github.RepositoryContent + + reader *bytes.Buffer +} + +// Close implements afero.File. +func (f *File) Close() error { + return nil +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.content.GetPath() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if err = f.ensure(); err != nil { + return + } + + return f.reader.Read(p) +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + return 0, syscall.EROFS +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + return nil, syscall.ENOTDIR +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + return nil, syscall.ENOTDIR +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + return 0, syscall.EPERM +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return &FileInfo{content: f.content}, nil +} + +func (f *File) ensure() error { + if f.reader != nil { + return nil + } + + content, err := f.content.GetContent() + if err != nil { + return err + } + + f.reader = bytes.NewBufferString(content) + return nil +} diff --git a/github/repository/content/fileinfo.go b/github/repository/content/fileinfo.go new file mode 100644 index 0000000..fab74da --- /dev/null +++ b/github/repository/content/fileinfo.go @@ -0,0 +1,43 @@ +package content + +import ( + "io/fs" + "os" + "time" + + "github.com/google/go-github/v68/github" +) + +type FileInfo struct { + content *github.RepositoryContent +} + +// IsDir implements fs.FileInfo. +func (f *FileInfo) IsDir() bool { + return f.content == nil +} + +// ModTime implements fs.FileInfo. +func (f *FileInfo) ModTime() time.Time { + panic("unimplemented") +} + +// Mode implements fs.FileInfo. +func (f *FileInfo) Mode() fs.FileMode { + return os.ModePerm +} + +// Name implements fs.FileInfo. +func (f *FileInfo) Name() string { + return f.content.GetName() +} + +// Size implements fs.FileInfo. +func (f *FileInfo) Size() int64 { + return int64(f.content.GetSize()) +} + +// Sys implements fs.FileInfo. +func (f *FileInfo) Sys() any { + return f.content +} diff --git a/github/repository/content/fs.go b/github/repository/content/fs.go new file mode 100644 index 0000000..0a0988e --- /dev/null +++ b/github/repository/content/fs.go @@ -0,0 +1,118 @@ +package content + +import ( + "context" + "fmt" + "io/fs" + + "github.com/google/go-github/v68/github" + "github.com/spf13/afero" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" +) + +type Fs struct { + internal.ReadOnlyFs + ghpath.BranchPath + client *github.Client +} + +// Name implements afero.Fs. +func (f *Fs) Name() string { + return fmt.Sprint(f.BranchPath) +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + if path, err := f.Parse(name); err != nil { + return nil, fmt.Errorf("open: %w", err) + } else { + return Open(context.TODO(), f.client, path) + } +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + if path, err := f.Parse(name); err != nil { + return nil, fmt.Errorf("open: %w", err) + } else { + return Open(context.TODO(), f.client, path) + } +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + if path, err := f.Parse(name); err != nil { + return nil, fmt.Errorf("stat: %w", err) + } else { + return Stat(context.TODO(), f.client, path) + } +} + +func Open(ctx context.Context, client *github.Client, path ghpath.Path) (afero.File, error) { + content, err := ghpath.ParseContent(path) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + + file, dir, _, err := client.Repositories.GetContents(ctx, + content.Owner, + content.Repository, + content.Content, + &github.RepositoryContentGetOptions{ + Ref: content.Branch, + }, + ) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + + if file != nil { + return &File{ + ContentPath: content, + client: client, + content: file, + }, nil + } else { + return &Directory{ + ContentPath: content, + client: client, + content: dir, + }, nil + } +} + +func Stat(ctx context.Context, client *github.Client, path ghpath.Path) (fs.FileInfo, error) { + content, err := ghpath.ParseContent(path) + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + + file, dir, _, err := client.Repositories.GetContents(ctx, + content.Owner, + content.Repository, + content.Content, + &github.RepositoryContentGetOptions{ + Ref: content.Branch, + }, + ) + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + + if file != nil { + return &FileInfo{content: file}, nil + } else { + return &DirectoryInfo{ + name: content.Content, + content: dir, + }, nil + } +} + +func NewFs(gh *github.Client, owner, repo, branch string) afero.Fs { + return &Fs{ + client: gh, + BranchPath: ghpath.NewBranchPath(owner, repo, branch), + } +} diff --git a/github/repository/content/fs_test.go b/github/repository/content/fs_test.go new file mode 100644 index 0000000..201e702 --- /dev/null +++ b/github/repository/content/fs_test.go @@ -0,0 +1,44 @@ +package content_test + +import ( + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/github/repository/content" +) + +var _ = Describe("Fs", func() { + It("should stat file", func() { + fs := content.NewFs(client, "UnstoppableMango", "tdl", "main") + + stat, err := fs.Stat("Makefile") + + Expect(err).NotTo(HaveOccurred()) + Expect(stat.Name()).To(Equal("Makefile")) + }) + + It("should open file", func() { + fs := content.NewFs(client, "UnstoppableMango", "tdl", "main") + + file, err := fs.Open("Makefile") + + Expect(err).NotTo(HaveOccurred()) + Expect(file.Name()).To(Equal("Makefile")) + data, err := io.ReadAll(file) + Expect(data).NotTo(BeEmpty()) + }) + + It("should open directory", func() { + fs := content.NewFs(client, "UnstoppableMango", "tdl", "main") + + file, err := fs.Open("cmd") + + Expect(err).NotTo(HaveOccurred()) + Expect(file.Name()).To(Equal("cmd")) + Expect(file.Readdirnames(3)).To( + ConsistOf("ux", "uml2uml"), + ) + }) +}) diff --git a/github/repository/file.go b/github/repository/file.go new file mode 100644 index 0000000..b176b4d --- /dev/null +++ b/github/repository/file.go @@ -0,0 +1,93 @@ +package repository + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + "syscall" + + "github.com/google/go-github/v68/github" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/repository/release" +) + +type File struct { + internal.ReadOnlyFile + ghpath.OwnerPath + + client *github.Client + repo *github.Repository + + reader *bytes.Buffer +} + +// Close implements afero.File. +func (f *File) Close() error { + return nil +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.repo.GetName() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if err = f.ensure(); err != nil { + return + } else { + return f.reader.Read(p) + } +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + return 0, syscall.EPERM +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + return release.Readdir(context.TODO(), + f.client, + f.Owner, + f.repo.GetName(), + count, + ) +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + return release.Readdirnames(context.TODO(), + f.client, + f.Owner, + f.repo.GetName(), + n, + ) +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + return 0, syscall.EPERM +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return &FileInfo{repo: f.repo}, nil +} + +func (f *File) ensure() error { + if f.reader != nil { + return nil + } + + data, err := json.Marshal(f.repo) + if err != nil { + return fmt.Errorf("marshaling repo: %w", err) + } + + f.reader = bytes.NewBuffer(data) + return nil +} diff --git a/github/repository/file_test.go b/github/repository/file_test.go new file mode 100644 index 0000000..95a54a9 --- /dev/null +++ b/github/repository/file_test.go @@ -0,0 +1,22 @@ +package repository_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/repository" +) + +var _ = Describe("File", func() { + It("should be readonly", func() { + fs := repository.NewFs(client, "UnstoppableMango") + file, err := fs.Open("tdl") + Expect(err).NotTo(HaveOccurred()) + + _, err = file.Write([]byte{}) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteAt([]byte{}, 69) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteString("doesn't matter") + Expect(err).To(MatchError("read-only file system")) + }) +}) diff --git a/github/repository/fileinfo.go b/github/repository/fileinfo.go new file mode 100644 index 0000000..4a7bc3d --- /dev/null +++ b/github/repository/fileinfo.go @@ -0,0 +1,43 @@ +package repository + +import ( + "io/fs" + "os" + "time" + + "github.com/google/go-github/v68/github" +) + +type FileInfo struct { + repo *github.Repository +} + +// IsDir implements fs.FileInfo. +func (f *FileInfo) IsDir() bool { + return true +} + +// ModTime implements fs.FileInfo. +func (f *FileInfo) ModTime() time.Time { + return f.repo.GetUpdatedAt().Time +} + +// Mode implements fs.FileInfo. +func (f *FileInfo) Mode() fs.FileMode { + return os.ModeDir +} + +// Name implements fs.FileInfo. +func (f *FileInfo) Name() string { + return f.repo.GetName() +} + +// Size implements fs.FileInfo. +func (f *FileInfo) Size() int64 { + panic("unimplemented") +} + +// Sys implements fs.FileInfo. +func (f *FileInfo) Sys() any { + return f.repo +} diff --git a/github/repository/fs.go b/github/repository/fs.go new file mode 100644 index 0000000..fbebe2e --- /dev/null +++ b/github/repository/fs.go @@ -0,0 +1,143 @@ +package repository + +import ( + "context" + "fmt" + "io/fs" + + "github.com/google/go-github/v68/github" + "github.com/spf13/afero" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/repository/content" + "github.com/unmango/aferox/github/repository/release" +) + +type Fs struct { + internal.ReadOnlyFs + ghpath.OwnerPath + client *github.Client +} + +// Name implements afero.Fs. +func (f *Fs) Name() string { + return fmt.Sprint(f.OwnerPath) +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return Open(context.TODO(), f.client, path) +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return Open(context.TODO(), f.client, path) +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", name, err) + } + + return Stat(context.TODO(), f.client, path) +} + +func NewFs(gh *github.Client, owner string) afero.Fs { + return &Fs{ + client: gh, + OwnerPath: ghpath.NewOwnerPath(owner), + } +} + +func Open(ctx context.Context, gh *github.Client, path ghpath.Path) (afero.File, error) { + if _, err := path.Release(); err == nil { + return release.Open(ctx, gh, path) + } + if _, err := path.Branch(); err == nil { + return content.Open(ctx, gh, path) + } + + repo, err := ghpath.ParseRepository(path) + if err != nil { + return nil, fmt.Errorf("invalid path %s: %w", path, err) + } + + r, _, err := gh.Repositories.Get(ctx, repo.Owner, repo.Repository) + if err != nil { + return nil, err + } + + return &File{ + client: gh, + repo: r, + OwnerPath: repo.OwnerPath, + }, nil +} + +func Readdir(ctx context.Context, gh *github.Client, user string, count int) ([]fs.FileInfo, error) { + // TODO: count == 0 + opt := &github.RepositoryListByUserOptions{ + ListOptions: github.ListOptions{PerPage: count}, + } + + repos, _, err := gh.Repositories.ListByUser(ctx, user, opt) + if err != nil { + return nil, fmt.Errorf("user %s readdir: %w", user, err) + } + + length := min(count, len(repos)) + infos := make([]fs.FileInfo, length) + + for i := 0; i < length; i++ { + infos[i] = &FileInfo{repo: repos[i]} + } + + return infos, nil +} + +func Readdirnames(ctx context.Context, gh *github.Client, user string, n int) ([]string, error) { + infos, err := Readdir(ctx, gh, user, n) + if err != nil { + return nil, err + } + + names := []string{} + for _, i := range infos { + names = append(names, i.Name()) + } + + return names, nil +} + +func Stat(ctx context.Context, gh *github.Client, path ghpath.Path) (fs.FileInfo, error) { + if _, err := path.Release(); err == nil { + return release.Stat(ctx, gh, path) + } + if _, err := path.Branch(); err == nil { + return content.Stat(ctx, gh, path) + } + + repo, err := ghpath.ParseRepository(path) + if err != nil { + return nil, fmt.Errorf("invalid path %s: %w", path, err) + } + + r, _, err := gh.Repositories.Get(ctx, repo.Owner, repo.Repository) + if err != nil { + return nil, fmt.Errorf("stat: %w", err) + } + + return &FileInfo{repo: r}, nil +} diff --git a/github/repository/fs_test.go b/github/repository/fs_test.go new file mode 100644 index 0000000..1e78bab --- /dev/null +++ b/github/repository/fs_test.go @@ -0,0 +1,31 @@ +package repository_test + +import ( + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/github/repository" +) + +var _ = Describe("Fs", func() { + It("should open repo", func() { + client := github.NewClient(nil) + fs := repository.NewFs(client, "UnstoppableMango") + + repo, err := fs.Open("advent-of-code") + + Expect(err).NotTo(HaveOccurred()) + Expect(repo.Name()).To(Equal("advent-of-code")) + }) + + It("should stat release", func() { + client := github.NewClient(nil) + fs := repository.NewFs(client, "UnstoppableMango") + + release, err := fs.Stat("tdl/releases/tag/v0.0.29") + + Expect(err).NotTo(HaveOccurred()) + Expect(release.Name()).To(Equal("v0.0.29")) + }) +}) diff --git a/github/repository/release/asset/asset_suite_test.go b/github/repository/release/asset/asset_suite_test.go new file mode 100644 index 0000000..c18f1e7 --- /dev/null +++ b/github/repository/release/asset/asset_suite_test.go @@ -0,0 +1,21 @@ +package asset_test + +import ( + "testing" + + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/internal" +) + +var client *github.Client + +func TestRelease(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Asset Suite") +} + +var _ = BeforeSuite(func() { + client = internal.DefaultClient() +}) diff --git a/github/repository/release/asset/file.go b/github/repository/release/asset/file.go new file mode 100644 index 0000000..fd4935a --- /dev/null +++ b/github/repository/release/asset/file.go @@ -0,0 +1,145 @@ +package asset + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "io/fs" + "net/http" + "strings" + "syscall" + + "github.com/google/go-github/v68/github" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" +) + +type File struct { + internal.ReadOnlyFile + ghpath.ReleasePath + + client *github.Client + asset *github.ReleaseAsset + + reader io.ReadCloser +} + +// Close implements afero.File. +func (f *File) Close() error { + if f.reader != nil { + return f.reader.Close() + } else { + return nil + } +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.asset.GetName() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if err = f.ensure(); err != nil { + return + } + + return f.reader.Read(p) +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + return 0, syscall.EPERM +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) (infos []fs.FileInfo, err error) { + if !f.isArchive() { + return nil, syscall.ENOTDIR + } + + if err := f.ensure(); err != nil { + return nil, err + } + + r := f.reader + if f.isGzip() { + if r, err = gzip.NewReader(r); err != nil { + return + } + } + + tar := tar.NewReader(r) + for { + h, err := tar.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("reading archive: %w", err) + } + + // TODO: Handle directories + infos = append(infos, h.FileInfo()) + } + + return infos, nil +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + infos, err := f.Readdir(n) + if err != nil { + return nil, err + } + + length := min(n, len(infos)) + names := make([]string, length) + for i, info := range infos { + names[i] = info.Name() + } + + return names, nil +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + return 0, syscall.EPERM +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return &FileInfo{asset: f.asset}, nil +} + +func (f *File) ensure() error { + if f.reader != nil { + return nil + } + + reader, _, err := f.client.Repositories.DownloadReleaseAsset( + context.TODO(), + f.Owner, + f.Repository, + f.asset.GetID(), + http.DefaultClient, + ) + if err != nil { + return err + } + + f.reader = reader + return nil +} + +func (f *File) isArchive() bool { + name := f.asset.GetName() + return strings.HasSuffix(name, ".tar.gz") || + strings.HasSuffix(name, ".tar") +} + +func (f *File) isGzip() bool { + return strings.HasSuffix(f.asset.GetName(), ".gz") +} diff --git a/github/repository/release/asset/file_test.go b/github/repository/release/asset/file_test.go new file mode 100644 index 0000000..ff9fb5f --- /dev/null +++ b/github/repository/release/asset/file_test.go @@ -0,0 +1,22 @@ +package asset_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/repository/release/asset" +) + +var _ = Describe("File", func() { + It("should be readonly", func() { + fs := asset.NewFs(client, "UnstoppableMango", "tdl", "v0.0.29") + file, err := fs.Open("tdl-linux-amd64.tar.gz") + Expect(err).NotTo(HaveOccurred()) + + _, err = file.Write([]byte{}) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteAt([]byte{}, 69) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteString("doesn't matter") + Expect(err).To(MatchError("read-only file system")) + }) +}) diff --git a/github/repository/release/asset/fileinfo.go b/github/repository/release/asset/fileinfo.go new file mode 100644 index 0000000..4ef766f --- /dev/null +++ b/github/repository/release/asset/fileinfo.go @@ -0,0 +1,44 @@ +package asset + +import ( + "io/fs" + "os" + "strings" + "time" + + "github.com/google/go-github/v68/github" +) + +type FileInfo struct { + asset *github.ReleaseAsset +} + +// IsDir implements fs.FileInfo. +func (a *FileInfo) IsDir() bool { + return strings.HasSuffix(a.Name(), "tar.gz") +} + +// ModTime implements fs.FileInfo. +func (a *FileInfo) ModTime() time.Time { + return a.asset.GetUpdatedAt().Time +} + +// Mode implements fs.FileInfo. +func (a *FileInfo) Mode() fs.FileMode { + return os.ModePerm +} + +// Name implements fs.FileInfo. +func (a *FileInfo) Name() string { + return a.asset.GetName() +} + +// Size implements fs.FileInfo. +func (a *FileInfo) Size() int64 { + return int64(a.asset.GetSize()) +} + +// Sys implements fs.FileInfo. +func (a *FileInfo) Sys() any { + return a.asset +} diff --git a/github/repository/release/asset/fs.go b/github/repository/release/asset/fs.go new file mode 100644 index 0000000..137a459 --- /dev/null +++ b/github/repository/release/asset/fs.go @@ -0,0 +1,208 @@ +package asset + +import ( + "context" + "fmt" + "io/fs" + "os" + + "github.com/google/go-github/v68/github" + "github.com/spf13/afero" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" +) + +type Fs struct { + internal.ReadOnlyFs + ghpath.ReleasePath + client *github.Client +} + +// Name implements afero.Fs. +func (f *Fs) Name() string { + return fmt.Sprintf("%s/download", f.ReleasePath) +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return Open(context.TODO(), f.client, path) +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return Open(context.TODO(), f.client, path) +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", name, err) + } + + return Stat(context.TODO(), f.client, path) +} + +func NewFs(gh *github.Client, owner, repository, release string) afero.Fs { + return &Fs{ + client: gh, + ReleasePath: ghpath.NewReleasePath(owner, repository, release), + } +} + +func Open(ctx context.Context, gh *github.Client, path ghpath.Path) (*File, error) { + assetPath, err := ghpath.ParseAsset(path) + if err != nil { + return nil, fmt.Errorf("invalid path %s: %w", path, err) + } + + id, err := assetId(ctx, gh, assetPath) + if err != nil { + return nil, fmt.Errorf("opening %s: %w", path, err) + } + + asset, _, err := gh.Repositories.GetReleaseAsset(ctx, + assetPath.Owner, + assetPath.Repository, + id, + ) + if err != nil { + return nil, err + } + + return &File{ + client: gh, + asset: asset, + ReleasePath: assetPath.ReleasePath, + }, nil +} + +func Readdir( + ctx context.Context, + gh *github.Client, + path ghpath.RepositoryPath, + id int64, + count int, +) ([]fs.FileInfo, error) { + // TODO: count == 0 + opt := &github.ListOptions{PerPage: count} + assets, _, err := gh.Repositories.ListReleaseAssets(ctx, + path.Owner, + path.Repository, + id, + opt, + ) + if err != nil { + return nil, fmt.Errorf("readdir %s: %w", path, err) + } + + length := min(count, len(assets)) + results := make([]fs.FileInfo, length) + + for i := 0; i < length; i++ { + results[i] = &FileInfo{asset: assets[i]} + } + + return results, nil +} + +func Readdirnames( + ctx context.Context, + gh *github.Client, + path ghpath.RepositoryPath, + id int64, + n int, +) ([]string, error) { + infos, err := Readdir(ctx, gh, path, id, n) + if err != nil { + return nil, err + } + + names := []string{} + for _, info := range infos { + names = append(names, info.Name()) + } + + return names, nil +} + +func Stat(ctx context.Context, gh *github.Client, path ghpath.Path) (*FileInfo, error) { + assetPath, err := ghpath.ParseAsset(path) + if err != nil { + return nil, fmt.Errorf("invalid path %s: %w", path, err) + } + + id, err := assetId(ctx, gh, assetPath) + if err != nil { + return nil, fmt.Errorf("reading asset id: %w", err) + } + + asset, _, err := gh.Repositories.GetReleaseAsset(ctx, assetPath.Owner, assetPath.Repository, id) + if err != nil { + return nil, err + } + + return &FileInfo{asset: asset}, nil +} + +func releaseId(ctx context.Context, gh *github.Client, path ghpath.ReleasePath) (int64, error) { + if id, ok := internal.TryGetId(path.Release); ok { + return id, nil + } + + releases, _, err := gh.Repositories.ListReleases(ctx, + path.Owner, + path.Repository, + nil, + ) + if err != nil { + return 0, err + } + + for _, r := range releases { + if r.GetName() == path.Release { + return r.GetID(), nil + } + } + + return 0, fmt.Errorf("%s: %w", path.Release, os.ErrNotExist) +} + +func assetId(ctx context.Context, gh *github.Client, path ghpath.AssetPath) (int64, error) { + if id, ok := internal.TryGetId(path.Asset); ok { + return id, nil + } + + releaseId, err := releaseId(ctx, gh, path.ReleasePath) + if err != nil { + return 0, fmt.Errorf("reading release id: %w", err) + } + + assets, _, err := gh.Repositories.ListReleaseAssets(ctx, + path.Owner, + path.Repository, + releaseId, + nil, + ) + if err != nil { + return 0, err + } + + for _, a := range assets { + if a.GetName() == path.Asset { + return a.GetID(), nil + } + } + + return 0, fmt.Errorf("%s: %w", path, os.ErrNotExist) +} diff --git a/github/repository/release/asset/fs_test.go b/github/repository/release/asset/fs_test.go new file mode 100644 index 0000000..bc38cd6 --- /dev/null +++ b/github/repository/release/asset/fs_test.go @@ -0,0 +1,48 @@ +package asset_test + +import ( + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/github/repository/release/asset" +) + +var _ = Describe("Fs", func() { + It("should stat an asset", func() { + r := asset.NewFs(client, "UnstoppableMango", "tdl", "v0.0.29") + + info, err := r.Stat("tdl-linux-amd64.tar.gz") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.IsDir()).To(BeTrueBecause("support reading archives")) + Expect(info.Name()).To(Equal("tdl-linux-amd64.tar.gz")) + }) + + It("should download an asset", Label("E2E"), func() { + r := asset.NewFs(client, "UnstoppableMango", "tdl", "v0.0.29") + + file, err := r.Open("tdl-linux-amd64.tar.gz") + + Expect(err).NotTo(HaveOccurred()) + Expect(file.Name()).To(Equal("tdl-linux-amd64.tar.gz")) + data, err := io.ReadAll(file) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(HaveLen(49_388_058)) + }) + + It("should read an archive asset", Label("E2E"), func() { + r := asset.NewFs(client, "UnstoppableMango", "tdl", "v0.0.29") + + file, err := r.Open("tdl-linux-amd64.tar.gz") + + Expect(err).NotTo(HaveOccurred()) + stat, err := file.Stat() + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("treat archives as directories")) + infos, err := file.Readdirnames(3) + Expect(err).NotTo(HaveOccurred()) + Expect(infos).To(ConsistOf("uml2ts", "uml2go", "uml2pcl")) + }) +}) diff --git a/github/repository/release/file.go b/github/repository/release/file.go new file mode 100644 index 0000000..e6adc13 --- /dev/null +++ b/github/repository/release/file.go @@ -0,0 +1,101 @@ +package release + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + + "github.com/google/go-github/v68/github" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/repository/release/asset" +) + +type File struct { + internal.ReadOnlyFile + ghpath.RepositoryPath + + client *github.Client + release *github.RepositoryRelease + + reader *bytes.Reader +} + +// Close implements afero.File. +func (f *File) Close() error { + f.reader = nil + return nil +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.release.GetName() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if err = f.ensure(); err != nil { + return + } else { + return f.reader.Read(p) + } +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + if err = f.ensure(); err != nil { + return + } else { + return f.reader.ReadAt(p, off) + } +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + return asset.Readdir(context.TODO(), + f.client, + f.RepositoryPath, + f.release.GetID(), + count, + ) +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + return asset.Readdirnames(context.TODO(), + f.client, + f.RepositoryPath, + f.release.GetID(), + n, + ) +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + if err := f.ensure(); err != nil { + return 0, err + } else { + return f.reader.Seek(offset, whence) + } +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return &FileInfo{release: f.release}, nil +} + +func (f *File) ensure() error { + if f.reader != nil { + return nil + } + + data, err := json.Marshal(f.release) + if err != nil { + return fmt.Errorf("marshaling release: %w", err) + } + + f.reader = bytes.NewReader(data) + return nil +} diff --git a/github/repository/release/file_test.go b/github/repository/release/file_test.go new file mode 100644 index 0000000..03fbc5e --- /dev/null +++ b/github/repository/release/file_test.go @@ -0,0 +1,22 @@ +package release_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/repository/release" +) + +var _ = Describe("File", func() { + It("should be readonly", func() { + fs := release.NewFs(client, "UnstoppableMango", "tdl") + file, err := fs.Open("releases/tag/v0.0.29") + Expect(err).NotTo(HaveOccurred()) + + _, err = file.Write([]byte{}) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteAt([]byte{}, 69) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteString("doesn't matter") + Expect(err).To(MatchError("read-only file system")) + }) +}) diff --git a/github/repository/release/fileinfo.go b/github/repository/release/fileinfo.go new file mode 100644 index 0000000..41f408f --- /dev/null +++ b/github/repository/release/fileinfo.go @@ -0,0 +1,43 @@ +package release + +import ( + "io/fs" + "os" + "time" + + "github.com/google/go-github/v68/github" +) + +type FileInfo struct { + release *github.RepositoryRelease +} + +// IsDir implements fs.FileInfo. +func (f *FileInfo) IsDir() bool { + return true +} + +// ModTime implements fs.FileInfo. +func (f *FileInfo) ModTime() time.Time { + return f.release.GetCreatedAt().Time +} + +// Mode implements fs.FileInfo. +func (f *FileInfo) Mode() fs.FileMode { + return os.ModeDir +} + +// Name implements fs.FileInfo. +func (f *FileInfo) Name() string { + return f.release.GetName() +} + +// Size implements fs.FileInfo. +func (f *FileInfo) Size() int64 { + panic("unimplemented") +} + +// Sys implements fs.FileInfo. +func (f *FileInfo) Sys() any { + return f.release +} diff --git a/github/repository/release/fs.go b/github/repository/release/fs.go new file mode 100644 index 0000000..2da85b7 --- /dev/null +++ b/github/repository/release/fs.go @@ -0,0 +1,163 @@ +package release + +import ( + "context" + "fmt" + "io/fs" + "os" + + "github.com/google/go-github/v68/github" + "github.com/spf13/afero" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/repository/release/asset" +) + +type Fs struct { + internal.ReadOnlyFs + ghpath.RepositoryPath + client *github.Client +} + +// Name implements afero.Fs. +func (f *Fs) Name() string { + return fmt.Sprintf("%s/releases", f.RepositoryPath) +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return Open(context.TODO(), f.client, path) +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } + + return Open(context.TODO(), f.client, path) +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + path, err := f.Parse(name) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", name, err) + } + + return Stat(context.TODO(), f.client, path) +} + +func NewFs(gh *github.Client, owner, repository string) afero.Fs { + return &Fs{ + client: gh, + RepositoryPath: ghpath.NewRepositoryPath(owner, repository), + } +} + +func Open(ctx context.Context, gh *github.Client, path ghpath.Path) (afero.File, error) { + release, err := ghpath.ParseRelease(path) + if err != nil { + return nil, fmt.Errorf("invalid path %s: %w", path, err) + } + + if _, err := path.Asset(); err == nil { + return asset.Open(ctx, gh, path) + } + + id, err := releaseId(ctx, gh, release) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + r, _, err := gh.Repositories.GetRelease(ctx, release.Owner, release.Repository, id) + if err != nil { + return nil, fmt.Errorf("open %d: %w", id, err) + } + + return &File{ + client: gh, + release: r, + RepositoryPath: release.RepositoryPath, + }, nil +} + +func Readdir(ctx context.Context, gh *github.Client, owner, repository string, count int) ([]fs.FileInfo, error) { + // TODO: count == 0 + opt := &github.ListOptions{PerPage: count} + releases, _, err := gh.Repositories.ListReleases(ctx, owner, repository, opt) + if err != nil { + return nil, fmt.Errorf("%s/%s readdir: %w", owner, repository, err) + } + + length := min(count, len(releases)) + results := make([]fs.FileInfo, length) + + for i := 0; i < length; i++ { + results[i] = &FileInfo{release: releases[i]} + } + + return results, nil +} + +func Readdirnames(ctx context.Context, gh *github.Client, owner, repository string, n int) ([]string, error) { + infos, err := Readdir(ctx, gh, owner, repository, n) + if err != nil { + return nil, err + } + + names := []string{} + for _, i := range infos { + names = append(names, i.Name()) + } + + return names, nil +} + +func Stat(ctx context.Context, gh *github.Client, path ghpath.Path) (fs.FileInfo, error) { + if _, err := path.Asset(); err == nil { + return asset.Stat(ctx, gh, path) + } + + release, err := ghpath.ParseRelease(path) + if err != nil { + return nil, fmt.Errorf("invalid path %s: %w", release, err) + } + + id, err := releaseId(ctx, gh, release) + if err != nil { + return nil, fmt.Errorf("reading release id: %w", err) + } + + r, _, err := gh.Repositories.GetRelease(ctx, release.Owner, release.Repository, id) + if err != nil { + return nil, fmt.Errorf("open %d: %w", id, err) + } + + return &FileInfo{release: r}, nil +} + +func releaseId(ctx context.Context, gh *github.Client, path ghpath.ReleasePath) (int64, error) { + if id, ok := internal.TryGetId(path.Release); ok { + return id, nil + } + + releases, _, err := gh.Repositories.ListReleases(ctx, path.Owner, path.Repository, nil) + if err != nil { + return 0, err + } + + for _, r := range releases { + if r.GetName() == path.Release { + return r.GetID(), nil + } + } + + return 0, os.ErrNotExist +} diff --git a/github/repository/release/fs_test.go b/github/repository/release/fs_test.go new file mode 100644 index 0000000..4d1cd45 --- /dev/null +++ b/github/repository/release/fs_test.go @@ -0,0 +1,28 @@ +package release_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/github/repository/release" +) + +var _ = Describe("Fs", func() { + It("should stat", func() { + fs := release.NewFs(client, "UnstoppableMango", "tdl") + + r, err := fs.Stat("releases/tag/v0.0.29") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Name()).To(Equal("v0.0.29")) + }) + + It("should stat asset", func() { + fs := release.NewFs(client, "UnstoppableMango", "tdl") + + r, err := fs.Stat("releases/tag/v0.0.29/tdl-linux-amd64.tar.gz") + + Expect(err).NotTo(HaveOccurred()) + Expect(r.Name()).To(Equal("tdl-linux-amd64.tar.gz")) + }) +}) diff --git a/github/repository/release/release_suite_test.go b/github/repository/release/release_suite_test.go new file mode 100644 index 0000000..1a6dc43 --- /dev/null +++ b/github/repository/release/release_suite_test.go @@ -0,0 +1,21 @@ +package release_test + +import ( + "testing" + + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/internal" +) + +var client *github.Client + +func TestRelease(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Release Suite") +} + +var _ = BeforeSuite(func() { + client = internal.DefaultClient() +}) diff --git a/github/repository/repository_suite_test.go b/github/repository/repository_suite_test.go new file mode 100644 index 0000000..6e50a39 --- /dev/null +++ b/github/repository/repository_suite_test.go @@ -0,0 +1,21 @@ +package repository_test + +import ( + "testing" + + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/internal" +) + +var client *github.Client + +func TestRepository(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Repository Suite") +} + +var _ = BeforeSuite(func() { + client = internal.DefaultClient() +}) diff --git a/github/user/file.go b/github/user/file.go new file mode 100644 index 0000000..0dc1a8d --- /dev/null +++ b/github/user/file.go @@ -0,0 +1,90 @@ +package user + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/fs" + + "github.com/google/go-github/v68/github" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/repository" +) + +type File struct { + internal.ReadOnlyFile + client *github.Client + user *github.User + + reader *bytes.Reader +} + +// Close implements afero.File. +func (f *File) Close() error { + return nil +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.user.GetLogin() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if err = f.ensure(); err != nil { + return + } else { + return f.reader.Read(p) + } +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + if err = f.ensure(); err != nil { + return + } else { + return f.reader.ReadAt(p, off) + } +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + return repository.Readdir(context.TODO(), f.client, f.Name(), count) +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + return repository.Readdirnames(context.TODO(), f.client, f.Name(), n) +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + if err := f.ensure(); err != nil { + return 0, err + } else { + return f.reader.Seek(offset, whence) + } +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return &FileInfo{ + client: f.client, + user: f.user, + }, nil +} + +func (f *File) ensure() error { + if f.reader != nil { + return nil + } + + data, err := json.Marshal(f.user) + if err != nil { + return fmt.Errorf("marshaling user: %w", err) + } + + f.reader = bytes.NewReader(data) + return nil +} diff --git a/github/user/file_test.go b/github/user/file_test.go new file mode 100644 index 0000000..86dd747 --- /dev/null +++ b/github/user/file_test.go @@ -0,0 +1,60 @@ +package user_test + +import ( + "io" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/github/user" +) + +var _ = Describe("File", func() { + It("should list repositories", func() { + fs := user.NewFs(client) + file, err := fs.Open("UnstoppableMango") + Expect(err).NotTo(HaveOccurred()) + + names, err := file.Readdirnames(69) + + Expect(err).NotTo(HaveOccurred()) + Expect(names).To(ContainElement("advent-of-code")) + }) + + It("should read json", func() { + fs := user.NewFs(client) + file, err := fs.Open("UnstoppableMango") + Expect(err).NotTo(HaveOccurred()) + + data, err := io.ReadAll(file) + + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(And( + ContainSubstring("login"), + ContainSubstring("UnstoppableMango"), + )) + Expect(file.Close()).To(Succeed()) + }) + + It("should Open user", func() { + fs := user.NewFs(client) + + file, err := fs.Open("UnstoppableMango") + + Expect(err).NotTo(HaveOccurred()) + Expect(file.Name()).To(Equal("UnstoppableMango")) + }) + + It("should be readonly", func() { + fs := user.NewFs(client) + file, err := fs.Open("UnstoppableMango") + Expect(err).NotTo(HaveOccurred()) + + _, err = file.Write([]byte{}) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteAt([]byte{}, 69) + Expect(err).To(MatchError("read-only file system")) + _, err = file.WriteString("doesn't matter") + Expect(err).To(MatchError("read-only file system")) + }) +}) diff --git a/github/user/fileinfo.go b/github/user/fileinfo.go new file mode 100644 index 0000000..7abe15a --- /dev/null +++ b/github/user/fileinfo.go @@ -0,0 +1,44 @@ +package user + +import ( + "io/fs" + "os" + "time" + + "github.com/google/go-github/v68/github" +) + +type FileInfo struct { + client *github.Client + user *github.User +} + +// IsDir implements fs.FileInfo. +func (f *FileInfo) IsDir() bool { + return true +} + +// ModTime implements fs.FileInfo. +func (f *FileInfo) ModTime() time.Time { + return f.user.GetUpdatedAt().Time +} + +// Mode implements fs.FileInfo. +func (f *FileInfo) Mode() fs.FileMode { + return os.ModeDir +} + +// Name implements fs.FileInfo. +func (f *FileInfo) Name() string { + return f.user.GetLogin() +} + +// Size implements fs.FileInfo. +func (f *FileInfo) Size() int64 { + panic("unimplemented") +} + +// Sys implements fs.FileInfo. +func (f *FileInfo) Sys() any { + return f.user +} diff --git a/github/user/fs.go b/github/user/fs.go new file mode 100644 index 0000000..d653fc7 --- /dev/null +++ b/github/user/fs.go @@ -0,0 +1,96 @@ +package user + +import ( + "context" + "fmt" + "io/fs" + + "github.com/google/go-github/v68/github" + "github.com/spf13/afero" + "github.com/unmango/aferox/github/ghpath" + "github.com/unmango/aferox/github/internal" + "github.com/unmango/aferox/github/repository" +) + +type Fs struct { + internal.ReadOnlyFs + client *github.Client +} + +// Name implements afero.Fs. +func (g *Fs) Name() string { + return "https://github.com" +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + if path, err := ghpath.Parse(name); err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } else { + return Open(context.TODO(), f.client, path) + } +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + if path, err := ghpath.Parse(name); err != nil { + return nil, fmt.Errorf("open %s: %w", name, err) + } else { + return Open(context.TODO(), f.client, path) + } +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + if path, err := ghpath.Parse(name); err != nil { + return nil, fmt.Errorf("stat %s: %w", name, err) + } else { + return Stat(context.TODO(), f.client, path) + } +} + +func NewFs(gh *github.Client) afero.Fs { + return &Fs{client: gh} +} + +func Open(ctx context.Context, gh *github.Client, path ghpath.Path) (afero.File, error) { + if _, err := path.Repository(); err == nil { + return repository.Open(ctx, gh, path) + } + + owner, err := ghpath.ParseOwner(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + user, _, err := gh.Users.Get(ctx, owner.Owner) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + + return &File{ + client: gh, + user: user, + }, nil +} + +func Stat(ctx context.Context, gh *github.Client, path ghpath.Path) (fs.FileInfo, error) { + if _, err := path.Repository(); err == nil { + return repository.Stat(ctx, gh, path) + } + + owner, err := ghpath.ParseOwner(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + + user, _, err := gh.Users.Get(ctx, owner.Owner) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + + return &FileInfo{ + client: gh, + user: user, + }, nil +} diff --git a/github/user/fs_test.go b/github/user/fs_test.go new file mode 100644 index 0000000..72754c8 --- /dev/null +++ b/github/user/fs_test.go @@ -0,0 +1,64 @@ +package user_test + +import ( + "os" + + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/github/user" +) + +var _ = Describe("Fs", func() { + It("should open user", func() { + client := github.NewClient(nil) + fs := user.NewFs(client) + + user, err := fs.Open("UnstoppableMango") + + Expect(err).NotTo(HaveOccurred()) + Expect(user.Name()).To(Equal("UnstoppableMango")) + }) + + It("should open user file", func() { + client := github.NewClient(nil) + fs := user.NewFs(client) + + user, err := fs.OpenFile("UnstoppableMango", 69, os.ModePerm) + + Expect(err).NotTo(HaveOccurred()) + Expect(user.Name()).To(Equal("UnstoppableMango")) + }) + + It("should stat user", func() { + client := github.NewClient(nil) + fs := user.NewFs(client) + + user, err := fs.Stat("UnstoppableMango") + + Expect(err).NotTo(HaveOccurred()) + Expect(user.Name()).To(Equal("UnstoppableMango")) + }) + + It("should be readonly", func() { + fs := user.NewFs(client) + + _, err := fs.Create("doesn't matter") + Expect(err).To(MatchError("operation not permitted")) + err = fs.Chmod("doesn't matter", os.ModeSetgid) + Expect(err).To(MatchError("operation not permitted")) + err = fs.Chown("doesn't matter", 420, 69) + Expect(err).To(MatchError("operation not permitted")) + err = fs.Mkdir("doesn't matter", os.ModeDir) + Expect(err).To(MatchError("operation not permitted")) + err = fs.MkdirAll("doesn't matter", os.ModeDir) + Expect(err).To(MatchError("operation not permitted")) + err = fs.Remove("doesn't matter") + Expect(err).To(MatchError("operation not permitted")) + err = fs.RemoveAll("doesn't matter") + Expect(err).To(MatchError("operation not permitted")) + err = fs.Rename("doesn't matter", "still doesn't matter") + Expect(err).To(MatchError("operation not permitted")) + }) +}) diff --git a/github/user/user_suite_test.go b/github/user/user_suite_test.go new file mode 100644 index 0000000..662ec36 --- /dev/null +++ b/github/user/user_suite_test.go @@ -0,0 +1,21 @@ +package user_test + +import ( + "testing" + + "github.com/google/go-github/v68/github" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/unmango/aferox/github/internal" +) + +var client *github.Client + +func TestUser(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "User Suite") +} + +var _ = BeforeSuite(func() { + client = internal.DefaultClient() +}) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0d25911 --- /dev/null +++ b/go.mod @@ -0,0 +1,86 @@ +module github.com/unmango/aferox + +go 1.23.4 + +require ( + github.com/charmbracelet/log v0.4.0 + github.com/docker/docker v27.4.1+incompatible + github.com/google/go-github/v68 v68.0.0 + github.com/goware/urlx v0.3.2 + github.com/onsi/ginkgo/v2 v2.22.2 + github.com/onsi/gomega v1.36.2 + github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 + github.com/spf13/afero v1.11.0 + github.com/testcontainers/testcontainers-go v0.34.0 + github.com/unmango/go v0.2.2 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/PuerkitoBio/purell v1.2.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/charmbracelet/lipgloss v1.0.0 // indirect + github.com/charmbracelet/x/ansi v0.6.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/klauspost/compress v1.17.11 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect + github.com/magiconair/properties v1.8.9 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.14 // indirect + github.com/tklauser/numcpus v0.9.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.33.0 // indirect + go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect + golang.org/x/crypto v0.31.0 // indirect + golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/tools v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..49c5a1d --- /dev/null +++ b/go.sum @@ -0,0 +1,239 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= +github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA= +github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +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= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4= +github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= +github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= +github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goware/urlx v0.3.2 h1:gdoo4kBHlkqZNaf6XlQ12LGtQOmpKJrR04Rc3RnpJEo= +github.com/goware/urlx v0.3.2/go.mod h1:h8uwbJy68o+tQXCGZNa9D73WN8n0r9OBae5bUnLcgjw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= +github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM= +github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= +github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/onsi/ginkgo/v2 v2.22.2 h1:/3X8Panh8/WwhU/3Ssa6rCKqPLuAkVY2I0RoyDLySlU= +github.com/onsi/ginkgo/v2 v2.22.2/go.mod h1:oeMosUL+8LtarXBHu/c0bx2D/K9zyQ6uX3cTyztHwsk= +github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= +github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= +github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo= +github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ= +github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= +github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= +github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo= +github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI= +github.com/unmango/go v0.2.2 h1:A5+uZjERHIPswHpku7xl9nGUtL+Eq1uwxEGPCeVJcrs= +github.com/unmango/go v0.2.2/go.mod h1:rziorpSDVSIkybz7KksWqNr9c9aotZgjvytDAy+fe3s= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= +go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= +go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= +go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= +go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= +go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= +go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= +go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4= +golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8= +golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= +google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d h1:H8tOf8XM88HvKqLTxe755haY6r1fqqzLbEnfrmLXlSA= +google.golang.org/genproto/googleapis/api v0.0.0-20250102185135-69823020774d/go.mod h1:2v7Z7gP2ZUOGsaFyxATQSRoBnKygqVq2Cwnvom7QiqY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d h1:xJJRGY7TJcvIlpSrN3K6LAWgNFUILlO+OMAqtg9aqnw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250102185135-69823020774d/go.mod h1:3ENsm/5D1mzDyhpzeRi1NR784I0BcofWBoSc5QqqMK4= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/hack/example.envrc b/hack/example.envrc new file mode 100644 index 0000000..af75002 --- /dev/null +++ b/hack/example.envrc @@ -0,0 +1,4 @@ +#!/bin/bash + +root="$(git rev-parse --show-toplevel)" +export PATH="$root/bin:$PATH" diff --git a/ignore/fs.go b/ignore/fs.go new file mode 100644 index 0000000..ad511af --- /dev/null +++ b/ignore/fs.go @@ -0,0 +1,58 @@ +package ignore + +import ( + "bufio" + "fmt" + "io" + + ignore "github.com/sabhiram/go-gitignore" + "github.com/spf13/afero" + "github.com/unmango/aferox/filter" +) + +const DefaultFile = ".gitignore" + +type Ignore interface { + MatchesPath(string) bool +} + +func NewFsFromGitIgnoreLines(base afero.Fs, lines ...string) afero.Fs { + return NewFsFromIgnore(base, ignore.CompileIgnoreLines(lines...)) +} + +func NewFsFromIgnore(base afero.Fs, ignore Ignore) afero.Fs { + return filter.NewFs(base, not(ignore.MatchesPath)) +} + +func NewFsFromGitIgnoreReader(base afero.Fs, reader io.Reader) (afero.Fs, error) { + lines := []string{} + s := bufio.NewScanner(reader) + for s.Scan() { + lines = append(lines, s.Text()) + } + if s.Err() != nil { + return nil, fmt.Errorf("scanning ignore lines: %w", s.Err()) + } + + return NewFsFromGitIgnoreLines(base, lines...), nil +} + +func NewFsFromGitIgnoreFile(base afero.Fs, path string) (afero.Fs, error) { + if f, err := base.Open(path); err != nil { + return nil, fmt.Errorf("opening ignore file: %w", err) + } else { + defer f.Close() + return NewFsFromGitIgnoreReader(base, f) + } +} + +func OpenDefaultGitIgnore(base afero.Fs) (afero.Fs, error) { + return NewFsFromGitIgnoreFile(base, DefaultFile) +} + +// This is entirely unnecessary +func not(fn func(string) bool) func(string) bool { + return func(s string) bool { + return !fn(s) + } +} diff --git a/ignore/fs_test.go b/ignore/fs_test.go new file mode 100644 index 0000000..8a4bf85 --- /dev/null +++ b/ignore/fs_test.go @@ -0,0 +1,152 @@ +package ignore_test + +import ( + "bytes" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/ignore" +) + +type ignoreStub string + +func (s ignoreStub) MatchesPath(p string) bool { + return string(s) == p +} + +var _ = Describe("Fs", func() { + var base afero.Fs + + BeforeEach(func() { + base = afero.NewMemMapFs() + }) + + Describe("NewFsFromGitIgnoreLines", func() { + It("should ignore pattern", func() { + err := afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs := ignore.NewFsFromGitIgnoreLines(base, "*.txt") + + _, err = fs.Stat("blah.txt") + Expect(err).To(MatchError(os.ErrNotExist)) + }) + + It("should open unignored files", func() { + err := afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs := ignore.NewFsFromGitIgnoreLines(base, "*.blah") + + _, err = fs.Stat("blah.txt") + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("NewFsFromIgnore", func() { + It("should ignore pattern", func() { + err := afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs := ignore.NewFsFromIgnore(base, ignoreStub("blah.txt")) + + _, err = fs.Stat("blah.txt") + Expect(err).To(MatchError(os.ErrNotExist)) + }) + + It("should open unignored files", func() { + err := afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs := ignore.NewFsFromIgnore(base, ignoreStub("txt.blah")) + + _, err = fs.Stat("blah.txt") + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("NewFsFromGitIgnoreReader", func() { + It("should ignore pattern", func() { + buf := bytes.NewBufferString("*.txt") + err := afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs, err := ignore.NewFsFromGitIgnoreReader(base, buf) + + Expect(err).NotTo(HaveOccurred()) + _, err = fs.Stat("blah.txt") + Expect(err).To(MatchError(os.ErrNotExist)) + }) + + It("should open unignored files", func() { + buf := bytes.NewBufferString("*.blah") + err := afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs, err := ignore.NewFsFromGitIgnoreReader(base, buf) + + Expect(err).NotTo(HaveOccurred()) + _, err = fs.Stat("blah.txt") + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("NewFsFromGitIgnoreFile", func() { + It("should ignore pattern", func() { + err := afero.WriteFile(base, "git.ignore", []byte("*.txt"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs, err := ignore.NewFsFromGitIgnoreFile(base, "git.ignore") + + Expect(err).NotTo(HaveOccurred()) + _, err = fs.Stat("blah.txt") + Expect(err).To(MatchError(os.ErrNotExist)) + }) + + It("should open unignored files", func() { + err := afero.WriteFile(base, "git.ignore", []byte("*.blah"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs, err := ignore.NewFsFromGitIgnoreFile(base, "git.ignore") + + Expect(err).NotTo(HaveOccurred()) + _, err = fs.Stat("blah.txt") + Expect(err).NotTo(HaveOccurred()) + }) + }) + + Describe("OpenDefaultGitIgnore", func() { + It("should ignore pattern", func() { + err := afero.WriteFile(base, ".gitignore", []byte("*.txt"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs, err := ignore.OpenDefaultGitIgnore(base) + + Expect(err).NotTo(HaveOccurred()) + _, err = fs.Stat("blah.txt") + Expect(err).To(MatchError(os.ErrNotExist)) + }) + + It("should open unignored files", func() { + err := afero.WriteFile(base, ".gitignore", []byte("*.blah"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(base, "blah.txt", []byte("fdh"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + + fs, err := ignore.OpenDefaultGitIgnore(base) + + Expect(err).NotTo(HaveOccurred()) + _, err = fs.Stat("blah.txt") + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/ignore/ignore_suite_test.go b/ignore/ignore_suite_test.go new file mode 100644 index 0000000..226803e --- /dev/null +++ b/ignore/ignore_suite_test.go @@ -0,0 +1,13 @@ +package ignore_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIgnore(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Ignore Suite") +} diff --git a/internal/copy.go b/internal/copy.go new file mode 100644 index 0000000..4fe36e7 --- /dev/null +++ b/internal/copy.go @@ -0,0 +1,30 @@ +package internal + +import ( + "fmt" + "io/fs" + + "github.com/spf13/afero" +) + +func Copy(src, dest afero.Fs) error { + return afero.Walk(src, "", + func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if path == "" { + return nil // Skip root + } + if info.IsDir() { + return dest.Mkdir(path, info.Mode()) + } + + if f, err := src.Open(path); err != nil { + return fmt.Errorf("open %s: %w", path, err) + } else { + return afero.WriteReader(dest, path, f) + } + }, + ) +} diff --git a/internal/copy_test.go b/internal/copy_test.go new file mode 100644 index 0000000..228cfcf --- /dev/null +++ b/internal/copy_test.go @@ -0,0 +1,121 @@ +package internal_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + "github.com/unmango/aferox/internal" + "github.com/unmango/go/testing/gfs" +) + +var _ = Describe("Copy", func() { + It("should error when src doesn't exist", func() { + src := afero.NewBasePathFs(afero.NewMemMapFs(), "blah") + dest := afero.NewMemMapFs() + + err := internal.Copy(src, dest) + + Expect(err).To(MatchError("open blah: file does not exist")) + }) + + It("should create dest when it doesn't exist", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + base := afero.NewMemMapFs() + dest := afero.NewBasePathFs(base, "blah") + + err = internal.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFile("test.txt")) + Expect(base).To(gfs.ContainFile("blah/test.txt")) + }) + + It("should copy files", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = internal.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFileWithBytes("test.txt", []byte("testing"))) + }) + + It("should copy directories", func() { + src := afero.NewMemMapFs() + err := src.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = internal.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + stat, err := dest.Stat("test") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the directory is created")) + }) + + It("should copy directories with files", func() { + src := afero.NewMemMapFs() + err := src.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test/test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = internal.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + stat, err := dest.Stat("test") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the directory is created")) + Expect(dest).To(gfs.ContainFileWithBytes("test/test.txt", []byte("testing"))) + }) + + It("should copy multiple files", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test2.txt", []byte("testing2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = internal.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFileWithBytes("test.txt", []byte("testing"))) + Expect(dest).To(gfs.ContainFileWithBytes("test2.txt", []byte("testing2"))) + }) + + It("should copy a directory structure", func() { + src := afero.NewMemMapFs() + err := afero.WriteFile(src, "test.txt", []byte("testing"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = src.MkdirAll("test/other", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test/test2.txt", []byte("testing2"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + err = afero.WriteFile(src, "test/other/test3.txt", []byte("testing3"), os.ModePerm) + Expect(err).NotTo(HaveOccurred()) + dest := afero.NewMemMapFs() + + err = internal.Copy(src, dest) + + Expect(err).NotTo(HaveOccurred()) + Expect(dest).To(gfs.ContainFileWithBytes("test.txt", []byte("testing"))) + stat, err := dest.Stat("test") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the first directory is created")) + Expect(dest).To(gfs.ContainFileWithBytes("test/test2.txt", []byte("testing2"))) + stat, err = dest.Stat("test/other") + Expect(err).NotTo(HaveOccurred()) + Expect(stat.IsDir()).To(BeTrueBecause("the second directory is created")) + Expect(dest).To(gfs.ContainFileWithBytes("test/other/test3.txt", []byte("testing3"))) + }) +}) diff --git a/internal/internal_suite_test.go b/internal/internal_suite_test.go new file mode 100644 index 0000000..c82b65a --- /dev/null +++ b/internal/internal_suite_test.go @@ -0,0 +1,13 @@ +package internal_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestInternal(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Internal Suite") +} diff --git a/iter.go b/iter.go new file mode 100644 index 0000000..a061146 --- /dev/null +++ b/iter.go @@ -0,0 +1,63 @@ +package aferox + +import ( + "io/fs" + + "github.com/spf13/afero" + "github.com/unmango/go/iter" + "github.com/unmango/go/option" +) + +type ErrFilter func(error) error + +type iterOptions struct { + continueOnErr bool + errFilter ErrFilter + skipDirs bool +} + +type IterOption func(*iterOptions) + +func ContinueOnError(options *iterOptions) { + options.continueOnErr = true +} + +func SkipDirs(options *iterOptions) { + options.skipDirs = true +} + +func FilterErrors(filter ErrFilter) IterOption { + return func(options *iterOptions) { + options.errFilter = filter + } +} + +func Iter(fsys afero.Fs, root string, options ...IterOption) iter.Seq3[string, fs.FileInfo, error] { + opts := iterOptions{} + option.ApplyAll(&opts, options) + + return func(yield func(string, fs.FileInfo, error) bool) { + done := false + err := afero.Walk(fsys, root, + func(path string, info fs.FileInfo, err error) error { + if err != nil && !opts.continueOnErr { + return err + } + if err != nil && opts.errFilter != nil { + return opts.errFilter(err) + } + if info.IsDir() && opts.skipDirs { + return nil + } + if done = !yield(path, info, err); done { + return fs.SkipAll + } else { + return nil + } + }, + ) + if err != nil && !done { + yield("", nil, err) + } + } +} diff --git a/iter_test.go b/iter_test.go new file mode 100644 index 0000000..7c65d77 --- /dev/null +++ b/iter_test.go @@ -0,0 +1,68 @@ +package aferox_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + aferox "github.com/unmango/aferox" + "github.com/unmango/go/slices" +) + +var _ = Describe("Iter", func() { + It("should iterate over an empty fs", func() { + fs := afero.NewMemMapFs() + + seq := aferox.Iter(fs, "") + + a, b, c := slices.Collect3(seq) + Expect(a).To(ConsistOf("")) // root dir + Expect(b).To(HaveLen(1)) + Expect(b[0].Name()).To(Equal("")) + Expect(c).To(ConsistOf(nil)) + }) + + It("should skip root when iterating over an empty fs", func() { + fs := afero.NewMemMapFs() + + seq := aferox.Iter(fs, "", aferox.SkipDirs) + + a, b, c := slices.Collect3(seq) + Expect(a).To(BeEmpty()) + Expect(b).To(BeEmpty()) + Expect(c).To(BeEmpty()) + }) + + It("should iterate over files", func() { + fs := afero.NewMemMapFs() + _, err := fs.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + + seq := aferox.Iter(fs, "") + + a, b, c := slices.Collect3(seq) + Expect(a).To(ConsistOf("", "test.txt")) + Expect(b).To(HaveLen(2)) + Expect(b[0].Name()).To(Equal("")) + Expect(b[1].Name()).To(Equal("test.txt")) + Expect(c).To(ConsistOf(nil, nil)) + }) + + It("should iterate over directories", func() { + fs := afero.NewMemMapFs() + err := fs.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + + seq := aferox.Iter(fs, "") + + a, b, c := slices.Collect3(seq) + Expect(a).To(ConsistOf("", "test")) + Expect(b).To(HaveLen(2)) + Expect(b[0].Name()).To(Equal("")) + Expect(b[1].Name()).To(Equal("test")) + Expect(b[1].IsDir()).To(BeTrue()) + Expect(c).To(ConsistOf(nil, nil)) + }) +}) diff --git a/single.go b/single.go new file mode 100644 index 0000000..508c697 --- /dev/null +++ b/single.go @@ -0,0 +1,87 @@ +package aferox + +import ( + "errors" + "fmt" + "io/fs" + + "github.com/spf13/afero" + "github.com/unmango/go/option" +) + +type errSingle struct { + acc string + cur string +} + +func (e errSingle) Error() string { + return fmt.Sprintf("fs contains more than one entry\n\thad:\t%s\n\tfound:\t%s", e.acc, e.cur) +} + +func StatSingle(fsys afero.Fs, root string, options ...IterOption) (fs.FileInfo, error) { + opts := &iterOptions{} + option.ApplyAll(opts, options) + + info, err := Fold(fsys, root, + func(path string, info fs.FileInfo, acc fs.FileInfo, err error) (fs.FileInfo, error) { + if err != nil { + return nil, err + } + if path == "." || path == "" { + return nil, nil + } + if info.IsDir() && opts.skipDirs { + return nil, nil + } + if acc != nil { + return nil, errSingle{acc.Name(), info.Name()} + } + + return info, nil + }, + nil, + ) + + if err != nil { + return nil, err + } + if info == nil { + return nil, errors.New("Fs contains no entries") + } + + return info, nil +} + +func OpenSingle(fsys afero.Fs, root string, options ...IterOption) (afero.File, error) { + opts := &iterOptions{} + option.ApplyAll(opts, options) + + file, err := Fold(fsys, root, + func(path string, info fs.FileInfo, acc afero.File, err error) (afero.File, error) { + if err != nil { + return nil, err + } + if path == "." || path == "" { + return nil, nil + } + if info.IsDir() && opts.skipDirs { + return nil, nil + } + if acc != nil { + return nil, errSingle{acc.Name(), info.Name()} + } + + return fsys.Open(path) + }, + nil, + ) + + if err != nil { + return nil, err + } + if file == nil { + return nil, errors.New("Fs contains no entries") + } + + return file, nil +} diff --git a/single_test.go b/single_test.go new file mode 100644 index 0000000..249a3c1 --- /dev/null +++ b/single_test.go @@ -0,0 +1,147 @@ +package aferox_test + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/afero" + + aferox "github.com/unmango/aferox" +) + +var _ = Describe("Single", func() { + Describe("StatSingle", func() { + It("should stat an Fs with a single file", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.StatSingle(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test.txt")) + }) + + It("should stat an Fs with a single directory", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.StatSingle(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test")) + }) + + It("should error when Fs contains multiple files", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("oops.txt") + Expect(err).NotTo(HaveOccurred()) + + _, err = aferox.StatSingle(fsys, "") + + Expect(err).To(HaveOccurred()) + }) + + It("should error when Fs contains no files", func() { + fsys := afero.NewMemMapFs() + + _, err := aferox.StatSingle(fsys, "") + + Expect(err).To(HaveOccurred()) + }) + + When("SkipDirs is provided", func() { + It("should stat the first file", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("test/test.txt") + + info, err := aferox.StatSingle(fsys, "", aferox.SkipDirs) + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test.txt")) + }) + + It("should error when only directories exist", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + + _, err = aferox.StatSingle(fsys, "", aferox.SkipDirs) + + Expect(err).To(HaveOccurred()) + }) + }) + }) + + Describe("OpenSingle", func() { + It("should open in an Fs with a single file", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.OpenSingle(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test.txt")) + }) + + It("should open in an Fs with a single directory", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + + info, err := aferox.OpenSingle(fsys, "") + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test")) + }) + + It("should error when Fs contains multiple files", func() { + fsys := afero.NewMemMapFs() + _, err := fsys.Create("test.txt") + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("oops.txt") + Expect(err).NotTo(HaveOccurred()) + + _, err = aferox.OpenSingle(fsys, "") + + Expect(err).To(HaveOccurred()) + }) + + It("should error when Fs contains no files", func() { + fsys := afero.NewMemMapFs() + + _, err := aferox.OpenSingle(fsys, "") + + Expect(err).To(HaveOccurred()) + }) + + When("SkipDirs is provided", func() { + It("should stat the first file", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + Expect(err).NotTo(HaveOccurred()) + _, err = fsys.Create("test/test.txt") + + info, err := aferox.OpenSingle(fsys, "", aferox.SkipDirs) + + Expect(err).NotTo(HaveOccurred()) + Expect(info.Name()).To(Equal("test/test.txt")) + }) + + It("should error when only directories exist", func() { + fsys := afero.NewMemMapFs() + err := fsys.Mkdir("test", os.ModeDir) + + _, err = aferox.OpenSingle(fsys, "", aferox.SkipDirs) + + Expect(err).To(HaveOccurred()) + }) + }) + }) +}) diff --git a/testing/context.go b/testing/context.go new file mode 100644 index 0000000..820e691 --- /dev/null +++ b/testing/context.go @@ -0,0 +1,139 @@ +package testing + +import ( + "io/fs" + "time" + + "github.com/spf13/afero" + "github.com/unmango/aferox/context" +) + +type ContextFs struct { + ChmodFunc func(context.Context, string, fs.FileMode) error + ChownFunc func(context.Context, string, int, int) error + ChtimesFunc func(context.Context, string, time.Time, time.Time) error + CreateFunc func(context.Context, string) (afero.File, error) + MkdirFunc func(context.Context, string, fs.FileMode) error + MkdirAllFunc func(context.Context, string, fs.FileMode) error + OpenFunc func(context.Context, string) (afero.File, error) + OpenFileFunc func(context.Context, string, int, fs.FileMode) (afero.File, error) + RemoveFunc func(context.Context, string) error + RemoveAllFunc func(context.Context, string) error + RenameFunc func(context.Context, string, string) error + StatFunc func(context.Context, string) (fs.FileInfo, error) +} + +// Chmod implements context.Fs. +func (c *ContextFs) Chmod(ctx context.Context, name string, mode fs.FileMode) error { + if c.ChmodFunc == nil { + panic("unimplemented") + } + + return c.ChmodFunc(ctx, name, mode) +} + +// Chown implements context.Fs. +func (c *ContextFs) Chown(ctx context.Context, name string, uid int, gid int) error { + if c.ChownFunc == nil { + panic("unimplemented") + } + + return c.ChownFunc(ctx, name, uid, gid) +} + +// Chtimes implements context.Fs. +func (c *ContextFs) Chtimes(ctx context.Context, name string, atime time.Time, mtime time.Time) error { + if c.ChtimesFunc == nil { + panic("unimplemented") + } + + return c.ChtimesFunc(ctx, name, atime, mtime) +} + +// Create implements context.Fs. +func (c *ContextFs) Create(ctx context.Context, name string) (afero.File, error) { + if c.CreateFunc == nil { + panic("unimplemented") + } + + return c.CreateFunc(ctx, name) +} + +// Mkdir implements context.Fs. +func (c *ContextFs) Mkdir(ctx context.Context, name string, perm fs.FileMode) error { + if c.MkdirFunc == nil { + panic("unimplemented") + } + + return c.MkdirFunc(ctx, name, perm) +} + +// MkdirAll implements context.Fs. +func (c *ContextFs) MkdirAll(ctx context.Context, path string, perm fs.FileMode) error { + if c.MkdirAllFunc == nil { + panic("unimplemented") + } + + return c.MkdirAllFunc(ctx, path, perm) +} + +// Name implements context.Fs. +func (c *ContextFs) Name() string { + return "Testing" +} + +// Open implements context.Fs. +func (c *ContextFs) Open(ctx context.Context, name string) (afero.File, error) { + if c.OpenFunc == nil { + panic("unimplemented") + } + + return c.OpenFunc(ctx, name) +} + +// OpenFile implements context.Fs. +func (c *ContextFs) OpenFile(ctx context.Context, name string, flag int, perm fs.FileMode) (afero.File, error) { + if c.OpenFileFunc == nil { + panic("unimplemented") + } + + return c.OpenFileFunc(ctx, name, flag, perm) +} + +// Remove implements context.Fs. +func (c *ContextFs) Remove(ctx context.Context, name string) error { + if c.RemoveFunc == nil { + panic("unimplemented") + } + + return c.RemoveFunc(ctx, name) +} + +// RemoveAll implements context.Fs. +func (c *ContextFs) RemoveAll(ctx context.Context, path string) error { + if c.RemoveAllFunc == nil { + panic("unimplemented") + } + + return c.RemoveAllFunc(ctx, path) +} + +// Rename implements context.Fs. +func (c *ContextFs) Rename(ctx context.Context, oldname string, newname string) error { + if c.RenameFunc == nil { + panic("unimplemented") + } + + return c.RenameFunc(ctx, oldname, newname) +} + +// Stat implements context.Fs. +func (c *ContextFs) Stat(ctx context.Context, name string) (fs.FileInfo, error) { + if c.StatFunc == nil { + panic("unimplemented") + } + + return c.StatFunc(ctx, name) +} + +var _ context.Fs = (*ContextFs)(nil) diff --git a/testing/file.go b/testing/file.go new file mode 100644 index 0000000..9bd3f55 --- /dev/null +++ b/testing/file.go @@ -0,0 +1,142 @@ +package testing + +import ( + "io/fs" + + "github.com/spf13/afero" +) + +type File struct { + CloseFunc func() error + NameFunc func() string + ReadFunc func([]byte) (int, error) + ReadAtFunc func([]byte, int64) (int, error) + ReaddirFunc func(int) ([]fs.FileInfo, error) + ReaddirnamesFunc func(int) ([]string, error) + SeekFunc func(int64, int) (int64, error) + StatFunc func() (fs.FileInfo, error) + SyncFunc func() error + TruncateFunc func(int64) error + WriteFunc func([]byte) (int, error) + WriteAtFunc func([]byte, int64) (int, error) + WriteStringFunc func(string) (int, error) +} + +// Close implements afero.File. +func (f *File) Close() error { + if f.CloseFunc == nil { + panic("unimplemented") + } + + return f.CloseFunc() +} + +// Name implements afero.File. +func (f *File) Name() string { + if f.NameFunc == nil { + panic("unimplemented") + } + + return f.NameFunc() +} + +// Read implements afero.File. +func (f *File) Read(p []byte) (n int, err error) { + if f.ReadFunc == nil { + panic("unimplemented") + } + + return f.ReadFunc(p) +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + if f.ReadAtFunc == nil { + panic("unimplemented") + } + + return f.ReadAtFunc(p, off) +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + if f.ReaddirFunc == nil { + panic("unimplemented") + } + + return f.ReaddirFunc(count) +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + if f.ReaddirnamesFunc == nil { + panic("unimplemented") + } + + return f.ReaddirnamesFunc(n) +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + if f.SeekFunc == nil { + panic("unimplemented") + } + + return f.SeekFunc(offset, whence) +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + if f.StatFunc == nil { + panic("unimplemented") + } + + return f.StatFunc() +} + +// Sync implements afero.File. +func (f *File) Sync() error { + if f.SyncFunc == nil { + panic("unimplemented") + } + + return f.SyncFunc() +} + +// Truncate implements afero.File. +func (f *File) Truncate(size int64) error { + if f.TruncateFunc == nil { + panic("unimplemented") + } + + return f.TruncateFunc(size) +} + +// Write implements afero.File. +func (f *File) Write(p []byte) (n int, err error) { + if f.WriteFunc == nil { + panic("unimplemented") + } + + return f.WriteFunc(p) +} + +// WriteAt implements afero.File. +func (f *File) WriteAt(p []byte, off int64) (n int, err error) { + if f.WriteAtFunc == nil { + panic("unimplemented") + } + + return f.WriteAtFunc(p, off) +} + +// WriteString implements afero.File. +func (f *File) WriteString(s string) (ret int, err error) { + if f.WriteStringFunc == nil { + panic("unimplemented") + } + + return f.WriteStringFunc(s) +} + +var _ afero.File = (*File)(nil) diff --git a/testing/fileinfo.go b/testing/fileinfo.go new file mode 100644 index 0000000..21f148e --- /dev/null +++ b/testing/fileinfo.go @@ -0,0 +1,47 @@ +package testing + +import ( + "io/fs" + "time" +) + +type FileInfo struct { + IsDirValue bool + ModTimeValue time.Time + ModeValue fs.FileMode + NameValue string + SizeValue int64 + SysValue any +} + +// IsDir implements fs.FileInfo. +func (f *FileInfo) IsDir() bool { + return f.IsDirValue +} + +// ModTime implements fs.FileInfo. +func (f *FileInfo) ModTime() time.Time { + return f.ModTimeValue +} + +// Mode implements fs.FileInfo. +func (f *FileInfo) Mode() fs.FileMode { + return f.ModeValue +} + +// Name implements fs.FileInfo. +func (f *FileInfo) Name() string { + return f.NameValue +} + +// Size implements fs.FileInfo. +func (f *FileInfo) Size() int64 { + return f.SizeValue +} + +// Sys implements fs.FileInfo. +func (f *FileInfo) Sys() any { + return f.SysValue +} + +var _ fs.FileInfo = (*FileInfo)(nil) diff --git a/testing/fs.go b/testing/fs.go new file mode 100644 index 0000000..c1322c1 --- /dev/null +++ b/testing/fs.go @@ -0,0 +1,138 @@ +package testing + +import ( + "io/fs" + "time" + + "github.com/spf13/afero" +) + +type Fs struct { + ChmodFunc func(string, fs.FileMode) error + ChownFunc func(string, int, int) error + ChtimesFunc func(string, time.Time, time.Time) error + CreateFunc func(string) (afero.File, error) + MkdirAllFunc func(string, fs.FileMode) error + MkdirFunc func(string, fs.FileMode) error + OpenFunc func(string) (afero.File, error) + OpenFileFunc func(string, int, fs.FileMode) (afero.File, error) + RemoveAllFunc func(string) error + RemoveFunc func(string) error + RenameFunc func(string, string) error + StatFunc func(string) (fs.FileInfo, error) +} + +// Chmod implements afero.Fs. +func (f *Fs) Chmod(name string, mode fs.FileMode) error { + if f.ChmodFunc == nil { + panic("unimplemented") + } + + return f.ChmodFunc(name, mode) +} + +// Chown implements afero.Fs. +func (f *Fs) Chown(name string, uid int, gid int) error { + if f.ChownFunc == nil { + panic("unimplemented") + } + + return f.ChownFunc(name, uid, gid) +} + +// Chtimes implements afero.Fs. +func (f *Fs) Chtimes(name string, atime time.Time, mtime time.Time) error { + if f.ChtimesFunc == nil { + panic("unimplemented") + } + + return f.ChtimesFunc(name, atime, mtime) +} + +// Create implements afero.Fs. +func (f *Fs) Create(name string) (afero.File, error) { + if f.CreateFunc == nil { + panic("unimplemented") + } + + return f.CreateFunc(name) +} + +// Mkdir implements afero.Fs. +func (f *Fs) Mkdir(name string, perm fs.FileMode) error { + if f.MkdirFunc == nil { + panic("unimplemented") + } + + return f.MkdirFunc(name, perm) +} + +// MkdirAll implements afero.Fs. +func (f *Fs) MkdirAll(path string, perm fs.FileMode) error { + if f.MkdirAllFunc == nil { + panic("unimplemented") + } + + return f.MkdirAllFunc(path, perm) +} + +// Name implements afero.Fs. +func (f *Fs) Name() string { + return "Testing" +} + +// Open implements afero.Fs. +func (f *Fs) Open(name string) (afero.File, error) { + if f.OpenFunc == nil { + panic("unimplemented") + } + + return f.OpenFunc(name) +} + +// OpenFile implements afero.Fs. +func (f *Fs) OpenFile(name string, flag int, perm fs.FileMode) (afero.File, error) { + if f.OpenFileFunc == nil { + panic("unimplemented") + } + + return f.OpenFileFunc(name, flag, perm) +} + +// Remove implements afero.Fs. +func (f *Fs) Remove(name string) error { + if f.RemoveFunc == nil { + panic("unimplemented") + } + + return f.RemoveFunc(name) +} + +// RemoveAll implements afero.Fs. +func (f *Fs) RemoveAll(path string) error { + if f.RemoveAllFunc == nil { + panic("unimplemented") + } + + return f.RemoveAllFunc(path) +} + +// Rename implements afero.Fs. +func (f *Fs) Rename(oldname string, newname string) error { + if f.RenameFunc == nil { + panic("unimplemented") + } + + return f.RenameFunc(oldname, newname) +} + +// Stat implements afero.Fs. +func (f *Fs) Stat(name string) (fs.FileInfo, error) { + if f.StatFunc == nil { + panic("unimplemented") + } + + return f.StatFunc(name) +} + +var _ afero.Fs = (*Fs)(nil) diff --git a/writer/file.go b/writer/file.go new file mode 100644 index 0000000..3acc17a --- /dev/null +++ b/writer/file.go @@ -0,0 +1,81 @@ +package writer + +import ( + "io" + "io/fs" + "syscall" +) + +type File struct { + writer io.Writer + name string +} + +// Close implements afero.File. +func (f *File) Close() error { + return nil +} + +// Name implements afero.File. +func (f *File) Name() string { + return f.name +} + +// Read implements afero.File. +func (f *File) Read([]byte) (n int, err error) { + return 0, syscall.EROFS +} + +// ReadAt implements afero.File. +func (f *File) ReadAt(p []byte, off int64) (n int, err error) { + return 0, syscall.EROFS +} + +// Readdir implements afero.File. +func (f *File) Readdir(count int) ([]fs.FileInfo, error) { + return nil, syscall.EROFS +} + +// Readdirnames implements afero.File. +func (f *File) Readdirnames(n int) ([]string, error) { + return nil, syscall.EROFS +} + +// Seek implements afero.File. +func (f *File) Seek(offset int64, whence int) (int64, error) { + return 0, syscall.EROFS +} + +// Stat implements afero.File. +func (f *File) Stat() (fs.FileInfo, error) { + return nil, syscall.EROFS +} + +// Sync implements afero.File. +func (f *File) Sync() error { + return syscall.EROFS +} + +// Truncate implements afero.File. +func (f *File) Truncate(size int64) error { + return syscall.EROFS +} + +// Write implements afero.File. +func (f *File) Write(p []byte) (n int, err error) { + return f.writer.Write(p) +} + +// WriteAt implements afero.File. +func (f *File) WriteAt(p []byte, off int64) (n int, err error) { + if wa, ok := f.writer.(io.WriterAt); ok { + return wa.WriteAt(p, off) + } + + return 0, syscall.EROFS +} + +// WriteString implements afero.File. +func (f *File) WriteString(s string) (ret int, err error) { + return io.WriteString(f.writer, s) +} diff --git a/writer/file_test.go b/writer/file_test.go new file mode 100644 index 0000000..3de747d --- /dev/null +++ b/writer/file_test.go @@ -0,0 +1,35 @@ +package writer_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/writer" +) + +type stub struct { + closed bool +} + +func (s *stub) Write(p []byte) (int, error) { + return 0, nil +} + +func (s *stub) Close() error { + s.closed = true + return nil +} + +var _ = Describe("File", func() { + It("should not close the parent writer", func() { + w := &stub{} + fs := writer.NewFs(w) + file, err := fs.Open("doesn't matter") + Expect(err).NotTo(HaveOccurred()) + + err = file.Close() + + Expect(err).NotTo(HaveOccurred()) + Expect(w.closed).To(BeFalseBecause("the writer is not closed")) + }) +}) diff --git a/writer/fileinfo.go b/writer/fileinfo.go new file mode 100644 index 0000000..3496664 --- /dev/null +++ b/writer/fileinfo.go @@ -0,0 +1,43 @@ +package writer + +import ( + "io" + "io/fs" + "os" + "time" +) + +type FileInfo struct { + io.Writer + name string +} + +// IsDir implements fs.FileInfo. +func (f *FileInfo) IsDir() bool { + return false +} + +// ModTime implements fs.FileInfo. +func (f *FileInfo) ModTime() time.Time { + return time.Time{} +} + +// Mode implements fs.FileInfo. +func (f *FileInfo) Mode() fs.FileMode { + return os.ModePerm +} + +// Name implements fs.FileInfo. +func (f *FileInfo) Name() string { + return f.name +} + +// Size implements fs.FileInfo. +func (f *FileInfo) Size() int64 { + return -1 +} + +// Sys implements fs.FileInfo. +func (f *FileInfo) Sys() any { + return f.Writer +} diff --git a/writer/fs.go b/writer/fs.go new file mode 100644 index 0000000..3fc298a --- /dev/null +++ b/writer/fs.go @@ -0,0 +1,37 @@ +package writer + +import ( + "io" + "io/fs" + + "github.com/spf13/afero" +) + +type Fs struct { + afero.ReadOnlyFs + writer io.Writer +} + +// Name implements afero.Fs. +func (w *Fs) Name() string { + return "io.Writer" +} + +// Open implements afero.Fs. +func (w *Fs) Open(name string) (afero.File, error) { + return &File{w.writer, name}, nil +} + +// OpenFile implements afero.Fs. +func (w *Fs) OpenFile(name string, _ int, _ fs.FileMode) (afero.File, error) { + return &File{w.writer, name}, nil +} + +// Stat implements afero.Fs. +func (w *Fs) Stat(name string) (fs.FileInfo, error) { + return &FileInfo{w.writer, name}, nil +} + +func NewFs(writer io.Writer) afero.Fs { + return &Fs{writer: writer} +} diff --git a/writer/fs_test.go b/writer/fs_test.go new file mode 100644 index 0000000..5da101b --- /dev/null +++ b/writer/fs_test.go @@ -0,0 +1,24 @@ +package writer_test + +import ( + "bytes" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/unmango/aferox/writer" +) + +var _ = Describe("Fs", func() { + It("should write to provided writer", func() { + buf := &bytes.Buffer{} + fs := writer.NewFs(buf) + + file, err := fs.Open("doesn't matter") + + Expect(err).NotTo(HaveOccurred()) + _, err = file.WriteString("blahblahblah") + Expect(err).NotTo(HaveOccurred()) + Expect(buf.String()).To(Equal("blahblahblah")) + }) +}) diff --git a/writer/writer_suite_test.go b/writer/writer_suite_test.go new file mode 100644 index 0000000..f60c416 --- /dev/null +++ b/writer/writer_suite_test.go @@ -0,0 +1,13 @@ +package writer_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestWriter(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Writer Suite") +}