From 1bfada68df0fe49c5b04caf7777d858d0cffb261 Mon Sep 17 00:00:00 2001 From: Dustin Stiles Date: Fri, 21 Mar 2025 20:23:15 -0400 Subject: [PATCH] refactor(project): add external abstractions --- cmd/dsfxctl/client/app.go | 143 ++++++++++++++++++ cmd/dsfxctl/conf/conf.go | 34 +++++ cmd/dsfxctl/main.go | 136 +---------------- cmd/dsfxnode/conf/conf.go | 42 ++++++ cmd/dsfxnode/main.go | 200 ++---------------------- cmd/dsfxnode/node/node.go | 243 ++++++++++++++++++++++++++++++ pkg/disk/default.go | 64 ++++++++ pkg/disk/default_test.go | 57 +++++++ pkg/disk/disk.go | 137 +++++++++++++++++ pkg/storage/scoped/scoped.go | 66 ++++++++ pkg/storage/scoped/scoped_test.go | 103 +++++++++++++ pkg/system/default.go | 85 +++++++++++ pkg/system/system.go | 30 ++++ 13 files changed, 1026 insertions(+), 314 deletions(-) create mode 100644 cmd/dsfxctl/client/app.go create mode 100644 cmd/dsfxctl/conf/conf.go create mode 100644 cmd/dsfxnode/conf/conf.go create mode 100644 cmd/dsfxnode/node/node.go create mode 100644 pkg/disk/default.go create mode 100644 pkg/disk/default_test.go create mode 100644 pkg/disk/disk.go create mode 100644 pkg/storage/scoped/scoped.go create mode 100644 pkg/storage/scoped/scoped_test.go create mode 100644 pkg/system/default.go create mode 100644 pkg/system/system.go diff --git a/cmd/dsfxctl/client/app.go b/cmd/dsfxctl/client/app.go new file mode 100644 index 0000000..44d3728 --- /dev/null +++ b/cmd/dsfxctl/client/app.go @@ -0,0 +1,143 @@ +package client + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "errors" + "log/slog" + "net" + + "koti.casa/numenor-labs/dsfx/cmd/dsfxctl/conf" + "koti.casa/numenor-labs/dsfx/pkg/crypto/identity" + "koti.casa/numenor-labs/dsfx/pkg/disk" + "koti.casa/numenor-labs/dsfx/pkg/logging" + "koti.casa/numenor-labs/dsfx/pkg/network" + "koti.casa/numenor-labs/dsfx/pkg/storage/scoped" + "koti.casa/numenor-labs/dsfx/pkg/system" +) + +// Client represents the client application for dsfxctl. +type Client struct { + // resources + disk disk.Disk + system system.System + // configuration + conf conf.Conf + // storage scopes + configScope disk.Disk +} + +// New creates a new Client instance with the provided disk, system, and +// configuration. +func New(disk disk.Disk, system system.System) *Client { + conf := conf.FromSystem(system) + + return &Client{ + // resources + disk: disk, + system: system, + // configuration + conf: conf, + // storage scopes + configScope: scoped.New(disk, conf.ConfigDir), + } +} + +// Run executes the main logic of the application. +func (a *Client) Run(ctx context.Context) error { + // --------------------------------------------------------------------------- + // Logger + + opts := &slog.HandlerOptions{ + AddSource: false, + Level: slog.LevelDebug, + } + logger := slog.New(slog.NewTextHandler(a.system.Stdout(), opts)) + + // Everything in the application will attempt to use the logger in stored in + // the context, but we also set the default with slog as a fallback. In cases + // where the context is not available, or the context is not a child of the + // context with the logger, the default logger will be used. + slog.SetDefault(logger) + ctx = logging.WithContext(ctx, logger) + + keyFile, err := a.configScope.Open("key") + if err != nil { + logger.WarnContext(ctx, "key file is missing, reinitializing") + logger.WarnContext(ctx, "if this is your first time running dsfxctl, you can ignore this") + } + if keyFile == nil { + logger.InfoContext(ctx, "generating new key") + keyFile, err = a.configScope.Create("key") + if err != nil { + logger.ErrorContext(ctx, "failed to create key file", slog.Any("error", err)) + return err + } + privkey, err := identity.Generate() + if err != nil { + logger.ErrorContext(ctx, "failed to generate key", slog.Any("error", err)) + return err + } + + _, err = keyFile.Write([]byte(base64.StdEncoding.EncodeToString(privkey))) + if err != nil { + logger.ErrorContext(ctx, "failed to write key", slog.Any("error", err)) + return err + } + } + defer keyFile.Close() + + keyRaw := make([]byte, ed25519.PrivateKeySize) + n, err := keyFile.Read(keyRaw) + if err != nil { + logger.ErrorContext(ctx, "failed to read key file", slog.Any("error", err)) + return err + } + if n != ed25519.PrivateKeySize { + logger.ErrorContext(ctx, "key file is not the correct size", slog.Int("size", n)) + return err + } + id := ed25519.PrivateKey(keyRaw) + + laddr := network.NewAddr( + net.ParseIP("0.0.0.0"), + 0, // port 0 means any available port + identity.ToPublicKey(id), + ) + + logger.DebugContext(ctx, "using addr", slog.String("address", laddr.String())) + + switch a.system.Arg(0) { + case "test": + raddrRaw := a.system.Arg(1) + if raddrRaw == "" { + logger.ErrorContext(ctx, "no remote address provided") + return err + } + testConnection(ctx, id, laddr, raddrRaw) + case "": + return errors.New("no command provided") + default: + return errors.New("unknown command: " + a.system.Arg(0)) + } + + return nil +} + +func testConnection(ctx context.Context, id ed25519.PrivateKey, laddr *network.Addr, raddrRaw string) { + logger := logging.FromContext(context.Background()) + + raddr, err := network.ParseAddr(raddrRaw) + if err != nil { + logger.ErrorContext(ctx, "failed to parse server address", slog.Any("error", err)) + return + } + + conn, err := network.Dial(ctx, id, laddr, raddr) + if err != nil { + logger.ErrorContext(ctx, "failed to connect", slog.Any("error", err)) + return + } + defer conn.Close() +} diff --git a/cmd/dsfxctl/conf/conf.go b/cmd/dsfxctl/conf/conf.go new file mode 100644 index 0000000..f95748b --- /dev/null +++ b/cmd/dsfxctl/conf/conf.go @@ -0,0 +1,34 @@ +package conf + +import "koti.casa/numenor-labs/dsfx/pkg/system" + +const ( + // DefaultConfigDir is the default directory for the dsfxctl configuration. + DefaultConfigDir = "/etc/dsfxctl" + // DefaultHost is the default host for the dsfxctl application. + DefaultHost = "0.0.0.0" +) + +// Conf holds the configuration for the dsfxctl application. +type Conf struct { + // Directories + ConfigDir string + // Networking + Host string +} + +func FromSystem(sys system.System) Conf { + var c Conf + + c.ConfigDir = sys.GetEnv("DSFXCTL_CONFIG_DIR") + if c.ConfigDir == "" { + c.ConfigDir = DefaultConfigDir + } + + c.Host = sys.GetEnv("DSFXCTL_HOST") + if c.Host == "" { + c.Host = DefaultHost + } + + return c +} diff --git a/cmd/dsfxctl/main.go b/cmd/dsfxctl/main.go index abdbf14..25b0d83 100644 --- a/cmd/dsfxctl/main.go +++ b/cmd/dsfxctl/main.go @@ -2,141 +2,17 @@ package main import ( "context" - "crypto/ed25519" - "encoding/base64" - "errors" - "flag" - "fmt" - "log/slog" - "net" - "os" - "path/filepath" - "koti.casa/numenor-labs/dsfx/pkg/crypto/identity" - "koti.casa/numenor-labs/dsfx/pkg/logging" - "koti.casa/numenor-labs/dsfx/pkg/network" + "koti.casa/numenor-labs/dsfx/cmd/dsfxctl/client" + "koti.casa/numenor-labs/dsfx/pkg/disk" + "koti.casa/numenor-labs/dsfx/pkg/system" ) func main() { - ctx := context.Background() + c := client.New(disk.Default(), system.Default()) - // --------------------------------------------------------------------------- - // Logger - - opts := &slog.HandlerOptions{ - AddSource: false, - Level: slog.LevelDebug, - } - logger := slog.New(slog.NewTextHandler(os.Stdout, opts)) - - // Everything in the application will attempt to use the logger in stored in - // the context, but we also set the default with slog as a fallback. In cases - // where the context is not available, or the context is not a child of the - // context with the logger, the default logger will be used. - slog.SetDefault(logger) - ctx = logging.WithContext(ctx, logger) - - homedir, err := os.UserHomeDir() + err := c.Run(context.Background()) if err != nil { - logger.ErrorContext(ctx, "failed to get home directory", slog.Any("error", err)) - os.Exit(1) - } - - flagDir := flag.String("dir", filepath.Join(homedir, ".dsfxctl"), "data directory") - - err = os.Mkdir(*flagDir, 0777) - if errors.Is(err, os.ErrExist) { - // If the directory already exists, we can ignore the error. - err = nil - } - if err != nil { - logger.ErrorContext(ctx, "failed to create directory", slog.Any("error", err)) - os.Exit(1) - } - - keyFile, err := os.Open(filepath.Join(*flagDir, "key")) - if err != nil { - logger.WarnContext(ctx, "key file is missing, reinitializing") - logger.WarnContext(ctx, "if this is your first time running dsfxctl, you can ignore this") - } - if keyFile == nil { - logger.InfoContext(ctx, "generating new key") - keyFile, err = os.Create(filepath.Join(*flagDir, "key")) - if err != nil { - logger.ErrorContext(ctx, "failed to create key file", slog.Any("error", err)) - os.Exit(1) - } - privkey, err := identity.Generate() - if err != nil { - logger.ErrorContext(ctx, "failed to generate key", slog.Any("error", err)) - os.Exit(1) - } - - _, err = keyFile.Write([]byte(base64.StdEncoding.EncodeToString(privkey))) - if err != nil { - logger.ErrorContext(ctx, "failed to write key", slog.Any("error", err)) - os.Exit(1) - } - } - defer keyFile.Close() - - // --------------------------------------------------------------------------- - // Commands - - flag.Parse() - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: dsfxctl [command] [args]\n") - fmt.Fprintf(os.Stderr, "Commands:\n") - fmt.Fprintf(os.Stderr, " test Test the connection to the server\n") - fmt.Fprintf(os.Stderr, "Flags:\n") - flag.PrintDefaults() - } - - id, err := identity.LoadSigningKeyFromFile(filepath.Join(*flagDir, "key")) - if err != nil { - logger.ErrorContext(ctx, "failed to load private key", slog.Any("error", err)) - os.Exit(1) - } - - laddr := network.NewAddr( - net.ParseIP("0.0.0.0"), - 0, // port 0 means any available port - identity.ToPublicKey(id), - ) - - logger.DebugContext(ctx, "using addr", slog.String("address", laddr.String())) - - switch flag.Arg(0) { - case "test": - raddrRaw := flag.Arg(1) - if raddrRaw == "" { - logger.ErrorContext(ctx, "no remote address provided") - os.Exit(1) - } - testConnection(ctx, id, laddr, raddrRaw) - case "": - logger.InfoContext(ctx, "no command provided") - os.Exit(1) - default: - logger.InfoContext(ctx, "unknown command") - os.Exit(1) + panic(err) } } - -func testConnection(ctx context.Context, id ed25519.PrivateKey, laddr *network.Addr, raddrRaw string) { - logger := logging.FromContext(context.Background()) - - raddr, err := network.ParseAddr(raddrRaw) - if err != nil { - logger.ErrorContext(ctx, "failed to parse server address", slog.Any("error", err)) - return - } - - conn, err := network.Dial(ctx, id, laddr, raddr) - if err != nil { - logger.ErrorContext(ctx, "failed to connect", slog.Any("error", err)) - return - } - defer conn.Close() -} diff --git a/cmd/dsfxnode/conf/conf.go b/cmd/dsfxnode/conf/conf.go new file mode 100644 index 0000000..087d495 --- /dev/null +++ b/cmd/dsfxnode/conf/conf.go @@ -0,0 +1,42 @@ +package conf + +import "koti.casa/numenor-labs/dsfx/pkg/system" + +const ( + // DefaultConfigDir is the default directory for the dsfxctl configuration. + DefaultConfigDir = "/etc/dsfxnode/config" + // DefaultHost is the default host for the dsfxctl application. + DefaultHost = "0.0.0.0" + // DefaultPort is the default port for the dsfxctl application. + DefaultPort = "8000" +) + +// Conf holds the configuration for the dsfxctl application. +type Conf struct { + // Directories + ConfigDir string + // Networking + Host string + Port string +} + +func FromSystem(sys system.System) Conf { + var c Conf + + c.ConfigDir = sys.GetEnv("DSFXCTL_CONFIG_DIR") + if c.ConfigDir == "" { + c.ConfigDir = DefaultConfigDir + } + + c.Host = sys.GetEnv("DSFXCTL_HOST") + if c.Host == "" { + c.Host = DefaultHost + } + + c.Port = sys.GetEnv("DSFXCTL_PORT") + if c.Port == "" { + c.Port = DefaultPort + } + + return c +} diff --git a/cmd/dsfxnode/main.go b/cmd/dsfxnode/main.go index cb91ccb..490b7d1 100644 --- a/cmd/dsfxnode/main.go +++ b/cmd/dsfxnode/main.go @@ -2,197 +2,29 @@ package main import ( "context" - "encoding/base64" - "errors" - "flag" - "fmt" "log/slog" - "net" - "os" - "path/filepath" - "runtime" - "strings" - "koti.casa/numenor-labs/dsfx/pkg/crypto/identity" - "koti.casa/numenor-labs/dsfx/pkg/logging" - "koti.casa/numenor-labs/dsfx/pkg/network" + "koti.casa/numenor-labs/dsfx/cmd/dsfxnode/node" + "koti.casa/numenor-labs/dsfx/pkg/disk" + "koti.casa/numenor-labs/dsfx/pkg/storage/scoped" + "koti.casa/numenor-labs/dsfx/pkg/system" ) -var () - func main() { ctx := context.Background() - // --------------------------------------------------------------------------- - // Logger - homedir, err := os.UserHomeDir() + sys := system.Default() + + configDir := sys.GetEnv("DSFXNODE_CONFIG_DIR") + if configDir == "" { + configDir = "/etc/dsfxnode/config" + } + configScope := scoped.New(disk.Default(), configDir) + + err := node.New(configScope, sys).Run(ctx) if err != nil { - logUserMsg("error: failed to get home directory\n") - logUserMsg("error: are you on a real computer?\n") - // Look up why this function might fail... - os.Exit(1) - } - - flagHost := flag.String("host", "localhost", "the host to listen on") - flagPort := flag.Int("port", 8000, "the port to listen on") - flagDir := flag.String("dir", filepath.Join(homedir, ".dsfxnode"), "the directory to store data in") - flagLogFile := flag.String("log", "logs", "the file to write logs to, relative to -dir") - flag.Parse() - - dataDir := *flagDir - - err = os.Mkdir(dataDir, 0777) - if errors.Is(err, os.ErrExist) { - // If the directory already exists, we can ignore the error. - err = nil - } - if err != nil { - logUserMsg("error: failed to create the data directory: %v\n", err) - os.Exit(1) - } - - var logFile *os.File - if *flagLogFile == "stdout" { - logFile = os.Stdout - } else { - logFilePath := filepath.Join(dataDir, *flagLogFile) - logFile, err = os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) - if err != nil { - logUserMsg("warn: log file missing, making a new one: %s\n", logFilePath) - logFile, err = os.Create(logFilePath) - if err != nil { - logUserMsg("error: failed to create log file: %v\n", err) - os.Exit(1) - } - } - } - defer logFile.Close() - - opts := &slog.HandlerOptions{ - AddSource: false, - Level: slog.LevelDebug, - } - logger := slog.New(slog.NewTextHandler(logFile, opts)) - - // Everything in the application will attempt to use the logger in stored in - // the context, but we also set the default with slog as a fallback. In cases - // where the context is not available, or the context is not a child of the - // context with the logger, the default logger will be used. - slog.SetDefault(logger) - ctx = logging.WithContext(ctx, logger) - - keyFile, err := os.Open(filepath.Join(dataDir, "key")) - if err != nil { - logUserMsg("warn: key file is missing, making a new one\n") - logUserMsg("warn: if this is your first time running dsfxctl, you can ignore this\n") - - keyFile, err = os.Create(filepath.Join(dataDir, "key")) - if err != nil { - logUserMsg("error: failed to create key file: %v\n", err) - os.Exit(1) - } - privkey, err := identity.Generate() - if err != nil { - logUserMsg("error: failed to generate key: %v\n", err) - os.Exit(1) - } - - _, err = keyFile.Write([]byte(base64.StdEncoding.EncodeToString(privkey))) - if err != nil { - logUserMsg("error: failed to write key: %v\n", err) - os.Exit(1) - } - } - defer keyFile.Close() - - // --------------------------------------------------------------------------- - // Flags - - masterKey, err := identity.LoadSigningKeyFromFile(filepath.Join(dataDir, "key")) - if err != nil { - logUserMsg("error: failed to load key: %v\n", err) - os.Exit(1) - } - - var admins []string - adminsFile, err := os.ReadFile(filepath.Join(dataDir, "admins")) - if err != nil { - logUserMsg("warn: failed to read admins file.. no one will be able to control this server..\n") - logUserMsg(` - If you aren't sure what this means, here's a brief explanation: - The admins file is a list of public keys that are allowed to control - the server. The base are base64 encoded and separated by newlines. - The application looks for this file at /admins, where is - the directory you specified with the -dir flag when starting the - system. By default, this is ~/.dsfxnode. - - When you run the dsfxctl command, it will generate a key for you and - print it to the console. You can copy this key and paste it into the - admins file to give yourself control over the server. - `) - logUserMsg("\n") - } - if adminsFile != nil { - rawAdmins := strings.Split(string(adminsFile), "\n") - for _, admin := range rawAdmins { - if admin != "" { - admins = append(admins, admin) - } - } - logUserMsg("info: loaded admins: %v\n", admins) - } - - tcpAddrRaw := net.JoinHostPort(*flagHost, fmt.Sprint(*flagPort)) - tcpAddr, err := net.ResolveTCPAddr("tcp", tcpAddrRaw) - if err != nil { - logUserMsg("warn: invalid host or port: %v\n", err) - os.Exit(1) - } - addr := network.FromTCPAddr(tcpAddr, identity.ToPublicKey(masterKey)) - - // --------------------------------------------------------------------------- - // Listener - - listener, err := network.Listen(ctx, masterKey, addr) - if err != nil { - logger.ErrorContext(ctx, "listener error", slog.Any("error", err)) - os.Exit(1) - } - - logUserMsg("info: listener created\n") - logUserMsg(">> Operating System: %s\n", runtime.GOOS) - logUserMsg(">> Architecture: %s\n", runtime.GOARCH) - logUserMsg(">> CPU Cores: %d\n", runtime.NumCPU()) - logUserMsg(">> Public Address: %s\n", addr.String()) - - for { - conn, err := listener.Accept() - if err != nil { - logger.ErrorContext(ctx, "accept failure", slog.Any("error", err)) - continue - } - - go handleConnection(ctx, conn) + // Log the error and exit with a non-zero status code. + slog.Error("Error running dsfxnode", slog.Any("error", err)) + sys.Exit(1) } } - -func handleConnection(ctx context.Context, conn net.Conn) error { - defer conn.Close() - - logger := logging.FromContext(ctx) - - msg := make([]byte, 1024) - n, err := conn.Read(msg) - if err != nil { - logger.ErrorContext(ctx, "failed to read from connection", slog.Any("error", err)) - return err - } - - logger.InfoContext(ctx, "received msg", slog.Int("bytes", n)) - - return nil -} - -func logUserMsg(msg string, args ...any) { - fmt.Fprintf(os.Stderr, msg, args...) -} diff --git a/cmd/dsfxnode/node/node.go b/cmd/dsfxnode/node/node.go new file mode 100644 index 0000000..bb02da6 --- /dev/null +++ b/cmd/dsfxnode/node/node.go @@ -0,0 +1,243 @@ +package node + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "fmt" + "log/slog" + "net" + "os" + "strings" + + "koti.casa/numenor-labs/dsfx/cmd/dsfxnode/conf" + "koti.casa/numenor-labs/dsfx/pkg/crypto/identity" + "koti.casa/numenor-labs/dsfx/pkg/disk" + "koti.casa/numenor-labs/dsfx/pkg/logging" + "koti.casa/numenor-labs/dsfx/pkg/network" + "koti.casa/numenor-labs/dsfx/pkg/system" +) + +type Node struct { + config disk.Disk + system system.System + conf conf.Conf +} + +func New(config disk.Disk, system system.System) *Node { + conf := conf.FromSystem(system) + return &Node{config, system, conf} +} + +func (a *Node) Run(ctx context.Context) error { + opts := &slog.HandlerOptions{ + AddSource: false, + Level: slog.LevelDebug, + } + logger := slog.New(slog.NewJSONHandler(a.system.Stdout(), opts)) + + // Everything in the application will attempt to use the logger in stored in + // the context, but we also set the default with slog as a fallback. In cases + // where the context is not available, or the context is not a child of the + // context with the logger, the default logger will be used. + slog.SetDefault(logger) + ctx = logging.WithContext(ctx, logger) + + ki := &KeyInit{disk: a.config} + + // Check if the key file exists and is not empty + hasKey, err := ki.Has() + if err != nil { + logger.ErrorContext(ctx, "failed to check key file", slog.Any("error", err)) + return err + } + + if !hasKey { + logger.InfoContext(ctx, "key file does not exist or is empty, generating new key") + err = ki.Init() + if err != nil { + logger.ErrorContext(ctx, "failed to initialize key file", slog.Any("error", err)) + return err + } + } + + // Read the key file + id, err := ki.Read() + if err != nil { + logger.ErrorContext(ctx, "failed to read key file", slog.Any("error", err)) + return err + } + + admins := []string{} + ai := &AdminInit{disk: a.config} + + // Check if the admins file exists and is not empty + hasAdmins, err := ai.Has() + if err != nil { + logger.ErrorContext(ctx, "failed to check admins file", slog.Any("error", err)) + return err + } + + if hasAdmins { + logger.InfoContext(ctx, "admins file exists, reading admins") + admins, err = ai.Read() + if err != nil { + logger.ErrorContext(ctx, "failed to read admins file", slog.Any("error", err)) + return err + } + logger.InfoContext(ctx, "loaded admins", slog.Any("admins", admins)) + } else { + logger.WarnContext(ctx, "admins file does not exist or is empty, no admins will be loaded") + } + + tcpAddrRaw := net.JoinHostPort(a.conf.Host, a.conf.Port) + tcpAddr, err := net.ResolveTCPAddr("tcp", tcpAddrRaw) + if err != nil { + return err + } + addr := network.FromTCPAddr(tcpAddr, id.Public().(ed25519.PublicKey)) + + // --------------------------------------------------------------------------- + // Listener + + listener, err := network.Listen(ctx, id, addr) + if err != nil { + logger.ErrorContext(ctx, "listener error", slog.Any("error", err)) + return err + } + + for { + conn, err := listener.Accept() + if err != nil { + logger.ErrorContext(ctx, "accept failure", slog.Any("error", err)) + continue + } + + go handleConnection(ctx, conn) + } +} + +func handleConnection(ctx context.Context, conn net.Conn) error { + defer conn.Close() + + logger := logging.FromContext(ctx) + + msg := make([]byte, 1024) + n, err := conn.Read(msg) + if err != nil { + logger.ErrorContext(ctx, "failed to read from connection", slog.Any("error", err)) + return err + } + + logger.InfoContext(ctx, "received msg", slog.Int("bytes", n)) + + return nil +} + +type KeyInit struct{ disk disk.Disk } + +func (ki *KeyInit) Has() (bool, error) { + f, err := ki.disk.Open("key") + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + defer f.Close() + + stats, err := f.Stat() + if err != nil { + return false, err + } + + return stats.Size() > 0, nil +} + +func (ki *KeyInit) Init() error { + f, err := ki.disk.Create("key") + if err != nil { + return err + } + defer f.Close() + + privkey, err := identity.Generate() + if err != nil { + return err + } + + _, err = f.Write([]byte(base64.StdEncoding.EncodeToString(privkey))) + if err != nil { + return err + } + + return nil +} + +func (ki *KeyInit) Read() (ed25519.PrivateKey, error) { + f, err := ki.disk.Open("key") + if err != nil { + return nil, err + } + defer f.Close() + + keyRaw := make([]byte, ed25519.PrivateKeySize) + n, err := f.Read(keyRaw) + if err != nil { + return nil, err + } + if n != ed25519.PrivateKeySize { + return nil, fmt.Errorf("key file is not the correct size: %d", n) + } + + return ed25519.PrivateKey(keyRaw), nil +} + +type AdminInit struct { + disk disk.Disk +} + +func (ai *AdminInit) Has() (bool, error) { + f, err := ai.disk.Open("admins") + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, err + } + defer f.Close() + + stats, err := f.Stat() + if err != nil { + return false, err + } + + return stats.Size() > 0, nil +} + +func (ai *AdminInit) Read() ([]string, error) { + f, err := ai.disk.Open("admins") + if err != nil { + return nil, err + } + defer f.Close() + + adminsRaw := make([]byte, 0) + n, err := f.Read(adminsRaw) + if err != nil { + return nil, err + } + if n == 0 { + return nil, fmt.Errorf("admins file is empty") + } + + rawAdmins := strings.Split(string(adminsRaw), "\n") + var admins []string + for _, admin := range rawAdmins { + if admin != "" { + admins = append(admins, admin) + } + } + + return admins, nil +} diff --git a/pkg/disk/default.go b/pkg/disk/default.go new file mode 100644 index 0000000..006e96d --- /dev/null +++ b/pkg/disk/default.go @@ -0,0 +1,64 @@ +package disk + +import ( + "io/fs" + "log/slog" + "os" + "time" +) + +type osDisk struct { + logger *slog.Logger +} + +// Default returns a new instance of the defaultDisk type, which implements +// the Disk interface. This is the default implementation for file operations +// using the standard os package. +func Default() Disk { + return &osDisk{logger: slog.Default()} +} + +// Create implements Disk. +func (d *osDisk) Create(name string) (File, error) { + ts := time.Now() + file, err := os.Create(name) + el := time.Now().Sub(ts).String() + d.logger.Debug("(io) disk.Create", "duration", el) + return file, err +} + +// Mkdir implements Disk. +func (d *osDisk) Mkdir(name string, perm fs.FileMode) error { + ts := time.Now() + err := os.Mkdir(name, perm) + el := time.Now().Sub(ts).String() + d.logger.Debug("(io) disk.Mkdir", "duration", el) + return err +} + +// Open implements Disk. +func (d *osDisk) Open(name string) (File, error) { + ts := time.Now() + file, err := os.Open(name) + el := time.Now().Sub(ts).String() + d.logger.Debug("(io) disk.Open", "duration", el) + return file, err +} + +// Remove implements Disk. +func (d *osDisk) Remove(name string) error { + ts := time.Now() + err := os.Remove(name) + el := time.Now().Sub(ts).String() + d.logger.Debug("(io) disk.Remove", "duration", el) + return err +} + +// Stat implements Disk. +func (d *osDisk) Stat(name string) (fs.FileInfo, error) { + ts := time.Now() + info, err := os.Stat(name) + el := time.Now().Sub(ts).String() + d.logger.Debug("(io) disk.Stat", "duration", el) + return info, err +} diff --git a/pkg/disk/default_test.go b/pkg/disk/default_test.go new file mode 100644 index 0000000..7862530 --- /dev/null +++ b/pkg/disk/default_test.go @@ -0,0 +1,57 @@ +package disk_test + +import ( + "testing" + + "koti.casa/numenor-labs/dsfx/pkg/disk" +) + +func TestDefaultDisk(t *testing.T) { + // Create a new disk instance using the default implementation + d := disk.Default() + + // Test creating a directory + dirName := "testdir" + if err := d.Mkdir(dirName, 0755); err != nil { + t.Fatalf("Failed to create directory: %v", err) + } + + // Test creating a file + fileName := "testfile.txt" + file, err := d.Create(fileName) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + } + defer file.Close() + + // Test writing to the file + if _, err := file.Write([]byte("Hello, World!")); err != nil { + t.Fatalf("Failed to write to file: %v", err) + } + + // Test statting the file + info, err := d.Stat(fileName) + if err != nil { + t.Fatalf("Failed to stat file: %v", err) + } + if info.Name() != fileName { + t.Errorf("Expected file name %s, got %s", fileName, info.Name()) + } + + // Test opening the file + openFile, err := d.Open(fileName) + if err != nil { + t.Fatalf("Failed to open file: %v", err) + } + defer openFile.Close() + + // Test removing the file + if err := d.Remove(fileName); err != nil { + t.Fatalf("Failed to remove file: %v", err) + } + + // Test removing the directory + if err := d.Remove(dirName); err != nil { + t.Fatalf("Failed to remove directory: %v", err) + } +} diff --git a/pkg/disk/disk.go b/pkg/disk/disk.go new file mode 100644 index 0000000..d37ae47 --- /dev/null +++ b/pkg/disk/disk.go @@ -0,0 +1,137 @@ +package disk + +import ( + "io" + "io/fs" + "time" +) + +// File interface extends the fs.File interface to include io.Writer. +// This is because the standard fs package is modeled after a read-only File +// System, but we need to write to files as well. The standard os.File type will +// satisfy this interface, as it implements both fs.File and io.Writer. +type File interface { + fs.File + io.Writer +} + +// Disk interface defines the methods for a disk abstraction layer. This allows +// us to easily create mock disks for testing purposes, or to simulate different +// levels of failure and latency in a simuated environment. +type Disk interface { + // Mkdir creates a directory with the specified name and permissions. + Mkdir(name string, perm fs.FileMode) error + // Create creates a new file with the specified name and returns a File interface. + Create(name string) (File, error) + // Stat retrieves the file information for the specified name, returning + // fs.FileInfo and an error if any. + Stat(name string) (fs.FileInfo, error) + // Open opens an existing file with the specified name and returns a File interface. + Open(name string) (File, error) + // Remove deletes the file or directory with the specified name. + Remove(name string) error +} + +type MockDisk struct { + MkdirFunc func(name string, perm fs.FileMode) error + CreateFunc func(name string) (File, error) + StatFunc func(name string) (fs.FileInfo, error) + OpenFunc func(name string) (File, error) + RemoveFunc func(name string) error +} + +var _ Disk = &MockDisk{} + +func (t *MockDisk) Mkdir(name string, perm fs.FileMode) error { + if t.MkdirFunc != nil { + return t.MkdirFunc(name, perm) + } + return nil +} + +func (t *MockDisk) Create(name string) (File, error) { + if t.CreateFunc != nil { + return t.CreateFunc(name) + } + return nil, nil +} + +func (t *MockDisk) Stat(name string) (fs.FileInfo, error) { + if t.StatFunc != nil { + return t.StatFunc(name) + } + return nil, nil +} + +func (t *MockDisk) Open(name string) (File, error) { + if t.OpenFunc != nil { + return t.OpenFunc(name) + } + return nil, nil +} + +func (t *MockDisk) Remove(name string) error { + if t.RemoveFunc != nil { + return t.RemoveFunc(name) + } + return nil +} + +type MockFileInfo struct { + NameFunc func() string + SizeFunc func() int64 + IsDirFunc func() bool + ModeFunc func() fs.FileMode + ModTimeFunc func() fs.FileInfo + SysFunc func() any +} + +var _ fs.FileInfo = &MockFileInfo{} + +// IsDir implements fs.FileInfo. +func (t *MockFileInfo) IsDir() bool { + if t.IsDirFunc != nil { + return t.IsDirFunc() + } + return false +} + +// ModTime implements fs.FileInfo. +func (t *MockFileInfo) ModTime() time.Time { + if t.ModTimeFunc != nil { + return t.ModTimeFunc().ModTime() + } + return time.Time{} +} + +// Mode implements fs.FileInfo. +func (t *MockFileInfo) Mode() fs.FileMode { + if t.ModeFunc != nil { + return t.ModeFunc() + } + return 0 +} + +// Name implements fs.FileInfo. +func (t *MockFileInfo) Name() string { + if t.NameFunc != nil { + return t.NameFunc() + } + return "" +} + +// Size implements fs.FileInfo. +func (t *MockFileInfo) Size() int64 { + if t.SizeFunc != nil { + return t.SizeFunc() + } + return 0 +} + +// Sys implements fs.FileInfo. +func (t *MockFileInfo) Sys() any { + if t.SysFunc != nil { + return t.SysFunc() + } + return nil +} diff --git a/pkg/storage/scoped/scoped.go b/pkg/storage/scoped/scoped.go new file mode 100644 index 0000000..57dbdb7 --- /dev/null +++ b/pkg/storage/scoped/scoped.go @@ -0,0 +1,66 @@ +package scoped + +import ( + "io/fs" + "path/filepath" + + "koti.casa/numenor-labs/dsfx/pkg/disk" +) + +// StorageScope is an interface that extends the disk.Disk interface by ensuring +// that all file operations are limited to a specific folder on the disk, which +// provides a dedicated storage scope that is isolated from other file operations. +type StorageScope interface { + disk.Disk + + // Scope returns the storage scope as a string. + Scope() string +} + +// scoped is a concrete implementation of the StorageScope interface. +type scoped struct { + disk disk.Disk + scope string +} + +// New creates a new StorageScope with the specified scope. +func New(disk disk.Disk, scope string) StorageScope { + return &scoped{disk, scope} +} + +// Create implements disk.Disk. +func (s *scoped) Create(name string) (disk.File, error) { + return s.disk.Create(s.path(name)) +} + +// Mkdir implements disk.Disk. +func (s *scoped) Mkdir(name string, perm fs.FileMode) error { + return s.disk.Mkdir(s.path(name), perm) +} + +// Open implements disk.Disk. +func (s *scoped) Open(name string) (disk.File, error) { + return s.disk.Open(s.path(name)) +} + +// Remove implements disk.Disk. +func (s *scoped) Remove(name string) error { + return s.disk.Remove(s.path(name)) +} + +// Stat implements disk.Disk. +func (s *scoped) Stat(name string) (fs.FileInfo, error) { + return s.disk.Stat(s.path(name)) +} + +// Scope implements StorageScope. +func (s *scoped) Scope() string { + return s.scope +} + +// path is a helper method that returns the full path for a given path relative +// to the storage scope. This ensures that all file operations are performed +// within the defined scope. +func (s *scoped) path(path string) string { + return filepath.Join(s.scope, path) +} diff --git a/pkg/storage/scoped/scoped_test.go b/pkg/storage/scoped/scoped_test.go new file mode 100644 index 0000000..c0bce30 --- /dev/null +++ b/pkg/storage/scoped/scoped_test.go @@ -0,0 +1,103 @@ +package scoped_test + +import ( + "io/fs" + "testing" + + "koti.casa/numenor-labs/dsfx/pkg/disk" + "koti.casa/numenor-labs/dsfx/pkg/storage/scoped" +) + +func TestScopedStorage_Scope(t *testing.T) { + mockDisk := &disk.MockDisk{} + storage := scoped.New(mockDisk, "testscope") + + if scope := storage.Scope(); scope != "testscope" { + t.Errorf("expected 'testscope', got '%s'", scope) + } +} + +func TestScopedStorage_Mkdir(t *testing.T) { + mockDisk := &disk.MockDisk{ + MkdirFunc: func(name string, perm fs.FileMode) error { + if name != "testscope/testdir" { + t.Errorf("expected 'testscope/testdir', got '%s'", name) + } + return nil + }, + } + + storage := scoped.New(mockDisk, "testscope") + err := storage.Mkdir("testdir", 0755) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestScopedStorage_Create(t *testing.T) { + mockDisk := &disk.MockDisk{ + CreateFunc: func(name string) (disk.File, error) { + if name != "testscope/testfile" { + t.Errorf("expected 'testscope/testfile', got '%s'", name) + } + return nil, nil + }, + } + + storage := scoped.New(mockDisk, "testscope") + _, err := storage.Create("testfile") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestScopedStorage_Stat(t *testing.T) { + mockDisk := &disk.MockDisk{ + StatFunc: func(name string) (fs.FileInfo, error) { + if name != "testscope/testfile" { + t.Errorf("expected 'testscope/testfile', got '%s'", name) + } + return nil, nil + }, + } + + storage := scoped.New(mockDisk, "testscope") + _, err := storage.Stat("testfile") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestScopedStorage_Open(t *testing.T) { + mockDisk := &disk.MockDisk{ + OpenFunc: func(name string) (disk.File, error) { + if name != "testscope/testfile" { + t.Errorf("expected 'testscope/testfile', got '%s'", name) + } + return nil, nil + }, + } + + storage := scoped.New(mockDisk, "testscope") + _, err := storage.Open("testfile") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestScopedStorage_Remove(t *testing.T) { + mockDisk := &disk.MockDisk{ + RemoveFunc: func(name string) error { + if name != "testscope/testfile" { + t.Errorf("expected 'testscope/testfile', got '%s'", name) + } + return nil + }, + } + + storage := scoped.New(mockDisk, "testscope") + err := storage.Remove("testfile") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/system/default.go b/pkg/system/default.go new file mode 100644 index 0000000..8055ab0 --- /dev/null +++ b/pkg/system/default.go @@ -0,0 +1,85 @@ +package system + +import ( + "os" + + "koti.casa/numenor-labs/dsfx/pkg/disk" +) + +// Default returns a default implementation of the System interface. +func Default() System { + return &osSystem{} +} + +type osSystem struct{} + +// Args returns the command-line arguments passed to the program. The name of the +// program is filtered out, so it only returns the arguments provided by the user. +// It implements the System interface. +func (s *osSystem) Args() []string { + return os.Args[1:] +} + +// Arg returns the command-line argument at the specified index. If the index is +// out of range, it returns an empty string. +// // It implements the System interface. +func (s *osSystem) Arg(i int) string { + if i < 0 || i >= len(os.Args) { + return "" + } + return os.Args[i+1] // +1 to skip the program name +} + +// UserHomeDir returns the user's home directory. +// It implements the System interface. +func (s *osSystem) UserHomeDir() (string, error) { + return os.UserHomeDir() +} + +// UserConfigDir returns the user's configuration directory. +// It implements the System interface. +func (s *osSystem) UserConfigDir() (string, error) { + return os.UserConfigDir() +} + +// UserCacheDir returns the user's cache directory. +// It implements the System interface. +func (s *osSystem) UserCacheDir() (string, error) { + return os.UserCacheDir() +} + +// TempDir returns the directory used for temporary files. +// It implements the System interface. +func (s *osSystem) TempDir() string { + return os.TempDir() +} + +// Stdout returns the standard output file. +// It implements the System interface. +func (s *osSystem) Stdout() disk.File { + return os.Stdout +} + +// Stderr returns the standard error file. +// It implements the System interface. +func (s *osSystem) Stderr() disk.File { + return os.Stderr +} + +// Exit terminates the program with the given exit code. +// It implements the System interface. +func (s *osSystem) Exit(code int) { + os.Exit(code) +} + +// GetEnv retrieves the value of the environment variable named by key. +// It implements the System interface. +func (s *osSystem) GetEnv(key string) string { + return os.Getenv(key) +} + +// SetEnv sets the value of the environment variable named by key to value. +// It implements the System interface. +func (s *osSystem) SetEnv(key, value string) error { + return os.Setenv(key, value) +} diff --git a/pkg/system/system.go b/pkg/system/system.go new file mode 100644 index 0000000..745724e --- /dev/null +++ b/pkg/system/system.go @@ -0,0 +1,30 @@ +package system + +import ( + "koti.casa/numenor-labs/dsfx/pkg/disk" +) + +type System interface { + // Args returns the command-line arguments passed to the program. + Args() []string + // Arg returns the command-line argument at the specified index. + Arg(int) string + // UserHomeDir returns the user's home directory. + UserHomeDir() (string, error) + // UserConfigDir returns the user's configuration directory. + UserConfigDir() (string, error) + // UserCacheDir returns the user's cache directory. + UserCacheDir() (string, error) + // Stdout returns the standard output file. + Stdout() disk.File + // Stderr returns the standard error file. + Stderr() disk.File + // TempDir returns the directory used for temporary files. + TempDir() string + // Exit terminates the program with the given exit code. + Exit(int) + // GetEnv retrieves the value of the environment variable named by key. + GetEnv(key string) string + // SetEnv sets the value of the environment variable named by key to value. + SetEnv(key, value string) error +}