diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..d675a01d --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,20 @@ +# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.241.1/containers/go/.devcontainer/base.Dockerfile + +# [Choice] Go version (use -bullseye variants on local arm64/Apple Silicon): 1, 1.18, 1.17, 1-bullseye, 1.18-bullseye, 1.17-bullseye, 1-buster, 1.18-buster, 1.17-buster +ARG VARIANT="1.18-bullseye" +FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT} + +# [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 +ARG NODE_VERSION="none" +RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi + +# [Optional] Uncomment this section to install additional OS packages. +# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ +# && apt-get -y install --no-install-recommends + +# [Optional] Uncomment the next lines to use go get to install anything else you need +# USER vscode +# RUN go get -x + +# [Optional] Uncomment this line to install global node packages. +# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 diff --git a/.gitignore b/.gitignore index 9fb9af23..dd01ec48 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ secrets.* cmd/slackdump/slackdump cmd/sdconv/sdconv /slackdump +/TODO.* /*.txt *~ .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..dedebf6d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM golang:1.18.4 + +WORKDIR /build + +COPY . . + + +RUN go test ./... diff --git a/Makefile b/Makefile index c7534005..d22a89cf 100644 --- a/Makefile +++ b/Makefile @@ -52,6 +52,9 @@ clean: test: go test -race -cover -count=3 ./... +docker_test: + docker build . + man: slackdump.1 slackdump.1: README.rst diff --git a/auth/auth.go b/auth/auth.go index 65706059..fc068951 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -1,7 +1,9 @@ package auth import ( + "encoding/json" "errors" + "io" "net/http" ) @@ -35,26 +37,26 @@ var ( ) type simpleProvider struct { - token string - cookies []http.Cookie + Token string + Cookie []http.Cookie } func (c simpleProvider) Validate() error { - if c.token == "" { + if c.Token == "" { return ErrNoToken } - if len(c.cookies) == 0 { + if len(c.Cookie) == 0 { return ErrNoCookies } return nil } func (c simpleProvider) SlackToken() string { - return c.token + return c.Token } func (c simpleProvider) Cookies() []http.Cookie { - return c.cookies + return c.Cookie } // deref dereferences []*T to []T. @@ -65,3 +67,35 @@ func deref[T any](cc []*T) []T { } return ret } + +// Load deserialises JSON data from reader and returns a ValueAuth, that can +// be used to authenticate Slackdump. It will return ErrNoToken or +// ErrNoCookie if the authentication information is missing. +func Load(r io.Reader) (ValueAuth, error) { + dec := json.NewDecoder(r) + var s simpleProvider + if err := dec.Decode(&s); err != nil { + return ValueAuth{}, err + } + return ValueAuth{s}, s.Validate() +} + +// Save serialises authentication information to writer. It will return +// ErrNoToken or ErrNoCookie if provider fails validation. +func Save(w io.Writer, p Provider) error { + if err := p.Validate(); err != nil { + return err + } + + var s = simpleProvider{ + Token: p.SlackToken(), + Cookie: p.Cookies(), + } + + enc := json.NewEncoder(w) + if err := enc.Encode(s); err != nil { + return err + } + + return nil +} diff --git a/auth/auth_test.go b/auth/auth_test.go new file mode 100644 index 00000000..726e1e03 --- /dev/null +++ b/auth/auth_test.go @@ -0,0 +1,109 @@ +package auth + +import ( + "bytes" + "io" + "net/http" + "reflect" + "strings" + "testing" +) + +func TestLoad(t *testing.T) { + type args struct { + r io.Reader + } + tests := []struct { + name string + args args + want ValueAuth + wantErr bool + }{ + { + "loads valid data", + args{strings.NewReader(`{"Token":"token_value","Cookie":[{"Name":"d","Value":"abc","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null}]}`)}, + ValueAuth{simpleProvider{Token: "token_value", Cookie: []http.Cookie{ + {Name: "d", Value: "abc"}, + }}}, + false, + }, + { + "corrupt data", + args{strings.NewReader(`{`)}, + ValueAuth{}, + true, + }, + { + "no data", + args{strings.NewReader(``)}, + ValueAuth{}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Load(tt.args.r) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Load() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSave(t *testing.T) { + type args struct { + p Provider + } + tests := []struct { + name string + args args + wantW string + wantErr bool + }{ + { + "all info present", + args{ValueAuth{simpleProvider{Token: "token_value", Cookie: []http.Cookie{ + {Name: "d", Value: "abc"}, + }}}}, + `{"Token":"token_value","Cookie":[{"Name":"d","Value":"abc","Path":"","Domain":"","Expires":"0001-01-01T00:00:00Z","RawExpires":"","MaxAge":0,"Secure":false,"HttpOnly":false,"SameSite":0,"Raw":"","Unparsed":null}]}` + "\n", + false, + }, + { + "token missing", + args{ValueAuth{simpleProvider{Token: "", Cookie: []http.Cookie{ + {Name: "d", Value: "abc"}, + }}}}, + "", + true, + }, + { + "cookies missing", + args{ValueAuth{simpleProvider{Token: "token_value", Cookie: []http.Cookie{}}}}, + "", + true, + }, + { + "token and cookie are missing", + args{ValueAuth{simpleProvider{}}}, + "", + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := &bytes.Buffer{} + if err := Save(w, tt.args.p); (err != nil) != tt.wantErr { + t.Errorf("Save() error = %v, wantErr %v", err, tt.wantErr) + return + } + gotW := w.String() + if gotW != tt.wantW { + t.Errorf("Save() = %v, want %v", gotW, tt.wantW) + } + }) + } +} diff --git a/auth/browser.go b/auth/browser.go index a4470d93..62fa79f6 100644 --- a/auth/browser.go +++ b/auth/browser.go @@ -78,8 +78,8 @@ func NewBrowserAuth(ctx context.Context, opts ...BrowserOption) (BrowserAuth, er return br, err } br.simpleProvider = simpleProvider{ - token: token, - cookies: cookies, + Token: token, + Cookie: cookies, } return br, nil diff --git a/auth/file.go b/auth/file.go index 56eaba30..56afdc93 100644 --- a/auth/file.go +++ b/auth/file.go @@ -21,8 +21,8 @@ func NewCookieFileAuth(token string, cookieFile string) (CookieFileAuth, error) } fc := CookieFileAuth{ simpleProvider: simpleProvider{ - token: token, - cookies: deref(ptrCookies), + Token: token, + Cookie: deref(ptrCookies), }, } return fc, nil diff --git a/auth/value.go b/auth/value.go index 62898d7d..a55885c5 100644 --- a/auth/value.go +++ b/auth/value.go @@ -28,8 +28,8 @@ func NewValueAuth(token string, cookie string) (ValueAuth, error) { return ValueAuth{}, ErrNoCookies } return ValueAuth{simpleProvider{ - token: token, - cookies: []http.Cookie{ + Token: token, + Cookie: []http.Cookie{ makeCookie("d", cookie), makeCookie("d-s", fmt.Sprintf("%d", time.Now().Unix()-10)), }, @@ -51,7 +51,7 @@ func makeCookie(key, val string) http.Cookie { Value: val, Path: defaultPath, Domain: defaultDomain, - Expires: timeFunc().AddDate(10, 0, 0), + Expires: timeFunc().AddDate(10, 0, 0).UTC(), Secure: true, } } diff --git a/cmd/slackdump/main.go b/cmd/slackdump/main.go index f24a7104..d0ab2c02 100644 --- a/cmd/slackdump/main.go +++ b/cmd/slackdump/main.go @@ -14,6 +14,7 @@ import ( "syscall" "github.com/joho/godotenv" + "github.com/rusq/dlog" "github.com/rusq/osenv/v2" "github.com/rusq/tracer" "github.com/slack-go/slack" @@ -48,8 +49,9 @@ var secrets = []string{".env", ".env.txt", "secrets.txt"} // params is the command line parameters type params struct { - appCfg app.Config - creds app.SlackCreds + appCfg app.Config + creds app.SlackCreds + authReset bool traceFile string // trace file logFile string //log file, if not specified, outputs to stderr. @@ -71,13 +73,16 @@ func main() { fmt.Println(build) return } + if params.authReset { + if err := app.AuthReset(params.appCfg.Options.CacheDir); err != nil { + if !os.IsNotExist(err) { + dlog.Printf("auth reset error: %s", err) + } + } + } if err := run(context.Background(), params); err != nil { - if params.verbose { - log.Fatalf("%+v", err) - } else { - log.Fatal(err) - } + log.Fatal(err) } } @@ -89,6 +94,7 @@ func run(ctx context.Context, p params) error { return err } defer logStopFn() + ctx = dlog.NewContext(ctx, lg) // - setting the logger for slackdump package p.appCfg.Options.Logger = lg @@ -104,10 +110,9 @@ func run(ctx context.Context, p params) error { ctx, task := trace.NewTask(ctx, "main.run") defer task.End() - // init the authentication provider - provider, err := p.creds.AuthProvider(ctx, "") + provider, err := app.InitProvider(ctx, p.appCfg.Options.CacheDir, "", p.creds) if err != nil { - return fmt.Errorf("failed to initialise the auth provider: %w", err) + return err } else { p.creds = app.SlackCreds{} } @@ -116,7 +121,7 @@ func run(ctx context.Context, p params) error { trace.Logf(ctx, "info", "params: input: %+v", p) // override default handler for SIGTERM and SIGQUIT signals. - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() // run the application @@ -136,7 +141,7 @@ func run(ctx context.Context, p params) error { // initialised logger, stop function and an error, if any. The stop function // must be called in the deferred call, it will close the log file, if it is // open. If the error is returned the stop function is nil. -func initLog(filename string, verbose bool) (logger.Interface, func(), error) { +func initLog(filename string, verbose bool) (*dlog.Logger, func(), error) { lg := logger.Default lg.SetDebug(verbose) @@ -223,6 +228,7 @@ func parseCmdLine(args []string) (params, error) { // authentication fs.StringVar(&p.creds.Token, "t", osenv.Secret(slackTokenEnv, ""), "Specify slack `API_token`, (environment: "+slackTokenEnv+")") fs.StringVar(&p.creds.Cookie, "cookie", osenv.Secret(slackCookieEnv, ""), "d= cookie `value` or a path to a cookie.txt file (environment: "+slackCookieEnv+")") + fs.BoolVar(&p.authReset, "auth-reset", false, "reset EZ-Login 3000 authentication.") // operation mode fs.BoolVar(&p.appCfg.ListFlags.Channels, "c", false, "same as -list-channels") @@ -262,7 +268,8 @@ func parseCmdLine(args []string) (params, error) { fs.IntVar(&p.appCfg.Options.ChannelsPerReq, "npr", slackdump.DefOptions.ChannelsPerReq, "number of `channels` per request.") fs.IntVar(&p.appCfg.Options.RepliesPerReq, "rpr", slackdump.DefOptions.RepliesPerReq, "number of `replies` per request.") - // - user cache controls + // - cache controls + fs.StringVar(&p.appCfg.Options.CacheDir, "cache-dir", app.CacheDir(), "slackdump cache directory") fs.StringVar(&p.appCfg.Options.UserCacheFilename, "user-cache-file", slackdump.DefOptions.UserCacheFilename, "user cache file`name`.") fs.DurationVar(&p.appCfg.Options.MaxUserCacheAge, "user-cache-age", slackdump.DefOptions.MaxUserCacheAge, "user cache lifetime `duration`. Set this to 0 to disable cache.") fs.BoolVar(&p.appCfg.Options.NoUserCache, "no-user-cache", slackdump.DefOptions.NoUserCache, "skip fetching users") diff --git a/cmd/slackdump/main_test.go b/cmd/slackdump/main_test.go index 7dad89dc..3faa1fbd 100644 --- a/cmd/slackdump/main_test.go +++ b/cmd/slackdump/main_test.go @@ -41,6 +41,10 @@ func Test_output_validFormat(t *testing.T) { } func Test_checkParameters(t *testing.T) { + // setup + slackdump.DefOptions.CacheDir = app.CacheDir() + + // test type args struct { args []string } @@ -63,7 +67,6 @@ func Test_checkParameters(t *testing.T) { Users: false, Channels: true, }, - FilenameTemplate: defFilenameTemplate, Input: app.Input{List: &structures.EntityList{}}, Output: app.Output{Filename: "-", Format: "text"}, diff --git a/doc/diagrams/auth-flow.puml b/doc/diagrams/auth-flow.puml new file mode 100644 index 00000000..37ef9847 --- /dev/null +++ b/doc/diagrams/auth-flow.puml @@ -0,0 +1,20 @@ +@startuml Authentication flow +start; +if (credentials empty?) then (yes) + :load stored credentials; + if (success?) then (yes) + :test credentials; + if (credentials valid?) then (yes) + #palegreen:return credentials provider; + stop + else (no) + endif + else (no) + endif +else(no) +endif + :determine auth provider type; + :save credentials; + #palegreen:return credentials provider; +end +@enduml diff --git a/fsadapter/zipfs_test.go b/fsadapter/zipfs_test.go index 7778424f..15cd9060 100644 --- a/fsadapter/zipfs_test.go +++ b/fsadapter/zipfs_test.go @@ -134,6 +134,7 @@ func TestNewZIP(t *testing.T) { t.Run("ensure it's the same zw", func(t *testing.T) { hFile, err := os.Create(filepath.Join(tmpdir, "x.zip")) assert.NoError(t, err) + defer hFile.Close() zw := zip.NewWriter(hFile) zf := NewZIP(zw) diff --git a/go.mod b/go.mod index 0e0814a9..8476e6c0 100644 --- a/go.mod +++ b/go.mod @@ -4,19 +4,21 @@ go 1.18 require ( github.com/MercuryEngineering/CookieMonster v0.0.0-20180304172713-1584578b3403 + github.com/denisbrodbeck/machineid v1.0.1 github.com/fatih/color v1.13.0 - github.com/gdamore/tcell/v2 v2.5.1 + github.com/gdamore/tcell/v2 v2.5.2 github.com/golang/mock v1.6.0 github.com/joho/godotenv v1.4.0 github.com/playwright-community/playwright-go v0.2000.1 - github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8 + github.com/rivo/tview v0.0.0-20220728094620-c6cff75ed57b github.com/rusq/dlog v1.3.3 github.com/rusq/osenv/v2 v2.0.1 + github.com/rusq/secure v0.0.3 github.com/rusq/tracer v1.0.1 github.com/schollz/progressbar/v3 v3.8.6 github.com/slack-go/slack v0.11.0 github.com/stretchr/testify v1.7.1 - golang.org/x/time v0.0.0-20220609170525-579cf78fd858 + golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 ) require ( @@ -31,10 +33,10 @@ require ( github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 // indirect - golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect - golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect + github.com/rivo/uniseg v0.3.1 // indirect + golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa // indirect + golang.org/x/sys v0.0.0-20220730100132-1609e554cd39 // indirect + golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/square/go-jose.v2 v2.6.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 2c7a9526..fb57d77f 100644 --- a/go.sum +++ b/go.sum @@ -5,13 +5,15 @@ github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9 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/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= +github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell/v2 v2.4.1-0.20210905002822-f057f0a857a1/go.mod h1:Az6Jt+M5idSED2YPGtwnfJV0kXohgdCBPmHGSYc1r04= -github.com/gdamore/tcell/v2 v2.5.1 h1:zc3LPdpK184lBW7syF2a5C6MV827KmErk9jGVnmsl/I= -github.com/gdamore/tcell/v2 v2.5.1/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= +github.com/gdamore/tcell/v2 v2.5.2 h1:tKzG29kO9p2V++3oBY2W9zUjYu7IK1MENFeY/BzJSVY= +github.com/gdamore/tcell/v2 v2.5.2/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= github.com/go-test/deep v1.0.4 h1:u2CU3YKy9I2pmu9pX0eq50wCgjfGIt539SqR7FbHiho= @@ -43,14 +45,17 @@ github.com/playwright-community/playwright-go v0.2000.1 h1:2JViSHpJQ/UL/PO1Gg6gX github.com/playwright-community/playwright-go v0.2000.1/go.mod h1:1y9cM9b9dVHnuRWzED1KLM7FtbwTJC8ibDjI6MNqewU= 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/rivo/tview v0.0.0-20220307222120-9994674d60a8 h1:xe+mmCnDN82KhC010l3NfYlA8ZbOuzbXAzSYBa6wbMc= -github.com/rivo/tview v0.0.0-20220307222120-9994674d60a8/go.mod h1:WIfMkQNY+oq/mWwtsjOYHIZBuwthioY2srOmljJkTnk= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/tview v0.0.0-20220728094620-c6cff75ed57b h1:/RX/1JPloj+3P0aYh1N6jfKNWUg1NEIeAHFLc+8UOOU= +github.com/rivo/tview v0.0.0-20220728094620-c6cff75ed57b/go.mod h1:/Ve2+D+tGMTMNAlGXKCIX9ZeX2InzODYHotmtKZUUVk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.3.1 h1:SDPP7SHNl1L7KrEFCSJslJ/DM9DT02Nq2C61XrfHMmk= +github.com/rivo/uniseg v0.3.1/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rusq/dlog v1.3.3 h1:Q9fZW1H/YEnlDg3Ph1k/BRSBfi/q5ezI+8Metws9tTI= github.com/rusq/dlog v1.3.3/go.mod h1:kjZAEvBu7m3+mnJQKoIeLul1YB3kJq/6lZBdDTZmpzA= github.com/rusq/osenv/v2 v2.0.1 h1:1LtNt8VNV/W86wb38Hyu5W3Rwqt/F1JNRGE+8GRu09o= github.com/rusq/osenv/v2 v2.0.1/go.mod h1:+wJBSisjNZpfoD961JzqjaM+PtaqSusO3b4oVJi7TFY= +github.com/rusq/secure v0.0.3 h1:PcWc7devLyJfMk8KZW2qUQFwhH5ugme4ncbcGztsLPw= +github.com/rusq/secure v0.0.3/go.mod h1:F1QilMKreuFRjov0UY7DZSIXn77/8RqMVGu2zV0RtqY= github.com/rusq/slack v0.11.100 h1:KnqoXJCtZEHcl4FtpdfQ1QgMOYlm8TX8fG9BMurEwlY= github.com/rusq/slack v0.11.100/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= github.com/rusq/tracer v1.0.1 h1:5u4PCV8NGO97VuAINQA4gOVRkPoqHimLE2jpezRVNMU= @@ -66,8 +71,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 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-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE= golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa h1:zuSxTR4o9y82ebqCUJYNGJbGPo6sKVl54f/TVDObg1c= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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= @@ -89,21 +95,21 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220730100132-1609e554cd39 h1:aNCnH+Fiqs7ZDTFH6oEFjIfbX2HvgQXJ6uQuUbTobjk= +golang.org/x/sys v0.0.0-20220730100132-1609e554cd39/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM= -golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 h1:Q5284mrmYTpACcm+eAKjKJH48BBwSyfJqmmGDTtT8Vc= +golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 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.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/time v0.0.0-20220609170525-579cf78fd858 h1:Dpdu/EMxGMFgq0CeYMh4fazTD2vtlZRYE7wyynxJb9U= -golang.org/x/time v0.0.0-20220609170525-579cf78fd858/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 h1:ftMN5LMiBFjbzleLqtoBZk7KdJwhuybIU+FckUHgoyQ= +golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 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.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= diff --git a/internal/app/auth.go b/internal/app/auth.go index a348c384..250ff564 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -3,12 +3,26 @@ package app import ( "context" "errors" + "fmt" + "io" "os" + "path/filepath" "runtime" + "runtime/trace" + "github.com/rusq/slackdump/v2" "github.com/rusq/slackdump/v2/auth" + "github.com/rusq/slackdump/v2/internal/encio" ) +//go:generate mockgen -source=auth.go -destination=../mocks/mock_app/mock_app.go Credentials,createOpener +//go:generate mockgen -destination=../mocks/mock_io/mock_io.go io ReadCloser,WriteCloser + +const ( + credsFile = "provider.bin" +) + +// SlackCreds holds the Token and Cookie reference. type SlackCreds struct { Token string Cookie string @@ -26,19 +40,25 @@ var ( // authentication determined is not supported for the current system, it will // return ErrUnsupported. func (c SlackCreds) Type(ctx context.Context) (auth.Type, error) { - if c.Token == "" || c.Cookie == "" { - if !ezLoginSupported() { - return auth.TypeInvalid, ErrUnsupported - } - if !ezLoginTested() { - return auth.TypeBrowser, ErrNotTested + if !c.IsEmpty() { + if isExistingFile(c.Cookie) { + return auth.TypeCookieFile, nil } - return auth.TypeBrowser, nil + return auth.TypeValue, nil } - if isExistingFile(c.Cookie) { - return auth.TypeCookieFile, nil + + if !ezLoginSupported() { + return auth.TypeInvalid, ErrUnsupported + } + if !ezLoginTested() { + return auth.TypeBrowser, ErrNotTested } - return auth.TypeValue, nil + return auth.TypeBrowser, nil + +} + +func (c SlackCreds) IsEmpty() bool { + return c.Token == "" || c.Cookie == "" } // AuthProvider returns the appropriate auth Provider depending on the values @@ -76,3 +96,118 @@ func ezLoginTested() bool { return true } } + +// filer is openCreator that will be used by InitProvider. +var filer createOpener = encryptedFile{} + +type Credentials interface { + IsEmpty() bool + AuthProvider(ctx context.Context, workspace string) (auth.Provider, error) +} + +// InitProvider initialises the auth.Provider depending on provided slack +// credentials. It returns auth.Provider or an error. The logic diagram is +// available in the doc/diagrams/auth_flow.puml. +// +// If the creds is empty, it attempts to load the stored credentials. If it +// finds them, it returns an initialised credentials provider. If not - it +// returns the auth provider according to the type of credentials determined +// by creds.AuthProvider, and saves them to an AES-256-CFB encrypted storage. +// +// The storage is encrypted using the hash of the unique machine-ID, supplied +// by the operating system (see package encio), it makes it impossible to +// transfer and use the stored credentials on another machine (including +// virtual), even another operating system on the same machine, unless it's a +// clone of the source operating system on which the credentials storage was +// created. +func InitProvider(ctx context.Context, cacheDir string, workspace string, creds Credentials) (auth.Provider, error) { + ctx, task := trace.NewTask(ctx, "InitProvider") + defer task.End() + + if err := os.MkdirAll(cacheDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create cache directory: %w", err) + } + + credsLoc := filepath.Join(cacheDir, credsFile) + + // try to load the existing credentials, if saved earlier. + if creds.IsEmpty() { + if prov, err := tryLoad(ctx, credsLoc); err != nil { + trace.Logf(ctx, "warn", "no saved credentials: %s", err) + } else { + trace.Log(ctx, "info", "loaded saved credentials") + return prov, nil + } + } + + // init the authentication provider + trace.Log(ctx, "info", "getting credentals from file or browser") + provider, err := creds.AuthProvider(ctx, workspace) + if err != nil { + return nil, fmt.Errorf("failed to initialise the auth provider: %w", err) + } + + if err := saveCreds(filer, credsLoc, provider); err != nil { + trace.Logf(ctx, "error", "failed to save credentials to: %s", credsLoc) + } + + return provider, nil +} + +var authTester = slackdump.TestAuth + +func tryLoad(ctx context.Context, filename string) (auth.Provider, error) { + prov, err := loadCreds(filer, filename) + if err != nil { + return nil, err + } + // test the loaded credentials + if err := authTester(ctx, prov); err != nil { + return nil, err + } + return prov, nil +} + +// loadCreds loads the encrypted credentials from the file. +func loadCreds(opener createOpener, filename string) (auth.Provider, error) { + f, err := opener.Open(filename) + if err != nil { + return nil, errors.New("failed to load stored credentials") + } + defer f.Close() + + return auth.Load(f) +} + +// saveCreds encrypts and saves the credentials. +func saveCreds(opener createOpener, filename string, p auth.Provider) error { + f, err := opener.Create(filename) + if err != nil { + return err + } + defer f.Close() + + return auth.Save(f, p) +} + +// AuthReset removes the cached credentials. +func AuthReset(cacheDir string) error { + return os.Remove(filepath.Join(cacheDir, credsFile)) +} + +// createOpener is the interface to be able to switch between encrypted file +// and plain text file, if needed. +type createOpener interface { + Create(string) (io.WriteCloser, error) + Open(string) (io.ReadCloser, error) +} + +type encryptedFile struct{} + +func (encryptedFile) Open(filename string) (io.ReadCloser, error) { + return encio.Open(filename) +} + +func (encryptedFile) Create(filename string) (io.WriteCloser, error) { + return encio.Create(filename) +} diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go index cad30110..f4aa65e5 100644 --- a/internal/app/auth_test.go +++ b/internal/app/auth_test.go @@ -1,13 +1,19 @@ package app import ( + "bytes" "context" + "errors" "os" "path/filepath" "reflect" "testing" + "github.com/golang/mock/gomock" "github.com/rusq/slackdump/v2/auth" + "github.com/rusq/slackdump/v2/internal/mocks/mock_app" + "github.com/rusq/slackdump/v2/internal/mocks/mock_io" + "github.com/stretchr/testify/assert" ) func Test_isExistingFile(t *testing.T) { @@ -37,6 +43,11 @@ func Test_isExistingFile(t *testing.T) { } func TestSlackCreds_Type(t *testing.T) { + dir := t.TempDir() + testFile := filepath.Join(dir, "fake_cookie") + if err := os.WriteFile(testFile, []byte("unittest"), 0644); err != nil { + t.Fatal(err) + } type fields struct { Token string Cookie string @@ -51,7 +62,9 @@ func TestSlackCreds_Type(t *testing.T) { want auth.Type wantErr bool }{ - // TODO: Add test cases. + {"browser", fields{Token: "", Cookie: ""}, args{context.Background()}, auth.TypeBrowser, false}, + {"value", fields{Token: "t", Cookie: "c"}, args{context.Background()}, auth.TypeValue, false}, + {"browser", fields{Token: "t", Cookie: testFile}, args{context.Background()}, auth.TypeCookieFile, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -70,3 +83,383 @@ func TestSlackCreds_Type(t *testing.T) { }) } } + +func TestSlackCreds_IsEmpty(t *testing.T) { + type fields struct { + Token string + Cookie string + } + tests := []struct { + name string + fields fields + want bool + }{ + {"empty", fields{Token: "", Cookie: ""}, true}, + {"empty", fields{Token: "x", Cookie: ""}, true}, + {"empty", fields{Token: "", Cookie: "x"}, true}, + {"empty", fields{Token: "x", Cookie: "x"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := SlackCreds{ + Token: tt.fields.Token, + Cookie: tt.fields.Cookie, + } + if got := c.IsEmpty(); got != tt.want { + t.Errorf("SlackCreds.IsEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func fakeAuthTester(retErr error) func(context.Context, auth.Provider) error { + return func(ctx context.Context, p auth.Provider) error { + return retErr + } +} + +func TestInitProvider(t *testing.T) { + // prep + testDir := t.TempDir() + + storedProv, _ := auth.NewValueAuth("xoxc", "xoxd") + returnedProv, _ := auth.NewValueAuth("a", "b") + // using default filer + + type args struct { + ctx context.Context + cacheDir string + workspace string + } + tests := []struct { + name string + args args + expect func(m *mock_app.MockCredentials) + authTestErr error + want auth.Provider + wantErr bool + }{ + { + "empty creds, no errors", + args{context.Background(), testDir, "wsp"}, + func(m *mock_app.MockCredentials) { + m.EXPECT().IsEmpty().Return(false) + m.EXPECT(). + AuthProvider(gomock.Any(), "wsp"). + Return(storedProv, nil) + }, + nil, //not used in the test + storedProv, + false, + }, + { + "creds empty, tryLoad succeeds", + args{context.Background(), testDir, "wsp"}, + func(m *mock_app.MockCredentials) { + m.EXPECT().IsEmpty().Return(true) + }, + nil, + storedProv, // loaded from file + false, + }, + { + "creds empty, tryLoad fails", + args{context.Background(), testDir, "wsp"}, + func(m *mock_app.MockCredentials) { + m.EXPECT().IsEmpty().Return(true) + m.EXPECT().AuthProvider(gomock.Any(), "wsp").Return(returnedProv, nil) + }, + errors.New("auth test fail"), // auth test fails + returnedProv, + false, + }, + { + "creds non-empty, provider failed", + args{context.Background(), testDir, "wsp"}, + func(m *mock_app.MockCredentials) { + m.EXPECT().IsEmpty().Return(false) + m.EXPECT().AuthProvider(gomock.Any(), "wsp").Return(nil, errors.New("authProvider failed")) + }, + nil, + nil, + true, + }, + { + "creds non-empty, provider succeeds, save succeeds", + args{context.Background(), testDir, "wsp"}, + func(m *mock_app.MockCredentials) { + m.EXPECT().IsEmpty().Return(false) + m.EXPECT().AuthProvider(gomock.Any(), "wsp").Return(returnedProv, nil) + }, + nil, + returnedProv, + false, + }, + { + "creds non-empty, provider succeeds, save fails", + args{context.Background(), t.TempDir() + "$", "wsp"}, + func(m *mock_app.MockCredentials) { + m.EXPECT().IsEmpty().Return(false) + m.EXPECT().AuthProvider(gomock.Any(), "wsp").Return(returnedProv, nil) + }, + nil, + returnedProv, + false, // save error is ignored, and is visible only in trace. + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + oldTester := authTester + defer func() { + authTester = oldTester + }() + authTester = fakeAuthTester(tt.authTestErr) + + // resetting credentials + credsFile := filepath.Join(testDir, credsFile) + if err := saveCreds(filer, credsFile, storedProv); err != nil { + t.Fatal(err) + } + + mc := mock_app.NewMockCredentials(gomock.NewController(t)) + tt.expect(mc) + + // test + got, err := InitProvider(tt.args.ctx, tt.args.cacheDir, tt.args.workspace, mc) + if (err != nil) != tt.wantErr { + t.Errorf("InitProvider() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("InitProvider() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_tryLoad(t *testing.T) { + // preparing file for testing + testDir := t.TempDir() + testProvider, _ := auth.NewValueAuth("xoxc", "xoxd") + credsFile := filepath.Join(testDir, credsFile) + if err := saveCreds(filer, credsFile, testProvider); err != nil { + t.Fatal(err) + } + + type args struct { + ctx context.Context + filename string + } + tests := []struct { + name string + args args + authTestErr error + want auth.Provider + wantErr bool + }{ + { + "all ok", + args{context.Background(), credsFile}, + nil, + testProvider, + false, + }, + { + "load fails", + args{context.Background(), filepath.Join(testDir, "fake")}, + nil, + nil, + true, + }, + { + "auth test fails", + args{context.Background(), credsFile}, + errors.New("auth test fail"), + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // setup + oldTester := authTester + defer func() { + authTester = oldTester + }() + authTester = fakeAuthTester(tt.authTestErr) + + got, err := tryLoad(tt.args.ctx, tt.args.filename) + if (err != nil) != tt.wantErr { + t.Errorf("tryLoad() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("tryLoad() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_loadCreds(t *testing.T) { + testProv, _ := auth.NewValueAuth("xoxc", "xoxd") + var buf bytes.Buffer + if err := auth.Save(&buf, testProv); err != nil { + t.Fatal(err) + } + var testProvBytes = buf.Bytes() + + type args struct { + filename string + } + tests := []struct { + name string + args args + expect func(mco *mock_app.MockcreateOpener, mrc *mock_io.MockReadCloser) + want auth.Provider + wantErr bool + }{ + { + "all ok", + args{"fakefile.ext"}, + func(mco *mock_app.MockcreateOpener, mrc *mock_io.MockReadCloser) { + readCall := mrc.EXPECT(). + Read(gomock.Any()). + DoAndReturn(func(b []byte) (int, error) { + return copy(b, []byte(testProvBytes)), nil + }) + mrc.EXPECT().Close().After(readCall).Return(nil) + + mco.EXPECT(). + Open("fakefile.ext"). + Return(mrc, nil) + }, + testProv, + false, + }, + { + "auth.Read error", + args{"fakefile.ext"}, + func(mco *mock_app.MockcreateOpener, mrc *mock_io.MockReadCloser) { + readCall := mrc.EXPECT(). + Read(gomock.Any()). + Return(0, errors.New("auth.Read error")) + mrc.EXPECT().Close().After(readCall).Return(nil) + + mco.EXPECT(). + Open("fakefile.ext"). + Return(mrc, nil) + }, + auth.ValueAuth{}, + true, + }, + { + "read error", + args{"fakefile.ext"}, + func(mco *mock_app.MockcreateOpener, mrc *mock_io.MockReadCloser) { + mco.EXPECT(). + Open("fakefile.ext"). + Return(nil, errors.New("it was at this moment that test framework knew: it fucked up")) + }, + nil, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mco := mock_app.NewMockcreateOpener(ctrl) + mrc := mock_io.NewMockReadCloser(ctrl) + tt.expect(mco, mrc) + + got, err := loadCreds(mco, tt.args.filename) + if (err != nil) != tt.wantErr { + t.Errorf("loadCreds() error = %v, wantErr %v", err, tt.wantErr) + return + } + assert.Equal(t, tt.want, got) + // if !reflect.DeepEqual(got, tt.want) { + // t.Errorf("loadCreds() = %v, want %v", got, tt.want) + // } + }) + } +} + +func Test_saveCreds(t *testing.T) { + testProv, _ := auth.NewValueAuth("xoxc", "xoxd") + var buf bytes.Buffer + if err := auth.Save(&buf, testProv); err != nil { + t.Fatal(err) + } + var testProvBytes = buf.Bytes() + + type args struct { + filename string + p auth.Provider + } + tests := []struct { + name string + args args + expect func(m *mock_app.MockcreateOpener, mwc *mock_io.MockWriteCloser) + wantErr bool + }{ + { + "all ok", + args{filename: "filename.ext", p: testProv}, + func(m *mock_app.MockcreateOpener, mwc *mock_io.MockWriteCloser) { + wc := mwc.EXPECT().Write(testProvBytes).Return(len(testProvBytes), nil) + mwc.EXPECT().Close().After(wc).Return(nil) + + m.EXPECT().Create("filename.ext").Return(mwc, nil) + }, + false, + }, + { + "create fails", + args{filename: "filename.ext", p: testProv}, + func(m *mock_app.MockcreateOpener, mwc *mock_io.MockWriteCloser) { + m.EXPECT().Create("filename.ext").Return(nil, errors.New("create fail")) + }, + true, + }, + { + "write fails", + args{filename: "filename.ext", p: testProv}, + func(m *mock_app.MockcreateOpener, mwc *mock_io.MockWriteCloser) { + wc := mwc.EXPECT().Write(testProvBytes).Return(0, errors.New("write fail")) + mwc.EXPECT().Close().After(wc).Return(nil) + + m.EXPECT().Create("filename.ext").Return(mwc, nil) + }, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mco := mock_app.NewMockcreateOpener(ctrl) + mwc := mock_io.NewMockWriteCloser(ctrl) + tt.expect(mco, mwc) + + if err := saveCreds(mco, tt.args.filename, tt.args.p); (err != nil) != tt.wantErr { + t.Errorf("saveCreds() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestAuthReset(t *testing.T) { + t.Run("file is removed", func(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, credsFile) + if err := os.WriteFile(testFile, []byte("unit"), 0644); err != nil { + t.Fatal(err) + } + if err := AuthReset(tmpDir); err != nil { + t.Errorf("AuthReset unexpected error: %s", err) + } + if fi, err := os.Stat(testFile); !os.IsNotExist(err) || fi != nil { + t.Errorf("expected the %s to be removed, but it is there", testFile) + } + }) +} diff --git a/internal/app/cache.go b/internal/app/cache.go new file mode 100644 index 00000000..9111b17a --- /dev/null +++ b/internal/app/cache.go @@ -0,0 +1,26 @@ +package app + +import ( + "os" + "path/filepath" + + "github.com/rusq/dlog" +) + +const ( + cacheDirName = "slackdump" +) + +// ucd detects user cache dir and returns slack cache directory name. +func ucd(ucdFn func() (string, error)) string { + ucd, err := ucdFn() + if err != nil { + dlog.Debug(err) + return "." + } + return filepath.Join(ucd, cacheDirName) +} + +func CacheDir() string { + return ucd(os.UserCacheDir) +} diff --git a/internal/app/cache_test.go b/internal/app/cache_test.go new file mode 100644 index 00000000..7f251184 --- /dev/null +++ b/internal/app/cache_test.go @@ -0,0 +1,60 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "testing" +) + +func TestCacheDir(t *testing.T) { + ucd, err := os.UserCacheDir() + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + want string + }{ + { + "returns the cacheDir value", + filepath.Join(ucd, cacheDirName), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := CacheDir(); got != tt.want { + t.Errorf("CacheDir() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_ucd(t *testing.T) { + type args struct { + ucdFn func() (string, error) + } + tests := []struct { + name string + args args + want string + }{ + { + "detect OK", + args{func() (string, error) { return "OK", nil }}, + filepath.Join("OK", cacheDirName), + }, + { + "detect failure", + args{func() (string, error) { return "", errors.New("failed") }}, + ".", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ucd(tt.args.ucdFn); got != tt.want { + t.Errorf("ucd() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/encio/encio.go b/internal/encio/encio.go new file mode 100644 index 00000000..e8bffa82 --- /dev/null +++ b/internal/encio/encio.go @@ -0,0 +1,157 @@ +// Package encio provides encrypted using AES-256-CFB input/output functions. +// +// Encrypted container structure is the following: +// +// |__...__|____________... +// 0 ^ 16 ^ +// | +-- encrypted data +// +----------- 16 bytes IV +// +package encio + +import ( + "bytes" + "crypto/aes" + "crypto/rand" + "errors" + "fmt" + "io" + "os" + + "github.com/rusq/secure" +) + +const keySz = 32 // 32 bytes key size enables the AES-256 + +var appID = "76d19bf515c59483e8923fcad9f1b65025d445e71801688b7edfb9cc2e64497f" + +var ErrDecrypt = errors.New("decryption error") + +// Open opens an encrypted file container. +func Open(filename string) (io.ReadCloser, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + r, err := NewReader(f) + if err != nil { + f.Close() + return nil, err + } + rc := readCloser{ + f: f, + Reader: r, + } + return &rc, nil +} + +// NewReader wraps the ciphertext reader, and returns the reader that a +// plaintext can be read from. +func NewReader(r io.Reader) (io.Reader, error) { + var iv [aes.BlockSize]byte + if n, err := r.Read(iv[:]); err != nil { + return nil, err + } else if n != len(iv) { + return nil, ErrDecrypt + } + + key, err := encryptionKey() + if err != nil { + return nil, err + } + return secure.NewReaderWithKey(r, key, iv) +} + +// readCloser wraps around the file closer and the reader. +type readCloser struct { + f io.Closer + io.Reader +} + +// Close closes the underlying file. +func (rc *readCloser) Close() error { + return rc.f.Close() +} + +// Create creates an encrypted file container. +func Create(filename string) (io.WriteCloser, error) { + f, err := os.Create(filename) + if err != nil { + return nil, err + } + ew, err := NewWriter(f) + if err != nil { + f.Close() + return nil, err + } + + wc := writeCloser{ + f: f, + WriteCloser: ew, + } + + return &wc, nil +} + +// NewWriter wraps the writer and returns the WriteCloser. Any information +// written to the writer is encrypted with the hashed machineID. WriteCloser +// must be closed to flush any buffered data. +func NewWriter(w io.Writer) (io.WriteCloser, error) { + iv, err := generateIV() + if err != nil { + return nil, err + } + // write IV to the file. + if _, err := io.CopyN(w, bytes.NewReader(iv[:]), int64(len(iv[:]))); err != nil { + return nil, fmt.Errorf("failed to write the initialisation vector: %w", err) + } + + key, err := encryptionKey() + if err != nil { + return nil, err + } + + return secure.NewWriterWithKey(w, key, iv) +} + +// writeCloser is a wrapper around file closer and the cipher WriteCloser. +type writeCloser struct { + f io.Closer + io.WriteCloser +} + +// Close closes the encrypted Writer and the underlying file. +func (wc *writeCloser) Close() error { + defer wc.f.Close() + if err := wc.WriteCloser.Close(); err != nil { + return err + } + return nil +} + +// generateIV generates the random initialisation vector. +func generateIV() ([aes.BlockSize]byte, error) { + var iv [aes.BlockSize]byte + _, err := rand.Read(iv[:]) + return iv, err +} + +// encryptionKey returns an encryption key from the passphrase that is +// generated from a hashed by appID machineID. +func encryptionKey() ([]byte, error) { + id, err := machineIDFn(appID) + if err != nil { + return nil, err + } + return secure.DeriveKey([]byte(id), keySz) +} + +// SetAppID allows to set the appID, that is used to hash the value of +// machineID. +func SetAppID(s string) error { + if s == "" { + return errors.New("empty app id") + } + appID = s + return nil +} diff --git a/internal/encio/encio_id.go b/internal/encio/encio_id.go new file mode 100644 index 00000000..b28d7664 --- /dev/null +++ b/internal/encio/encio_id.go @@ -0,0 +1,8 @@ +//go:build !linux +// +build !linux + +package encio + +import "github.com/denisbrodbeck/machineid" + +var machineIDFn = machineid.ProtectedID diff --git a/internal/encio/encio_id_linux.go b/internal/encio/encio_id_linux.go new file mode 100644 index 00000000..3c892d21 --- /dev/null +++ b/internal/encio/encio_id_linux.go @@ -0,0 +1,84 @@ +package encio + +import ( + "bufio" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "os" + "os/exec" + "strings" + + "github.com/denisbrodbeck/machineid" +) + +const cgroupFile = "/proc/1/cgroup" + +const ( + idLXC = "lxc" + idDocker = "docker" + idDockerNew = "0::/" +) + +var machineIDFn = protectedIDwrapper + +// protectedIDwrapper is a wrapper around machineid.ProtectedID. If executed +// inside docker container, the machineid.ProtectedID will fail, because it +// relies on /etc/machine-id that may not be present. If it fails to locate +// the machine ID it calls genID which will attempt to generate an ID using +// hostname, which is pretty random in docker, unless the user has assigned +// a specific name. +func protectedIDwrapper(appID string) (string, error) { + if inContainer, err := isInContainer(cgroupFile); !inContainer || err != nil { + return machineid.ProtectedID(appID) + } + compound := append(genID(), []byte(appID)...) + id := sha256.Sum256(compound) + return hex.EncodeToString(id[:]), nil +} + +// genID generates an ID either from hostname or, if it is unable to get the +// hostname, it will return "no-machine-id" +func genID() []byte { + if id, err := os.Hostname(); err == nil { + return []byte(id) + } + if buf, err := exec.Command("uname", "-n").Output(); err == nil { + return buf + } + var b = make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return b + } + return []byte("no-machine-id") +} + +// isInContainer checks if the service is being executed in docker or lxc +// container. +func isInContainer(cgroupPath string) (bool, error) { + + const maxlines = 5 // maximum lines to scan + + f, err := os.Open(cgroupPath) + if err != nil { + return false, err + } + defer f.Close() + scan := bufio.NewScanner(f) + + lines := 0 + for scan.Scan() && !(lines > maxlines) { + text := scan.Text() + for _, s := range []string{idDockerNew, idDocker, idLXC} { + if strings.Contains(text, s) { + return true, nil + } + } + lines++ + } + if err := scan.Err(); err != nil { + return false, err + } + + return false, nil +} diff --git a/internal/encio/encio_id_linux_test.go b/internal/encio/encio_id_linux_test.go new file mode 100644 index 00000000..ef208e5f --- /dev/null +++ b/internal/encio/encio_id_linux_test.go @@ -0,0 +1,98 @@ +package encio + +import ( + "os" + "path/filepath" + "testing" +) + +type testFiles struct { + dockerCgroup string + linuxCgroup string + newDockerCgroup string +} + +// createTestCgroupFiles creates mock files for tests +func createTestCgroupFiles(t *testing.T, dir string) *testFiles { + // docker cgroup setup + var files = testFiles{ + dockerCgroup: filepath.Join(dir, "docker.cgroup"), + linuxCgroup: filepath.Join(dir, "linux.cgroup"), + newDockerCgroup: filepath.Join(dir, "docker_new.cgroup"), + } + + if err := os.WriteFile(files.dockerCgroup, []byte(dockerCgroup), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(files.linuxCgroup, []byte(linuxCgroup), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(files.newDockerCgroup, []byte(newDockerCgroup), 0644); err != nil { + t.Fatal(err) + } + + return &files +} + +func Test_isInContainer(t *testing.T) { + dir := t.TempDir() + files := createTestCgroupFiles(t, dir) + + // TEST + type args struct { + cgroupPath string + } + tests := []struct { + name string + args args + want bool + wantErr bool + }{ + {"docker", args{files.dockerCgroup}, true, false}, + {"linux", args{files.linuxCgroup}, false, false}, + {"docker new", args{files.newDockerCgroup}, true, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := isInContainer(tt.args.cgroupPath) + if (err != nil) != tt.wantErr { + t.Errorf("isInContainer() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("isInContainer() = %v, want %v", got, tt.want) + } + }) + } +} + +const ( + dockerCgroup = `13:name=systemd:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +12:pids:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +11:hugetlb:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +10:net_prio:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +9:perf_event:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +8:net_cls:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +7:freezer:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +6:devices:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +5:memory:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +4:blkio:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +3:cpuacct:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +2:cpu:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee +1:cpuset:/docker/bc9f0894926991e3064b731c26d86af6df7390c0e6453e6027f9545aba5809ee` + + linuxCgroup = `11:cpuset:/ +10:pids:/init.scope +9:perf_event:/ +8:memory:/init.scope +7:blkio:/ +6:devices:/init.scope +5:rdma:/ +4:net_cls,net_prio:/ +3:freezer:/ +2:cpu,cpuacct:/ +1:name=systemd:/init.scope +0::/init.scope` + + newDockerCgroup = `0::/` +) diff --git a/internal/encio/encio_test.go b/internal/encio/encio_test.go new file mode 100644 index 00000000..bc10fb62 --- /dev/null +++ b/internal/encio/encio_test.go @@ -0,0 +1,185 @@ +// Package encio provides encrypted input/output functions +package encio + +import ( + "bytes" + "crypto/aes" + "io" + "path/filepath" + "strings" + "testing" +) + +const plaintext = "1234567890123456" + +func TestNewReadWriter(t *testing.T) { + var buf bytes.Buffer + + w, err := NewWriter(&buf) + if err != nil { + t.Fatal(err) + } + + n, err := w.Write([]byte(plaintext)) + if err != nil { + t.Fatalf("error encrypting text: %s", err) + } + if err := w.Close(); err != nil { + t.Errorf("error closing writer: %s", err) + } + if n != len(plaintext) { + t.Errorf("incosistent write byte count: want=%d, got=%d", len(plaintext), n) + } + if sz := len(plaintext) + aes.BlockSize; sz != buf.Len() { + t.Errorf("invalid encrypted message size: want=%d, got=%d", sz, buf.Len()) + } + + r, err := NewReader(&buf) + if err != nil { + t.Errorf("error creating reader: %s", err) + } + var result strings.Builder + if _, err := io.Copy(&result, r); err != nil { + t.Errorf("error reading encrypted data: %s", err) + } + if !strings.EqualFold(result.String(), plaintext) { + t.Errorf("invalid decrypted text: want=%q, got=%q", plaintext, result.String()) + } +} + +func TestCreateOpen(t *testing.T) { + const testfile = "testfile" + td := t.TempDir() + f, err := Create(filepath.Join(td, testfile)) + if err != nil { + t.Fatalf("error creating a test file: %s", err) + } + if n, err := f.Write([]byte(plaintext)); err != nil { + t.Errorf("error writing test data: %s", err) + } else if n != len(plaintext) { + t.Errorf("unexpected number of bytes written: want=%d, got=%d", len(plaintext), n) + } + if err := f.Close(); err != nil { + t.Errorf("error while closing R/W file: %s", err) + } + + g, err := Open(filepath.Join(td, testfile)) + if err != nil { + t.Errorf("error opening a test file: %s", err) + } + defer func() { + if err := g.Close(); err != nil { + t.Errorf("error while closing R/O file: %s", err) + } + }() + + var result strings.Builder + if _, err := io.Copy(&result, g); err != nil { + t.Errorf("error reading encrypted data: %s", err) + } + if !strings.EqualFold(result.String(), plaintext) { + t.Errorf("invalid decrypted text: want=%q, got=%q", plaintext, result.String()) + } +} + +func TestSetAppID(t *testing.T) { + type args struct { + s string + } + tests := []struct { + name string + args args + wantErr bool + }{ + { + "set app ID", + args{"test"}, + false, + }, + { + "empty app ID", + args{""}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oldAppID := appID + defer func() { + appID = oldAppID + }() + err := SetAppID(tt.args.s) + if (err != nil) != tt.wantErr { + t.Errorf("SetAppID() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if appID != tt.args.s { + t.Errorf("SetAppID failed to set the appID. want=%q, got=%q", tt.args.s, appID) + } + }) + } +} + +func Test_generateIV(t *testing.T) { + iv1, err := generateIV() + if err != nil { + t.Errorf("unexpected error: %s", err) + } + iv2, err2 := generateIV() + if err2 != nil { + t.Errorf("unexpected error 2: %s", err2) + } + + if bytes.EqualFold(iv1[:], iv2[:]) { + t.Errorf("same IV was generated, while it must be random: iv1=%#v, iv2=%#v", iv1, iv2) + } +} + +type fakeCloser struct { + closeCalledTimes int +} + +func (c *fakeCloser) Close() error { + c.closeCalledTimes++ + return nil +} + +type fakeReadWriter struct { + fakeCloser + + readCalledTimes int + writeCalledTimes int +} + +func (frw *fakeReadWriter) Read(b []byte) (int64, error) { + frw.readCalledTimes++ + return int64(len(b)), nil +} + +func (frw *fakeReadWriter) Write(b []byte) (int, error) { + frw.writeCalledTimes++ + return len(b), nil +} + +func Test_writeCloser_Close(t *testing.T) { + var ( + frw = new(fakeReadWriter) + fc = new(fakeCloser) + ) + + fw := writeCloser{ + f: fc, + WriteCloser: frw, + } + + fw.Close() + + if fc.closeCalledTimes != 1 { + t.Errorf("f.Close called unexpected number of times: %d", 1) + } + if frw.closeCalledTimes != 1 { + t.Errorf("wc.Close called unexpected number of times: %d", 1) + } +} diff --git a/internal/mocks/mock_app/mock_app.go b/internal/mocks/mock_app/mock_app.go new file mode 100644 index 00000000..012cc310 --- /dev/null +++ b/internal/mocks/mock_app/mock_app.go @@ -0,0 +1,119 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: auth.go + +// Package mock_app is a generated GoMock package. +package mock_app + +import ( + context "context" + io "io" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + auth "github.com/rusq/slackdump/v2/auth" +) + +// MockCredentials is a mock of Credentials interface. +type MockCredentials struct { + ctrl *gomock.Controller + recorder *MockCredentialsMockRecorder +} + +// MockCredentialsMockRecorder is the mock recorder for MockCredentials. +type MockCredentialsMockRecorder struct { + mock *MockCredentials +} + +// NewMockCredentials creates a new mock instance. +func NewMockCredentials(ctrl *gomock.Controller) *MockCredentials { + mock := &MockCredentials{ctrl: ctrl} + mock.recorder = &MockCredentialsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockCredentials) EXPECT() *MockCredentialsMockRecorder { + return m.recorder +} + +// AuthProvider mocks base method. +func (m *MockCredentials) AuthProvider(ctx context.Context, workspace string) (auth.Provider, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "AuthProvider", ctx, workspace) + ret0, _ := ret[0].(auth.Provider) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AuthProvider indicates an expected call of AuthProvider. +func (mr *MockCredentialsMockRecorder) AuthProvider(ctx, workspace interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AuthProvider", reflect.TypeOf((*MockCredentials)(nil).AuthProvider), ctx, workspace) +} + +// IsEmpty mocks base method. +func (m *MockCredentials) IsEmpty() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsEmpty") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsEmpty indicates an expected call of IsEmpty. +func (mr *MockCredentialsMockRecorder) IsEmpty() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEmpty", reflect.TypeOf((*MockCredentials)(nil).IsEmpty)) +} + +// MockcreateOpener is a mock of createOpener interface. +type MockcreateOpener struct { + ctrl *gomock.Controller + recorder *MockcreateOpenerMockRecorder +} + +// MockcreateOpenerMockRecorder is the mock recorder for MockcreateOpener. +type MockcreateOpenerMockRecorder struct { + mock *MockcreateOpener +} + +// NewMockcreateOpener creates a new mock instance. +func NewMockcreateOpener(ctrl *gomock.Controller) *MockcreateOpener { + mock := &MockcreateOpener{ctrl: ctrl} + mock.recorder = &MockcreateOpenerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockcreateOpener) EXPECT() *MockcreateOpenerMockRecorder { + return m.recorder +} + +// Create mocks base method. +func (m *MockcreateOpener) Create(arg0 string) (io.WriteCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Create", arg0) + ret0, _ := ret[0].(io.WriteCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Create indicates an expected call of Create. +func (mr *MockcreateOpenerMockRecorder) Create(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockcreateOpener)(nil).Create), arg0) +} + +// Open mocks base method. +func (m *MockcreateOpener) Open(arg0 string) (io.ReadCloser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Open", arg0) + ret0, _ := ret[0].(io.ReadCloser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Open indicates an expected call of Open. +func (mr *MockcreateOpenerMockRecorder) Open(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Open", reflect.TypeOf((*MockcreateOpener)(nil).Open), arg0) +} diff --git a/internal/mocks/mock_io/mock_io.go b/internal/mocks/mock_io/mock_io.go new file mode 100644 index 00000000..5065f7f1 --- /dev/null +++ b/internal/mocks/mock_io/mock_io.go @@ -0,0 +1,115 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: io (interfaces: ReadCloser,WriteCloser) + +// Package mock_io is a generated GoMock package. +package mock_io + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" +) + +// MockReadCloser is a mock of ReadCloser interface. +type MockReadCloser struct { + ctrl *gomock.Controller + recorder *MockReadCloserMockRecorder +} + +// MockReadCloserMockRecorder is the mock recorder for MockReadCloser. +type MockReadCloserMockRecorder struct { + mock *MockReadCloser +} + +// NewMockReadCloser creates a new mock instance. +func NewMockReadCloser(ctrl *gomock.Controller) *MockReadCloser { + mock := &MockReadCloser{ctrl: ctrl} + mock.recorder = &MockReadCloserMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockReadCloser) EXPECT() *MockReadCloserMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockReadCloser) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockReadCloserMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockReadCloser)(nil).Close)) +} + +// Read mocks base method. +func (m *MockReadCloser) Read(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Read", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Read indicates an expected call of Read. +func (mr *MockReadCloserMockRecorder) Read(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Read", reflect.TypeOf((*MockReadCloser)(nil).Read), arg0) +} + +// MockWriteCloser is a mock of WriteCloser interface. +type MockWriteCloser struct { + ctrl *gomock.Controller + recorder *MockWriteCloserMockRecorder +} + +// MockWriteCloserMockRecorder is the mock recorder for MockWriteCloser. +type MockWriteCloserMockRecorder struct { + mock *MockWriteCloser +} + +// NewMockWriteCloser creates a new mock instance. +func NewMockWriteCloser(ctrl *gomock.Controller) *MockWriteCloser { + mock := &MockWriteCloser{ctrl: ctrl} + mock.recorder = &MockWriteCloserMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockWriteCloser) EXPECT() *MockWriteCloserMockRecorder { + return m.recorder +} + +// Close mocks base method. +func (m *MockWriteCloser) Close() error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Close") + ret0, _ := ret[0].(error) + return ret0 +} + +// Close indicates an expected call of Close. +func (mr *MockWriteCloserMockRecorder) Close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockWriteCloser)(nil).Close)) +} + +// Write mocks base method. +func (m *MockWriteCloser) Write(arg0 []byte) (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Write", arg0) + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Write indicates an expected call of Write. +func (mr *MockWriteCloserMockRecorder) Write(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Write", reflect.TypeOf((*MockWriteCloser)(nil).Write), arg0) +} diff --git a/internal/network/network_allother_test.go b/internal/network/network_allother_test.go new file mode 100644 index 00000000..735a7d0f --- /dev/null +++ b/internal/network/network_allother_test.go @@ -0,0 +1,8 @@ +//go:build !windows +// +build !windows + +package network + +import "time" + +const maxRunDurationError = 10 * time.Millisecond // maximum deviation of run duration diff --git a/internal/network/network_test.go b/internal/network/network_test.go index 9dfd834b..18d51392 100644 --- a/internal/network/network_test.go +++ b/internal/network/network_test.go @@ -12,8 +12,7 @@ import ( ) const ( - testRateLimit = 100.0 // per second - maxRunDurationError = 10 * time.Millisecond // maximum deviation of run duration + testRateLimit = 100.0 // per second ) // calcRunDuration is the convenience function to calculate the expected run duration. diff --git a/internal/network/network_windows_test.go b/internal/network/network_windows_test.go new file mode 100644 index 00000000..22e8e564 --- /dev/null +++ b/internal/network/network_windows_test.go @@ -0,0 +1,8 @@ +//go:build windows +// +build windows + +package network + +import "time" + +const maxRunDurationError = 100 * time.Millisecond // so special diff --git a/options.go b/options.go index 7dd42282..b2132426 100644 --- a/options.go +++ b/options.go @@ -28,6 +28,7 @@ type Options struct { UserCacheFilename string // user cache filename MaxUserCacheAge time.Duration // how long the user cache is valid for. NoUserCache bool // disable fetching users from the API. + CacheDir string // cache directory Logger logger.Interface } @@ -47,6 +48,7 @@ var DefOptions = Options{ RepliesPerReq: 200, // the API-default is 1000 (see conversations.replies), but on large threads it may fail (see #54) UserCacheFilename: "users.cache", // seems logical MaxUserCacheAge: 4 * time.Hour, // quick math: that's 1/6th of a day, how's that, huh? + CacheDir: ".", // default cache dir Logger: logger.Default, } @@ -154,3 +156,12 @@ func WithLogger(l logger.Interface) Option { o.Logger = l } } + +func CacheDir(dir string) Option { + return func(o *Options) { + if dir == "" { + return + } + o.CacheDir = dir + } +} diff --git a/slackdump.go b/slackdump.go index ec6426b0..ed1a8743 100644 --- a/slackdump.go +++ b/slackdump.go @@ -6,7 +6,6 @@ import ( "io" "net/http" "os" - "path/filepath" "runtime/trace" "time" @@ -28,11 +27,6 @@ import ( //go:generate sh -c "mockgen -source slackdump.go -destination clienter_mock_test.go -package slackdump -mock_names clienter=mockClienter,Reporter=mockReporter" //go:generate sed -i ~ -e "s/NewmockClienter/newmockClienter/g" -e "s/NewmockReporter/newmockReporter/g" clienter_mock_test.go -const ( - cacheDirName = "slackdump" - cacheDirPermissions = 0750 -) - // Session stores basic session parameters. type Session struct { client clienter // Slack client @@ -46,8 +40,6 @@ type Session struct { UserIndex structures.UserIndex `json:"-"` options Options - - cacheDir string // cache directory on local system } // clienter is the interface with some functions of slack.Client with the sole @@ -99,22 +91,19 @@ func NewWithOptions(ctx context.Context, authProvider auth.Provider, opts Option return nil, &AuthError{Err: err} } - cacheDir, err := createCacheDir(cacheDirName) - if err != nil { - cacheDir = "." - trace.Logf(ctx, "warn", "failed to create the cache directory %q, will use current", cacheDirName) - } - sd := &Session{ - client: cl, - options: opts, - wspInfo: authTestResp, - fs: fsadapter.NewDirectory("."), // default is to save attachments to the current directory. - cacheDir: cacheDir, + client: cl, + options: opts, + wspInfo: authTestResp, + fs: fsadapter.NewDirectory("."), // default is to save attachments to the current directory. } sd.propagateLogger(sd.l()) + if err := os.MkdirAll(opts.CacheDir, 0700); err != nil { + return nil, fmt.Errorf("failed to create the cache directory: %s", err) + } + sd.l().Println("> checking user cache...") users, err := sd.GetUsers(ctx) if err != nil { @@ -127,19 +116,21 @@ func NewWithOptions(ctx context.Context, authProvider auth.Provider, opts Option return sd, nil } -func createCacheDir(subdir string) (string, error) { - if subdir == "" { - return "", errors.New("can't use top level cache directory") - } - cache, err := os.UserCacheDir() +// TestAuth attempts to authenticate with the given provider. It will return +// AuthError if faled. +func TestAuth(ctx context.Context, provider auth.Provider) error { + ctx, task := trace.NewTask(ctx, "TestAuth") + defer task.End() + + cl := slack.New(provider.SlackToken(), slack.OptionCookieRAW(toPtrCookies(provider.Cookies())...)) + + region := trace.StartRegion(ctx, "AuthTestContext") + defer region.End() + _, err := cl.AuthTestContext(ctx) if err != nil { - return "", err - } - cachePath := filepath.Join(cache, subdir) - if err := os.MkdirAll(cachePath, cacheDirPermissions); err != nil { - return "", err + return &AuthError{Err: err} } - return cachePath, nil + return nil } // Client returns the underlying slack.Client. diff --git a/slackdump_test.go b/slackdump_test.go index 6b4d1972..6a875d54 100644 --- a/slackdump_test.go +++ b/slackdump_test.go @@ -268,7 +268,6 @@ func TestSession_Me(t *testing.T) { Users: tt.fields.Users, UserIndex: tt.fields.UserIndex, options: tt.fields.options, - cacheDir: tt.fields.cacheDir, } got, err := sd.Me() if (err != nil) != tt.wantErr { @@ -322,7 +321,6 @@ func TestSession_l(t *testing.T) { Users: tt.fields.Users, UserIndex: tt.fields.UserIndex, options: tt.fields.options, - cacheDir: tt.fields.cacheDir, } if got := sd.l(); !reflect.DeepEqual(got, tt.want) { t.Errorf("Session.l() = %v, want %v", got, tt.want) diff --git a/users.go b/users.go index 02375f60..1f7ef786 100644 --- a/users.go +++ b/users.go @@ -15,6 +15,7 @@ import ( "github.com/slack-go/slack" + "github.com/rusq/slackdump/v2/internal/encio" "github.com/rusq/slackdump/v2/internal/network" "github.com/rusq/slackdump/v2/types" ) @@ -79,7 +80,7 @@ func (sd *Session) loadUserCache(filename string, suffix string, maxAge time.Dur return nil, err } - f, err := os.Open(filename) + f, err := encio.Open(filename) if err != nil { return nil, fmt.Errorf("failed to open %s: %w", filename, err) } @@ -96,14 +97,13 @@ func (sd *Session) loadUserCache(filename string, suffix string, maxAge time.Dur func (sd *Session) saveUserCache(filename string, suffix string, uu types.Users) error { filename = sd.makeCacheFilename(filename, suffix) - f, err := os.Create(filename) + f, err := encio.Create(filename) if err != nil { return fmt.Errorf("failed to create file %s: %w", filename, err) } defer f.Close() enc := json.NewEncoder(f) - enc.SetIndent("", " ") if err := enc.Encode(uu); err != nil { return fmt.Errorf("failed to encode data for %s: %w", filename, err) } @@ -113,7 +113,7 @@ func (sd *Session) saveUserCache(filename string, suffix string, uu types.Users) // makeCacheFilename converts filename.ext to filename-suffix.ext. func (sd *Session) makeCacheFilename(filename, suffix string) string { ne := filenameSplit(filename) - return filepath.Join(sd.cacheDir, filenameJoin(nameExt{ne[0] + "-" + suffix, ne[1]})) + return filepath.Join(sd.options.CacheDir, filenameJoin(nameExt{ne[0] + "-" + suffix, ne[1]})) } type nameExt [2]string diff --git a/users_test.go b/users_test.go index 13af0051..56bd6887 100644 --- a/users_test.go +++ b/users_test.go @@ -15,6 +15,7 @@ import ( "github.com/slack-go/slack" "github.com/stretchr/testify/assert" + "github.com/rusq/slackdump/v2/internal/encio" "github.com/rusq/slackdump/v2/internal/fixtures" "github.com/rusq/slackdump/v2/internal/structures" "github.com/rusq/slackdump/v2/types" @@ -49,7 +50,6 @@ func TestUsers_IndexByID(t *testing.T) { } func TestSession_saveUserCache(t *testing.T) { - // test saving file works sd := Session{wspInfo: &slack.AuthTestResponse{TeamID: "123"}} @@ -58,7 +58,7 @@ func TestSession_saveUserCache(t *testing.T) { assert.NoError(t, sd.saveUserCache(testfile, testSuffix, testUsers)) - reopenedF, err := os.Open(sd.makeCacheFilename(testfile, testSuffix)) + reopenedF, err := encio.Open(sd.makeCacheFilename(testfile, testSuffix)) if err != nil { t.Fatal(err) }