aboutsummaryrefslogtreecommitdiffstats
path: root/fikenconfig
diff options
context:
space:
mode:
authorSimen A. W. Olsen <hello@simenandre.no>2026-11-03 22:08:49 +0100
committerSimen A. W. Olsen <hello@simenandre.no>2026-11-03 22:08:49 +0100
commitbef3ccf4521d4ed431f3cdfc66e618c619ce28e4 (patch)
treeef8e6ff90fed4b27d4f2cfd082106f2efdb38ca5 /fikenconfig
parenta5491c9dccdf4678bf3b4380d208cf2562afeee4 (diff)
downloadfiken-bef3ccf4521d4ed431f3cdfc66e618c619ce28e4.tar.gz
fiken-bef3ccf4521d4ed431f3cdfc66e618c619ce28e4.zip
Share one Fiken login between programsHEADv0.4.0main
fikenconfig keeps an OAuth app and a grant per account in ~/.config/fiken, and finds the account that reaches a company. Renewing a grant takes a lock, so programs sharing it take turns.
Diffstat (limited to 'fikenconfig')
-rw-r--r--fikenconfig/config.go436
-rw-r--r--fikenconfig/config_test.go213
2 files changed, 649 insertions, 0 deletions
diff --git a/fikenconfig/config.go b/fikenconfig/config.go
new file mode 100644
index 0000000..9b6b800
--- /dev/null
+++ b/fikenconfig/config.go
@@ -0,0 +1,436 @@
+// Package fikenconfig is the Fiken login shared by every program on a machine:
+// one OAuth app, and one grant per Fiken account, each recording which
+// companies it reaches.
+//
+// A program names the company it works for and is handed the account that
+// reaches it, so logging in once, from any of them, logs in all of them. They
+// read and renew the same grant file, which is the only safe way to share one:
+// Fiken rotates the refresh token on every renewal, so two copies of a grant
+// means the first to renew silently logs out the other.
+package fikenconfig
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "simenandre.no/fiken/fiken"
+ "simenandre.no/fiken/fikenauth"
+)
+
+// Where the configuration lives, and what in the environment overrides it.
+const (
+ DirEnv = "FIKEN_CONFIG_DIR"
+ ClientIDEnv = "FIKEN_CLIENT_ID"
+ ClientSecretEnv = "FIKEN_CLIENT_SECRET"
+)
+
+const (
+ configFile = "config.json"
+ accountsDir = "accounts"
+ maxConfigBytes = 1 << 20
+)
+
+// ErrNoAccount means no account logged in here reaches the company asked for.
+var ErrNoAccount = errors.New("no Fiken account reaches that company")
+
+// Config is the shared configuration. It is written to disk, so the JSON field
+// names are a file format every program using this package reads.
+type Config struct {
+ App App `json:"app"`
+ Accounts map[string]*Account `json:"accounts,omitempty"`
+
+ // LoginCommand is how the calling program logs in, named whenever logging
+ // in is the fix.
+ LoginCommand string `json:"-"`
+
+ // ClientOptions and AuthOverride point the API and the OAuth endpoints
+ // somewhere else, which is how tests keep away from Fiken.
+ ClientOptions []fiken.Option `json:"-"`
+ AuthOverride func(*fikenauth.Config) `json:"-"`
+ Now func() time.Time `json:"-"`
+
+ dir string
+}
+
+// App is the OAuth app registered in Fiken. One serves every account: any
+// Fiken user can grant it access to their companies.
+type App struct {
+ ClientID string `json:"client_id,omitempty"`
+ ClientSecret string `json:"client_secret,omitempty"`
+ RedirectURL string `json:"redirect_url,omitempty"`
+}
+
+// Account is one Fiken login, keyed by its email in Config.Accounts.
+type Account struct {
+ Name string `json:"name,omitempty"`
+
+ // Companies is what the login reached when it was last asked. A company
+ // added in Fiken since is found by asking again (Refresh), which Credential
+ // does by itself when no account has the company it wants.
+ Companies []fiken.Company `json:"companies"`
+
+ // Token is a personal API token. Without one the account is an OAuth grant,
+ // kept in its own file beside this one.
+ Token string `json:"token,omitempty"`
+}
+
+// Reaches reports whether the account can act for the company.
+func (a *Account) Reaches(slug string) bool {
+ for _, c := range a.Companies {
+ if c.Slug == slug {
+ return true
+ }
+ }
+ return false
+}
+
+// Dir is where the shared configuration lives: $FIKEN_CONFIG_DIR, or fiken
+// under $XDG_CONFIG_HOME, or ~/.config/fiken. Not os.UserConfigDir, which on a
+// Mac is a directory nobody looks in for a file they might edit.
+func Dir() (string, error) {
+ if dir := strings.TrimSpace(os.Getenv(DirEnv)); dir != "" {
+ return dir, nil
+ }
+ if base := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); base != "" {
+ return filepath.Join(base, "fiken"), nil
+ }
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("cannot tell where the Fiken configuration is: set %s", DirEnv)
+ }
+ return filepath.Join(home, ".config", "fiken"), nil
+}
+
+// Open reads the configuration in dir, or in Dir when dir is empty. One that
+// does not exist yet is empty, not an error: nobody has logged in.
+func Open(dir string) (*Config, error) {
+ if dir == "" {
+ var err error
+ if dir, err = Dir(); err != nil {
+ return nil, err
+ }
+ }
+ c := &Config{dir: dir}
+ path := c.Path()
+
+ info, err := os.Stat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return c, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
+ if info.Size() > maxConfigBytes {
+ return nil, fmt.Errorf("%s is %d bytes, which is not a Fiken configuration", path, info.Size())
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
+ if err := json.Unmarshal(raw, c); err != nil {
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
+ return c, nil
+}
+
+// Path is the configuration file.
+func (c *Config) Path() string { return filepath.Join(c.dir, configFile) }
+
+// Save writes the configuration, readable only by its owner because it holds
+// the app's secret and any personal tokens. It is written to a temporary name
+// and renamed, so an interrupted write leaves the old one whole.
+func (c *Config) Save() error {
+ if err := os.MkdirAll(c.dir, 0o700); err != nil {
+ return fmt.Errorf("create %s: %w", c.dir, err)
+ }
+ // Map keys are sorted by encoding/json, so the file is stable across saves.
+ raw, err := json.MarshalIndent(c, "", " ")
+ if err != nil {
+ return fmt.Errorf("encode %s: %w", c.Path(), err)
+ }
+ temp, err := os.CreateTemp(c.dir, ".config-*")
+ if err != nil {
+ return fmt.Errorf("write %s: %w", c.Path(), err)
+ }
+ defer os.Remove(temp.Name()) //nolint:errcheck // already renamed on success
+
+ if err := temp.Chmod(0o600); err != nil {
+ temp.Close()
+ return fmt.Errorf("write %s: %w", c.Path(), err)
+ }
+ if _, err := temp.Write(append(raw, '\n')); err != nil {
+ temp.Close()
+ return fmt.Errorf("write %s: %w", c.Path(), err)
+ }
+ if err := temp.Close(); err != nil {
+ return fmt.Errorf("write %s: %w", c.Path(), err)
+ }
+ if err := os.Rename(temp.Name(), c.Path()); err != nil {
+ return fmt.Errorf("write %s: %w", c.Path(), err)
+ }
+ return nil
+}
+
+// Auth is the OAuth app, with $FIKEN_CLIENT_ID and $FIKEN_CLIENT_SECRET taking
+// precedence over the file.
+func (c *Config) Auth() fikenauth.Config {
+ cfg := fikenauth.Config{
+ ClientID: pick(os.Getenv(ClientIDEnv), c.App.ClientID),
+ ClientSecret: pick(os.Getenv(ClientSecretEnv), c.App.ClientSecret),
+ RedirectURL: strings.TrimSpace(c.App.RedirectURL),
+ Now: c.Now,
+ }
+ if c.AuthOverride != nil {
+ c.AuthOverride(&cfg)
+ }
+ return cfg
+}
+
+// CheckApp reports what is missing from the OAuth app, and where it goes.
+func (c *Config) CheckApp() error {
+ if err := c.Auth().Validate(); err != nil {
+ return fmt.Errorf("%w. It goes under \"app\" in %s, or in $%s and $%s",
+ err, c.Path(), ClientIDEnv, ClientSecretEnv)
+ }
+ return nil
+}
+
+// Source is the token source for an OAuth account's grant.
+func (c *Config) Source(email string) (*fikenauth.Source, error) {
+ path, err := c.grantPath(email)
+ if err != nil {
+ return nil, err
+ }
+ source := fikenauth.NewSource(c.Auth(), fikenauth.NewStore(path))
+ source.LoginCommand = c.LoginCommand
+ return source, nil
+}
+
+func (c *Config) grantPath(email string) (string, error) {
+ if email == "" || email != filepath.Base(email) || strings.HasPrefix(email, ".") {
+ return "", fmt.Errorf("%q cannot name a Fiken account's file", email)
+ }
+ return filepath.Join(c.dir, accountsDir, email+".json"), nil
+}
+
+func (c *Config) credential(email string) (fiken.TokenSource, error) {
+ if a := c.Accounts[email]; a != nil && a.Token != "" {
+ return fiken.StaticToken(a.Token), nil
+ }
+ return c.Source(email)
+}
+
+// Reach is a company and the account that reaches it.
+type Reach struct {
+ Company fiken.Company
+ Account string
+}
+
+// Companies is every company some account reaches, by slug. A company two
+// accounts reach is listed once, under the first account by email, which is
+// the one Credential uses.
+func (c *Config) Companies() []Reach {
+ var out []Reach
+ seen := map[string]bool{}
+ for _, email := range c.emails() {
+ for _, co := range c.Accounts[email].Companies {
+ if !seen[co.Slug] {
+ seen[co.Slug] = true
+ out = append(out, Reach{Company: co, Account: email})
+ }
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Company.Slug < out[j].Company.Slug })
+ return out
+}
+
+func (c *Config) emails() []string {
+ emails := make([]string, 0, len(c.Accounts))
+ for email := range c.Accounts {
+ emails = append(emails, email)
+ }
+ sort.Strings(emails)
+ return emails
+}
+
+// AccountFor is the account that reaches company, by what each account
+// reached when last asked.
+func (c *Config) AccountFor(company string) (string, bool) {
+ for _, email := range c.emails() {
+ if c.Accounts[email].Reaches(company) {
+ return email, true
+ }
+ }
+ return "", false
+}
+
+// Credential is the token source for an account reaching company. When none
+// does, every account is asked again first, in case the company was added in
+// Fiken since.
+func (c *Config) Credential(ctx context.Context, company string) (fiken.TokenSource, error) {
+ email, ok := c.AccountFor(company)
+ if !ok && len(c.Accounts) > 0 {
+ if err := c.Refresh(ctx); err != nil {
+ return nil, err
+ }
+ email, ok = c.AccountFor(company)
+ }
+ if !ok {
+ return nil, c.noAccount(company)
+ }
+ return c.credential(email)
+}
+
+// Client is a Fiken client acting as the account that reaches company.
+func (c *Config) Client(ctx context.Context, company string) (*fiken.Client, error) {
+ cred, err := c.Credential(ctx, company)
+ if err != nil {
+ return nil, err
+ }
+ return fiken.NewWithTokens(cred, c.ClientOptions...)
+}
+
+func (c *Config) noAccount(company string) error {
+ fix := "log in to Fiken as a user who can reach it"
+ if c.LoginCommand != "" {
+ fix = "run `" + c.LoginCommand + "` as a Fiken user who can reach it"
+ }
+ if len(c.Accounts) == 0 {
+ return fmt.Errorf("company %q: nobody is logged in to Fiken in %s; %s: %w",
+ company, c.dir, fix, ErrNoAccount)
+ }
+ var slugs []string
+ for _, r := range c.Companies() {
+ slugs = append(slugs, r.Company.Slug)
+ }
+ return fmt.Errorf("company %q: the accounts in %s reach %s; %s: %w",
+ company, c.dir, strings.Join(slugs, ", "), fix, ErrNoAccount)
+}
+
+// Refresh asks every account which companies it reaches now, and saves.
+func (c *Config) Refresh(ctx context.Context) error {
+ for _, email := range c.emails() {
+ cred, err := c.credential(email)
+ if err != nil {
+ return err
+ }
+ companies, err := c.companies(ctx, cred)
+ if err != nil {
+ return fmt.Errorf("Fiken account %s: %w", email, err)
+ }
+ c.Accounts[email].Companies = companies
+ }
+ return c.Save()
+}
+
+func (c *Config) companies(ctx context.Context, cred fiken.TokenSource) ([]fiken.Company, error) {
+ client, err := fiken.NewWithTokens(cred, c.ClientOptions...)
+ if err != nil {
+ return nil, err
+ }
+ companies, err := client.Companies(ctx)
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(companies, func(i, j int) bool { return companies[i].Slug < companies[j].Slug })
+ return companies, nil
+}
+
+// AddGrant records the grant a login produced, under the email of the Fiken
+// user who gave it, and returns that email. Logging in again as the same user
+// replaces their grant.
+func (c *Config) AddGrant(ctx context.Context, grant fikenauth.Token) (string, error) {
+ email, account, err := c.whoIs(ctx, fiken.StaticToken(grant.AccessToken))
+ if err != nil {
+ return "", err
+ }
+ path, err := c.grantPath(email)
+ if err != nil {
+ return "", err
+ }
+ if err := fikenauth.NewStore(path).Save(grant); err != nil {
+ return "", err
+ }
+ return email, c.put(email, account)
+}
+
+// AddToken records a personal API token, under the email of the Fiken user it
+// belongs to, and returns that email.
+func (c *Config) AddToken(ctx context.Context, token string) (string, error) {
+ token = strings.TrimSpace(token)
+ email, account, err := c.whoIs(ctx, fiken.StaticToken(token))
+ if err != nil {
+ return "", err
+ }
+ account.Token = token
+ return email, c.put(email, account)
+}
+
+func (c *Config) whoIs(ctx context.Context, cred fiken.TokenSource) (string, *Account, error) {
+ client, err := fiken.NewWithTokens(cred, c.ClientOptions...)
+ if err != nil {
+ return "", nil, err
+ }
+ user, err := client.User(ctx)
+ if err != nil {
+ return "", nil, err
+ }
+ email := strings.ToLower(strings.TrimSpace(user.Email))
+ if email == "" {
+ return "", nil, errors.New("fiken did not say which user the login belongs to")
+ }
+ companies, err := c.companies(ctx, cred)
+ if err != nil {
+ return "", nil, err
+ }
+ return email, &Account{Name: user.Name, Companies: companies}, nil
+}
+
+func (c *Config) put(email string, account *Account) error {
+ if c.Accounts == nil {
+ c.Accounts = map[string]*Account{}
+ }
+ c.Accounts[email] = account
+ return c.Save()
+}
+
+// Logout revokes an account's grant at Fiken and forgets the account, for
+// every program sharing this configuration. A personal token is only
+// forgotten: Fiken has nothing to revoke it with.
+func (c *Config) Logout(ctx context.Context, email string) error {
+ account := c.Accounts[email]
+ if account == nil {
+ return fmt.Errorf("no Fiken account %q in %s", email, c.dir)
+ }
+ var revokeErr error
+ if account.Token == "" {
+ source, err := c.Source(email)
+ if err != nil {
+ return err
+ }
+ if revokeErr = source.Logout(ctx); errors.Is(revokeErr, fikenauth.ErrNoToken) {
+ revokeErr = nil
+ }
+ }
+ delete(c.Accounts, email)
+ if err := c.Save(); err != nil {
+ return err
+ }
+ return revokeErr
+}
+
+func pick(values ...string) string {
+ for _, v := range values {
+ if v = strings.TrimSpace(v); v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/fikenconfig/config_test.go b/fikenconfig/config_test.go
new file mode 100644
index 0000000..2e89b7c
--- /dev/null
+++ b/fikenconfig/config_test.go
@@ -0,0 +1,213 @@
+package fikenconfig
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "simenandre.no/fiken/fiken"
+ "simenandre.no/fiken/fikenauth"
+)
+
+// fakeFiken answers /user and /companies per bearer token, so each token is a
+// different Fiken user.
+type fakeFiken struct {
+ users map[string]fiken.User
+ companies map[string][]fiken.Company
+ asked int
+}
+
+func (f *fakeFiken) start(t *testing.T) *httptest.Server {
+ t.Helper()
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
+ user, ok := f.users[token]
+ if !ok {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ switch r.URL.Path {
+ case "/user":
+ json.NewEncoder(w).Encode(user)
+ case "/companies":
+ f.asked++
+ w.Header().Set("Fiken-Api-Page-Count", "1")
+ json.NewEncoder(w).Encode(f.companies[token])
+ case "/oauth/revoke":
+ w.WriteHeader(http.StatusOK)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+ t.Cleanup(server.Close)
+ return server
+}
+
+func open(t *testing.T, server *httptest.Server, dir string) *Config {
+ t.Helper()
+ c, err := Open(dir)
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ c.LoginCommand = "tool login"
+ c.ClientOptions = []fiken.Option{fiken.WithBaseURL(server.URL)}
+ c.AuthOverride = func(a *fikenauth.Config) {
+ a.RevokeURL = server.URL + "/oauth/revoke"
+ a.TokenURL = server.URL + "/oauth/token"
+ }
+ return c
+}
+
+func twoUsers() *fakeFiken {
+ return &fakeFiken{
+ users: map[string]fiken.User{
+ "simen-token": {Name: "Simen", Email: "Simen@Sokkel.io"},
+ "bjerk-token": {Name: "Bjerk", Email: "post@bjerk.io"},
+ },
+ companies: map[string][]fiken.Company{
+ "simen-token": {{Name: "Sokkel AS", Slug: "sokkel-as"}},
+ "bjerk-token": {{Name: "Bjerk AS", Slug: "bjerk"}},
+ },
+ }
+}
+
+func TestEachCompanyGetsTheAccountThatReachesIt(t *testing.T) {
+ t.Setenv(ClientIDEnv, "")
+ t.Setenv(ClientSecretEnv, "")
+ fake := twoUsers()
+ server := fake.start(t)
+ dir := t.TempDir()
+
+ c := open(t, server, dir)
+ if _, err := c.AddToken(t.Context(), "bjerk-token"); err != nil {
+ t.Fatalf("AddToken: %v", err)
+ }
+ email, err := c.AddGrant(t.Context(), fikenauth.Token{
+ AccessToken: "simen-token", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour),
+ })
+ if err != nil {
+ t.Fatalf("AddGrant: %v", err)
+ }
+ if email != "simen@sokkel.io" {
+ t.Errorf("email = %q, want it lowercased", email)
+ }
+
+ // Another program, opening the same directory afterwards.
+ other := open(t, server, dir)
+ for company, want := range map[string]string{"sokkel-as": "simen@sokkel.io", "bjerk": "post@bjerk.io"} {
+ if got, _ := other.AccountFor(company); got != want {
+ t.Errorf("AccountFor(%s) = %q, want %q", company, got, want)
+ }
+ client, err := other.Client(t.Context(), company)
+ if err != nil {
+ t.Fatalf("Client(%s): %v", company, err)
+ }
+ if _, err := client.Companies(t.Context()); err != nil {
+ t.Errorf("Client(%s) could not reach Fiken: %v", company, err)
+ }
+ }
+
+ for _, path := range []string{other.Path(), filepath.Join(dir, "accounts", "simen@sokkel.io.json")} {
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if info.Mode().Perm() != 0o600 {
+ t.Errorf("%s is %v, want 0600", path, info.Mode().Perm())
+ }
+ }
+}
+
+func TestACompanyAddedSinceIsFoundByAskingAgain(t *testing.T) {
+ fake := twoUsers()
+ server := fake.start(t)
+ c := open(t, server, t.TempDir())
+ if _, err := c.AddToken(t.Context(), "simen-token"); err != nil {
+ t.Fatal(err)
+ }
+
+ fake.companies["simen-token"] = append(fake.companies["simen-token"],
+ fiken.Company{Name: "Ny AS", Slug: "ny-as"})
+ if _, err := c.Client(t.Context(), "ny-as"); err != nil {
+ t.Fatalf("Client(ny-as): %v", err)
+ }
+ reopened := open(t, server, c.dir)
+ if _, ok := reopened.AccountFor("ny-as"); !ok {
+ t.Error("the company found by asking again was not saved")
+ }
+
+ _, err := c.Client(t.Context(), "nobody-as")
+ if !errors.Is(err, ErrNoAccount) {
+ t.Fatalf("err = %v, want ErrNoAccount", err)
+ }
+ for _, want := range []string{"ny-as, sokkel-as", "`tool login`"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("error %q does not mention %q", err, want)
+ }
+ }
+}
+
+func TestNobodyLoggedIn(t *testing.T) {
+ c := open(t, twoUsers().start(t), t.TempDir())
+ _, err := c.Client(t.Context(), "sokkel-as")
+ if !errors.Is(err, ErrNoAccount) || !strings.Contains(err.Error(), "nobody is logged in") {
+ t.Errorf("err = %v", err)
+ }
+}
+
+func TestTheEnvironmentOverridesTheApp(t *testing.T) {
+ c := &Config{App: App{ClientID: "file-id", ClientSecret: "file-secret"}}
+ t.Setenv(ClientIDEnv, "")
+ t.Setenv(ClientSecretEnv, "env-secret")
+ got := c.Auth()
+ if got.ClientID != "file-id" || got.ClientSecret != "env-secret" {
+ t.Errorf("Auth = %q / %q", got.ClientID, got.ClientSecret)
+ }
+}
+
+func TestLogoutForgetsTheAccountAndItsGrant(t *testing.T) {
+ server := twoUsers().start(t)
+ c := open(t, server, t.TempDir())
+ email, err := c.AddGrant(t.Context(), fikenauth.Token{
+ AccessToken: "simen-token", RefreshToken: "r", ExpiresAt: time.Now().Add(time.Hour),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := c.Logout(t.Context(), email); err != nil {
+ t.Fatalf("Logout: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(c.dir, "accounts", email+".json")); !os.IsNotExist(err) {
+ t.Errorf("the grant file is still there: %v", err)
+ }
+ if _, ok := open(t, server, c.dir).AccountFor("sokkel-as"); ok {
+ t.Error("the account is still configured")
+ }
+}
+
+func TestAnEmailCannotEscapeTheAccountsDirectory(t *testing.T) {
+ c := &Config{dir: t.TempDir()}
+ for _, email := range []string{"", "../x", "a/b", ".hidden"} {
+ if _, err := c.Source(email); err == nil {
+ t.Errorf("Source(%q) was accepted", email)
+ }
+ }
+}
+
+func TestDir(t *testing.T) {
+ t.Setenv(DirEnv, "")
+ t.Setenv("XDG_CONFIG_HOME", "/xdg")
+ if got, _ := Dir(); got != "/xdg/fiken" {
+ t.Errorf("Dir = %q", got)
+ }
+ t.Setenv(DirEnv, "/explicit")
+ if got, _ := Dir(); got != "/explicit" {
+ t.Errorf("Dir = %q", got)
+ }
+}