diff --git a/cmd/core-gui/main.go b/cmd/core-gui/main.go index 54c1150..2a1d7b1 100644 --- a/cmd/core-gui/main.go +++ b/cmd/core-gui/main.go @@ -4,7 +4,7 @@ import ( "embed" "log" - "github.com/Snider/Core/pkg/runtime" + "github.com/Snider/Core/runtime" "github.com/wailsapp/wails/v3/pkg/application" ) diff --git a/cmd/core/cmd/sync.go b/cmd/core/cmd/sync.go index 2c9fe24..77e0e27 100644 --- a/cmd/core/cmd/sync.go +++ b/cmd/core/cmd/sync.go @@ -101,10 +101,10 @@ package {{.ServiceName}} import ( // Import the internal implementation with an alias. - impl "github.com/Snider/Core/pkg/{{.ServiceName}}" + impl "github.com/Snider/Core/{{.ServiceName}}" // Import the core contracts to re-export the interface. - "github.com/Snider/Core/pkg/core" + "github.com/Snider/Core/core" ) {{range .Symbols}} diff --git a/cmd/examples/core-static-di/main.go b/cmd/examples/core-static-di/main.go index 27759f1..137fd18 100644 --- a/cmd/examples/core-static-di/main.go +++ b/cmd/examples/core-static-di/main.go @@ -4,7 +4,7 @@ import ( "embed" "log" - "github.com/Snider/Core/pkg/runtime" + "github.com/Snider/Core/runtime" "github.com/wailsapp/wails/v3/pkg/application" ) diff --git a/cmd/examples/core-task-change/main.go b/cmd/examples/core-task-change/main.go index f15a36d..e08eaf9 100644 --- a/cmd/examples/core-task-change/main.go +++ b/cmd/examples/core-task-change/main.go @@ -4,7 +4,7 @@ import ( "embed" "log" - "github.com/Snider/Core/pkg/runtime" + "github.com/Snider/Core/runtime" "github.com/wailsapp/wails/v3/pkg/application" ) diff --git a/pkg/core/actions.go b/core/actions.go similarity index 100% rename from pkg/core/actions.go rename to core/actions.go diff --git a/pkg/core/core.go b/core/core.go similarity index 100% rename from pkg/core/core.go rename to core/core.go diff --git a/pkg/core/core_test.go b/core/core_test.go similarity index 100% rename from pkg/core/core_test.go rename to core/core_test.go diff --git a/pkg/core/interfaces.go b/core/interfaces.go similarity index 100% rename from pkg/core/interfaces.go rename to core/interfaces.go diff --git a/pkg/core/runtime.go b/core/runtime.go similarity index 100% rename from pkg/core/runtime.go rename to core/runtime.go diff --git a/pkg/core/testdata/test.txt b/core/testdata/test.txt similarity index 100% rename from pkg/core/testdata/test.txt rename to core/testdata/test.txt diff --git a/pkg/core/testutil/testutil.go b/core/testutil/testutil.go similarity index 100% rename from pkg/core/testutil/testutil.go rename to core/testutil/testutil.go diff --git a/docs/index.md b/docs/index.md index 02dfb7a..096f2d4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,7 @@ import ( "embed" "log" - "github.com/Snider/Core/pkg/runtime" + "github.com/Snider/Core/runtime" "github.com/wailsapp/wails/v3/pkg/application" ) diff --git a/pkg/e/e.go b/e/e.go similarity index 100% rename from pkg/e/e.go rename to e/e.go diff --git a/pkg/e/e_test.go b/e/e_test.go similarity index 100% rename from pkg/e/e_test.go rename to e/e_test.go diff --git a/go.mod b/go.mod index 99715f1..12b86f5 100644 --- a/go.mod +++ b/go.mod @@ -3,17 +3,14 @@ module github.com/Snider/Core go 1.25 require ( - github.com/ProtonMail/go-crypto v1.3.0 - github.com/pkg/sftp v1.13.10 - github.com/skeema/knownhosts v1.3.2 github.com/stretchr/testify v1.11.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.37 - golang.org/x/crypto v0.43.0 ) require ( dario.cat/mergo v1.0.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect github.com/adrg/xdg v0.5.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect @@ -32,7 +29,6 @@ require ( github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/kr/fs v0.1.0 // indirect github.com/leaanthony/go-ansi-parser v1.6.1 // indirect github.com/leaanthony/u v1.1.1 // indirect github.com/lmittmann/tint v1.1.2 // indirect @@ -45,9 +41,11 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.52.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.43.0 // indirect golang.org/x/net v0.46.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect diff --git a/go.sum b/go.sum index 1790463..2ccabe3 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,6 @@ github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PW github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= -github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -84,8 +82,6 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= -github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= 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/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= diff --git a/pkg/crypt/crypt.go b/pkg/crypt/crypt.go deleted file mode 100644 index 848be61..0000000 --- a/pkg/crypt/crypt.go +++ /dev/null @@ -1,33 +0,0 @@ -package crypt - -import ( - "github.com/Snider/Core/pkg/core" - "github.com/Snider/Core/pkg/crypt/internal" -) - -// Options holds configuration for the crypt service. -type Options = internal.Options - -// Service provides cryptographic functions to the application. -type Service = internal.Service - -// HashType defines the supported hashing algorithms. -type HashType = internal.HashType - -const ( - LTHN = internal.LTHN - SHA512 = internal.SHA512 - SHA256 = internal.SHA256 - SHA1 = internal.SHA1 - MD5 = internal.MD5 -) - -// New is the constructor for static dependency injection. -func New() (*Service, error) { - return internal.New() -} - -// Register is the constructor for dynamic dependency injection. -func Register(c *core.Core) (any, error) { - return internal.Register(c) -} diff --git a/pkg/crypt/crypt_test.go b/pkg/crypt/crypt_test.go deleted file mode 100644 index c02904d..0000000 --- a/pkg/crypt/crypt_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package crypt - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestHash(t *testing.T) { - s, err := New() - assert.NoError(t, err) - payload := "hello" - hash := s.Hash(LTHN, payload) - assert.NotEmpty(t, hash) -} - -func TestLuhn(t *testing.T) { - s, err := New() - assert.NoError(t, err) - assert.True(t, s.Luhn("79927398713")) - assert.False(t, s.Luhn("79927398714")) -} diff --git a/pkg/crypt/internal/service.go b/pkg/crypt/internal/service.go deleted file mode 100644 index 66ed1b7..0000000 --- a/pkg/crypt/internal/service.go +++ /dev/null @@ -1,181 +0,0 @@ -package internal - -import ( - "bytes" - "crypto/md5" - "crypto/sha1" - "crypto/sha256" - "crypto/sha512" - "encoding/binary" - "encoding/hex" - "io" - "strconv" - "strings" - - "github.com/Snider/Core/pkg/core" - "github.com/Snider/Core/pkg/crypt/lthn" - "github.com/Snider/Core/pkg/crypt/openpgp" - "github.com/Snider/Core/pkg/e" -) - -// Options holds configuration for the crypt service. -type Options struct{} - -// Service provides cryptographic functions to the application. -type Service struct { - *core.Runtime[Options] -} - -// HashType defines the supported hashing algorithms. -type HashType string - -const ( - LTHN HashType = "lthn" - SHA512 HashType = "sha512" - SHA256 HashType = "sha256" - SHA1 HashType = "sha1" - MD5 HashType = "md5" -) - -// newCryptService contains the common logic for initializing a Service struct. -func newCryptService() (*Service, error) { - return &Service{}, nil -} - -// New is the constructor for static dependency injection. -// It creates a Service instance without initializing the core.Runtime field. -func New() (*Service, error) { - return newCryptService() -} - -// Register is the constructor for dynamic dependency injection (used with core.WithService). -// It creates a Service instance and initializes its core.Runtime field. -func Register(c *core.Core) (any, error) { - s, err := newCryptService() - if err != nil { - return nil, e.E("crypt.Register", "failed to create new crypt service", err) - } - s.Runtime = core.NewRuntime(c, Options{}) - return s, nil -} - -// --- Hashing --- - -// Hash computes a hash of the payload using the specified algorithm. -func (s *Service) Hash(lib HashType, payload string) string { - switch lib { - case LTHN: - return lthn.Hash(payload) - case SHA512: - hash := sha512.Sum512([]byte(payload)) - return hex.EncodeToString(hash[:]) - case SHA1: - hash := sha1.Sum([]byte(payload)) - return hex.EncodeToString(hash[:]) - case MD5: - hash := md5.Sum([]byte(payload)) - return hex.EncodeToString(hash[:]) - case SHA256: - fallthrough - default: - hash := sha256.Sum256([]byte(payload)) - return hex.EncodeToString(hash[:]) - } -} - -// --- Checksums --- - -// Luhn validates a number using the Luhn algorithm. -func (s *Service) Luhn(payload string) bool { - payload = strings.ReplaceAll(payload, " ", "") - sum := 0 - isSecond := false - for i := len(payload) - 1; i >= 0; i-- { - digit, err := strconv.Atoi(string(payload[i])) - if err != nil { - return false // Contains non-digit - } - - if isSecond { - digit = digit * 2 - if digit > 9 { - digit = digit - 9 - } - } - - sum += digit - isSecond = !isSecond - } - return sum%10 == 0 -} - -// Fletcher16 computes the Fletcher-16 checksum. -func (s *Service) Fletcher16(payload string) uint16 { - data := []byte(payload) - var sum1, sum2 uint16 - for _, b := range data { - sum1 = (sum1 + uint16(b)) % 255 - sum2 = (sum2 + sum1) % 255 - } - return (sum2 << 8) | sum1 -} - -// Fletcher32 computes the Fletcher-32 checksum. -func (s *Service) Fletcher32(payload string) uint32 { - data := []byte(payload) - if len(data)%2 != 0 { - data = append(data, 0) - } - - var sum1, sum2 uint32 - for i := 0; i < len(data); i += 2 { - val := binary.LittleEndian.Uint16(data[i : i+2]) - sum1 = (sum1 + uint32(val)) % 65535 - sum2 = (sum2 + sum1) % 65535 - } - return (sum2 << 16) | sum1 -} - -// Fletcher64 computes the Fletcher-64 checksum. -func (s *Service) Fletcher64(payload string) uint64 { - data := []byte(payload) - if len(data)%4 != 0 { - padding := 4 - (len(data) % 4) - data = append(data, make([]byte, padding)...) - } - - var sum1, sum2 uint64 - for i := 0; i < len(data); i += 4 { - val := binary.LittleEndian.Uint32(data[i : i+4]) - sum1 = (sum1 + uint64(val)) % 4294967295 - sum2 = (sum2 + sum1) % 4294967295 - } - return (sum2 << 32) | sum1 -} - -// --- PGP --- - -// EncryptPGP encrypts data for a recipient, optionally signing it. -func (s *Service) EncryptPGP(writer io.Writer, recipientPath, data string, signerPath, signerPassphrase *string) (string, error) { - var buf bytes.Buffer - err := openpgp.EncryptPGP(&buf, recipientPath, data, signerPath, signerPassphrase) - if err != nil { - return "", e.E("crypt.EncryptPGP", "failed to encrypt PGP message", err) - } - - // Copy the encrypted data to the original writer. - if _, err := writer.Write(buf.Bytes()); err != nil { - return "", e.E("crypt.EncryptPGP", "failed to write encrypted PGP message to writer", err) - } - - return buf.String(), nil -} - -// DecryptPGP decrypts a PGP message, optionally verifying the signature. -func (s *Service) DecryptPGP(recipientPath, message, passphrase string, signerPath *string) (string, error) { - decrypted, err := openpgp.DecryptPGP(recipientPath, message, passphrase, signerPath) - if err != nil { - return "", e.E("crypt.DecryptPGP", "failed to decrypt PGP message", err) - } - return decrypted, nil -} diff --git a/pkg/crypt/lthn/hash_test.go b/pkg/crypt/lthn/hash_test.go deleted file mode 100644 index 463ea5d..0000000 --- a/pkg/crypt/lthn/hash_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package lthn - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestHash(t *testing.T) { - input := "test_string" - expectedHash := "45d4027179b17265c38732fb1e7089a0b1adfe1d3ba4105fce66f7d46ba42f7d" - - hashed := Hash(input) - fmt.Printf("Hash for \"%s\": %s\n", input, hashed) - - assert.Equal(t, expectedHash, hashed, "The hash should match the expected value") -} - -func TestCreateSalt(t *testing.T) { - // Test with default keyMap - SetKeyMap(map[rune]rune{}) - assert.Equal(t, "gnirts_tset", createSalt("test_string")) - assert.Equal(t, "", createSalt("")) - assert.Equal(t, "A", createSalt("A")) - - // Test with a custom keyMap - customKeyMap := map[rune]rune{ - 'a': 'x', - 'b': 'y', - 'c': 'z', - } - SetKeyMap(customKeyMap) - assert.Equal(t, "zyx", createSalt("abc")) - assert.Equal(t, "gnirts_tset", createSalt("test_string")) // 'test_string' doesn't have 'a', 'b', 'c' - - // Reset keyMap to default for other tests - SetKeyMap(map[rune]rune{}) -} - -func TestVerify(t *testing.T) { - input := "another_test_string" - hashed := Hash(input) - - assert.True(t, Verifyf(input, hashed), "Verifyf should return true for a matching hash") - assert.False(t, Verifyf(input, "wrong_hash"), "Verifyf should return false for a non-matching hash") - assert.False(t, Verifyf("different_input", hashed), "Verifyf should return false for different input") -} diff --git a/pkg/crypt/lthn/lthn.go b/pkg/crypt/lthn/lthn.go deleted file mode 100644 index 1b6c97d..0000000 --- a/pkg/crypt/lthn/lthn.go +++ /dev/null @@ -1,61 +0,0 @@ -package lthn - -import ( - "crypto/sha256" - "encoding/hex" -) - -// keyMap is the default character-swapping map used for the quasi-salting process. -var keyMap = map[rune]rune{ - 'o': '0', - 'l': '1', - 'e': '3', - 'a': '4', - 's': 'z', - 't': '7', - '0': 'o', - '1': 'l', - '3': 'e', - '4': 'a', - '7': 't', -} - -// SetKeyMap sets the key map for the notarisation process. -func SetKeyMap(newKeyMap map[rune]rune) { - keyMap = newKeyMap -} - -// GetKeyMap gets the current key map. -func GetKeyMap() map[rune]rune { - return keyMap -} - -// Hash creates a reproducible hash from a string. -func Hash(input string) string { - salt := createSalt(input) - hash := sha256.Sum256([]byte(input + salt)) - return hex.EncodeToString(hash[:]) -} - -// createSalt creates a quasi-salt from a string by reversing it and swapping characters. -func createSalt(input string) string { - if input == "" { - return "" - } - runes := []rune(input) - salt := make([]rune, len(runes)) - for i := 0; i < len(runes); i++ { - char := runes[len(runes)-1-i] - if replacement, ok := keyMap[char]; ok { - salt[i] = replacement - } else { - salt[i] = char - } - } - return string(salt) -} - -// Verify checks if an input string matches a given hash. -func Verifyf(input string, hash string) bool { - return Hash(input) == hash -} diff --git a/pkg/crypt/openpgp/encrypt.go b/pkg/crypt/openpgp/encrypt.go deleted file mode 100644 index 04853ed..0000000 --- a/pkg/crypt/openpgp/encrypt.go +++ /dev/null @@ -1,233 +0,0 @@ -package openpgp - -import ( - "bytes" - "fmt" - "io" - "os" - "strings" - - "github.com/ProtonMail/go-crypto/openpgp" - "github.com/ProtonMail/go-crypto/openpgp/armor" - "github.com/ProtonMail/go-crypto/openpgp/packet" -) - -// readRecipientEntity reads an armored PGP public key from the given path. -func readRecipientEntity(path string) (entity *openpgp.Entity, err error) { - recipientFile, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to open recipient public key file at %s: %w", path, err) - } - defer func() { - if closeErr := recipientFile.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("openpgp: failed to close recipient key file: %w", closeErr) - } - }() - - block, err := armor.Decode(recipientFile) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to decode armored key from %s: %w", path, err) - } - - if block.Type != openpgp.PublicKeyType { - return nil, fmt.Errorf("openpgp: invalid key type in %s: expected public key, got %s", path, block.Type) - } - - entity, err = openpgp.ReadEntity(packet.NewReader(block.Body)) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to read entity from public key: %w", err) - } - return entity, nil -} - -// readSignerEntity reads and decrypts an armored PGP private key. -func readSignerEntity(path, passphrase string) (entity *openpgp.Entity, err error) { - signerFile, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to open signer private key file at %s: %w", path, err) - } - defer func() { - if closeErr := signerFile.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("openpgp: failed to close signer key file: %w", closeErr) - } - }() - - block, err := armor.Decode(signerFile) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to decode armored key from %s: %w", path, err) - } - - if block.Type != openpgp.PrivateKeyType { - return nil, fmt.Errorf("openpgp: invalid key type in %s: expected private key, got %s", path, block.Type) - } - - entity, err = openpgp.ReadEntity(packet.NewReader(block.Body)) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to read entity from private key: %w", err) - } - - // Decrypt the primary private key. - if entity.PrivateKey != nil && entity.PrivateKey.Encrypted { - if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil { - return nil, fmt.Errorf("openpgp: failed to decrypt private key: %w", err) - } - } - - // Decrypt all subkeys. - for _, subkey := range entity.Subkeys { - if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted { - if err := subkey.PrivateKey.Decrypt([]byte(passphrase)); err != nil { - return nil, fmt.Errorf("openpgp: failed to decrypt subkey: %w", err) - } - } - } - - return entity, nil -} - -// readRecipientKeyRing reads an armored PGP key ring from the given path. -func readRecipientKeyRing(path string) (entityList openpgp.EntityList, err error) { - recipientFile, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to open recipient key file at %s: %w", path, err) - } - defer func() { - if closeErr := recipientFile.Close(); closeErr != nil && err == nil { - err = fmt.Errorf("openpgp: failed to close recipient key file: %w", closeErr) - } - }() - - entityList, err = openpgp.ReadArmoredKeyRing(recipientFile) - if err != nil { - return nil, fmt.Errorf("openpgp: failed to read armored key ring from %s: %w", path, err) - } - if len(entityList) == 0 { - return nil, fmt.Errorf("openpgp: no keys found in recipient key file %s", path) - } - - return entityList, nil -} - -// EncryptPGP encrypts a string using PGP, writing the armored, encrypted -// result to the provided io.Writer. -func EncryptPGP(writer io.Writer, recipientPath, data string, signerPath, signerPassphrase *string) error { - // 1. Read the recipient's public key - recipientEntity, err := readRecipientEntity(recipientPath) - if err != nil { - return err - } - - // 2. Set up the list of recipients - to := openpgp.EntityList{recipientEntity} - - // 3. Handle optional signing - var signer *openpgp.Entity - if signerPath != nil { - var passphrase string - if signerPassphrase != nil { - passphrase = *signerPassphrase - } - signer, err = readSignerEntity(*signerPath, passphrase) - if err != nil { - return fmt.Errorf("openpgp: failed to prepare signer: %w", err) - } - } - - // 4. Create an armored writer and encrypt the message - armoredWriter, err := armor.Encode(writer, "PGP MESSAGE", nil) - if err != nil { - return fmt.Errorf("openpgp: failed to create armored writer: %w", err) - } - - plaintext, err := openpgp.Encrypt(armoredWriter, to, signer, nil, nil) - if err != nil { - _ = armoredWriter.Close() // Attempt to close, but prioritize the encryption error. - return fmt.Errorf("openpgp: failed to begin encryption: %w", err) - } - - _, err = plaintext.Write([]byte(data)) - if err != nil { - _ = plaintext.Close() - _ = armoredWriter.Close() - return fmt.Errorf("openpgp: failed to write data to encryption stream: %w", err) - } - - // 5. Explicitly close the writers to finalize the message. - if err := plaintext.Close(); err != nil { - return fmt.Errorf("openpgp: failed to finalize plaintext writer: %w", err) - } - if err := armoredWriter.Close(); err != nil { - return fmt.Errorf("openpgp: failed to finalize armored writer: %w", err) - } - - return nil -} - -// DecryptPGP decrypts an armored PGP message. -func DecryptPGP(recipientPath, message, passphrase string, signerPath *string) (string, error) { - // 1. Read the recipient's private key - entityList, err := readRecipientKeyRing(recipientPath) - if err != nil { - return "", err - } - - // 2. Decode the armored message - block, err := armor.Decode(strings.NewReader(message)) - if err != nil { - return "", fmt.Errorf("openpgp: failed to decode armored message: %w", err) - } - if block.Type != "PGP MESSAGE" { - return "", fmt.Errorf("openpgp: invalid message type: got %s, want PGP MESSAGE", block.Type) - } - - // 3. If signature verification is required, add signer's public key to keyring - var signerEntity *openpgp.Entity - keyring := entityList - if signerPath != nil { - signerEntity, err = readRecipientEntity(*signerPath) - if err != nil { - return "", fmt.Errorf("openpgp: failed to read signer public key: %w", err) - } - keyring = append(keyring, signerEntity) - } - - // 4. Decrypt the message body - md, err := openpgp.ReadMessage(block.Body, keyring, func(keys []openpgp.Key, symmetric bool) ([]byte, error) { - return []byte(passphrase), nil - }, nil) - if err != nil { - return "", fmt.Errorf("openpgp: failed to read PGP message: %w", err) - } - - // Buffer the unverified body. Do not return or act on it until signature checks pass. - plaintextBuffer := new(bytes.Buffer) - if _, err := io.Copy(plaintextBuffer, md.UnverifiedBody); err != nil { - return "", fmt.Errorf("openpgp: failed to buffer plaintext message body: %w", err) - } - - // 5. Handle optional signature verification - if signerPath != nil { - // First, ensure a signature actually exists when one is expected. - if md.SignedByKeyId == 0 { - return "", fmt.Errorf("openpgp: signature verification failed: message is not signed") - } - - if md.SignatureError != nil { - return "", fmt.Errorf("openpgp: signature verification failed: %w", md.SignatureError) - } - if signerEntity != nil && md.SignedByKeyId != signerEntity.PrimaryKey.KeyId { - match := false - for _, subkey := range signerEntity.Subkeys { - if subkey.PublicKey != nil && subkey.PublicKey.KeyId == md.SignedByKeyId { - match = true - break - } - } - if !match { - return "", fmt.Errorf("openpgp: signature from unexpected key id: got %d, want one of signer key IDs", md.SignedByKeyId) - } - } - } - - return plaintextBuffer.String(), nil -} diff --git a/pkg/crypt/openpgp/encrypt_extra_test.go b/pkg/crypt/openpgp/encrypt_extra_test.go deleted file mode 100644 index c0b46bc..0000000 --- a/pkg/crypt/openpgp/encrypt_extra_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package openpgp - -import ( - "bytes" - "testing" - - "github.com/stretchr/testify/assert" -) - -// TestDecryptWithWrongPassphrase checks that DecryptPGP returns an error when the wrong passphrase is used. -func TestDecryptWithWrongPassphrase(t *testing.T) { - recipientPub, _, cleanup := generateTestKeys(t, "recipient", "") // Unencrypted key for encryption - defer cleanup() - - // Use the pre-generated encrypted key for decryption test - encryptedPrivKeyPath, cleanup2 := createEncryptedKeyFile(t) - defer cleanup2() - - originalMessage := "This message should fail to decrypt." - - var encryptedBuf bytes.Buffer - err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, nil, nil) - assert.NoError(t, err, "Encryption failed unexpectedly") - encryptedMessage := encryptedBuf.String() - - _, err = DecryptPGP(encryptedPrivKeyPath, encryptedMessage, "wrong-passphrase", nil) - assert.Error(t, err, "Decryption was expected to fail with wrong passphrase, but it succeeded.") - assert.Contains(t, err.Error(), "failed to read PGP message", "Expected error message about failing to read PGP message") -} - -// TestDecryptMalformedMessage checks that DecryptPGP handles non-PGP or malformed input gracefully. -func TestDecryptMalformedMessage(t *testing.T) { - // Generate an unencrypted key for this test, as we expect failure before key usage. - _, recipientPriv, cleanup := generateTestKeys(t, "recipient", "") - defer cleanup() - - malformedMessage := "This is not a PGP message." - - // The passphrase here is irrelevant as the key is not encrypted, but we pass one - // to satisfy the function signature. - _, err := DecryptPGP(recipientPriv, malformedMessage, "any-pass", nil) - assert.Error(t, err, "Decryption should fail for a malformed message, but it did not.") - assert.Contains(t, err.Error(), "failed to decode armored message", "Expected error about decoding armored message") -} - -// TestEncryptWithNonexistentRecipient checks that EncryptPGP fails when the recipient's public key file does not exist. -func TestEncryptWithNonexistentRecipient(t *testing.T) { - var encryptedBuf bytes.Buffer - err := EncryptPGP(&encryptedBuf, "/path/to/nonexistent/key.pub", "message", nil, nil) - assert.Error(t, err, "Encryption should fail if recipient key does not exist, but it succeeded.") - assert.Contains(t, err.Error(), "failed to open recipient public key file", "Expected file open error for recipient key") -} - -// TestEncryptAndSignWithWrongPassphrase checks that signing during encryption fails with an incorrect passphrase. -func TestEncryptAndSignWithWrongPassphrase(t *testing.T) { - recipientPub, _, rCleanup := generateTestKeys(t, "recipient", "") - defer rCleanup() - - // Use the pre-generated encrypted key for the signer - signerPriv, sCleanup := createEncryptedKeyFile(t) - defer sCleanup() - - originalMessage := "This message should fail to sign." - wrongPassphrase := "wrong-signer-pass" - - var encryptedBuf bytes.Buffer - err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, &signerPriv, &wrongPassphrase) - - assert.Error(t, err, "Encryption with signing was expected to fail with a wrong passphrase, but it succeeded.") - assert.Contains(t, err.Error(), "failed to decrypt private key", "Expected error about private key decryption failure") -} diff --git a/pkg/crypt/openpgp/encrypt_test.go b/pkg/crypt/openpgp/encrypt_test.go deleted file mode 100644 index 5fa7ec8..0000000 --- a/pkg/crypt/openpgp/encrypt_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package openpgp - -import ( - "bytes" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/ProtonMail/go-crypto/openpgp" - "github.com/ProtonMail/go-crypto/openpgp/armor" - "github.com/ProtonMail/go-crypto/openpgp/packet" -) - -// generateTestKeys creates a new PGP entity and saves the public and private keys to temporary files. -func generateTestKeys(t *testing.T, name, passphrase string) (string, string, func()) { - t.Helper() - - tempDir, err := os.MkdirTemp("", "pgp-keys-*") - if err != nil { - t.Fatalf("test setup: failed to create temp dir for keys: %v", err) - } - - config := &packet.Config{ - RSABits: 2048, // Use a reasonable key size for tests - } - - entity, err := openpgp.NewEntity(name, "", name, config) - if err != nil { - t.Fatalf("test setup: failed to create new PGP entity: %v", err) - } - - // --- Save Public Key --- - pubKeyPath := filepath.Join(tempDir, name+".pub") - pubKeyFile, err := os.Create(pubKeyPath) - if err != nil { - t.Fatalf("test setup: failed to create public key file: %v", err) - } - pubKeyWriter, err := armor.Encode(pubKeyFile, openpgp.PublicKeyType, nil) - if err != nil { - t.Fatalf("test setup: failed to create armored writer for public key: %v", err) - } - if err := entity.Serialize(pubKeyWriter); err != nil { - t.Fatalf("test setup: failed to serialize public key: %v", err) - } - if err := pubKeyWriter.Close(); err != nil { - t.Fatalf("test setup: failed to close public key writer: %v", err) - } - if err := pubKeyFile.Close(); err != nil { - t.Fatalf("test setup: failed to close public key file: %v", err) - } - - // --- Save Private Key (unencrypted for test setup) --- - privKeyPath := filepath.Join(tempDir, name+".asc") - privKeyFile, err := os.Create(privKeyPath) - if err != nil { - t.Fatalf("test setup: failed to create private key file: %v", err) - } - privKeyWriter, err := armor.Encode(privKeyFile, openpgp.PrivateKeyType, nil) - if err != nil { - t.Fatalf("test setup: failed to create armored writer for private key: %v", err) - } - - // Serialize the whole entity with an unencrypted private key. - if err := entity.SerializePrivate(privKeyWriter, nil); err != nil { - t.Fatalf("test setup: failed to serialize private key: %v", err) - } - if err := privKeyWriter.Close(); err != nil { - t.Fatalf("test setup: failed to close private key writer: %v", err) - } - if err := privKeyFile.Close(); err != nil { - t.Fatalf("test setup: failed to close private key file: %v", err) - } - - cleanup := func() { os.RemoveAll(tempDir) } - return pubKeyPath, privKeyPath, cleanup -} - -func TestEncryptDecryptPGP(t *testing.T) { - recipientPub, recipientPriv, cleanup := generateTestKeys(t, "recipient", "recipient-pass") - defer cleanup() - - originalMessage := "This is a secret message." - - // --- Test Encryption --- - var encryptedBuf bytes.Buffer - err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, nil, nil) - if err != nil { - t.Fatalf("EncryptPGP() failed unexpectedly: %v", err) - } - encryptedMessage := encryptedBuf.String() - - if !strings.Contains(encryptedMessage, "-----BEGIN PGP MESSAGE-----") { - t.Errorf("Encrypted message does not appear to be PGP armored") - } - - // --- Test Decryption --- - decryptedMessage, err := DecryptPGP(recipientPriv, encryptedMessage, "recipient-pass", nil) - if err != nil { - t.Fatalf("DecryptPGP() failed unexpectedly: %v", err) - } - - if decryptedMessage != originalMessage { - t.Errorf("Decrypted message mismatch: got=%q, want=%q", decryptedMessage, originalMessage) - } -} - -func TestSignAndVerifyPGP(t *testing.T) { - recipientPub, recipientPriv, rCleanup := generateTestKeys(t, "recipient", "recipient-pass") - defer rCleanup() - - signerPub, signerPriv, sCleanup := generateTestKeys(t, "signer", "signer-pass") - defer sCleanup() - - originalMessage := "This is a signed and verified message." - - // --- Encrypt and Sign --- - var encryptedBuf bytes.Buffer - signerPass := "signer-pass" - err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, &signerPriv, &signerPass) - if err != nil { - t.Fatalf("EncryptPGP() with signing failed unexpectedly: %v", err) - } - encryptedMessage := encryptedBuf.String() - - // --- Decrypt and Verify --- - decryptedMessage, err := DecryptPGP(recipientPriv, encryptedMessage, "recipient-pass", &signerPub) - if err != nil { - t.Fatalf("DecryptPGP() with verification failed unexpectedly: %v", err) - } - - if decryptedMessage != originalMessage { - t.Errorf("Decrypted message mismatch after signing: got=%q, want=%q", decryptedMessage, originalMessage) - } -} - -func TestVerificationFailure(t *testing.T) { - recipientPub, recipientPriv, rCleanup := generateTestKeys(t, "recipient", "recipient-pass") - defer rCleanup() - - _, signerPriv, sCleanup := generateTestKeys(t, "signer", "signer-pass") - defer sCleanup() - - // Generate a third, unexpected key to test verification failure - unexpectedSignerPub, _, uCleanup := generateTestKeys(t, "unexpected", "unexpected-pass") - defer uCleanup() - - originalMessage := "This message should fail verification." - - // --- Encrypt and Sign with the actual signer key --- - var encryptedBuf bytes.Buffer - signerPass := "signer-pass" - err := EncryptPGP(&encryptedBuf, recipientPub, originalMessage, &signerPriv, &signerPass) - if err != nil { - t.Fatalf("EncryptPGP() with signing failed unexpectedly: %v", err) - } - encryptedMessage := encryptedBuf.String() - - // --- Attempt to Decrypt and Verify with the WRONG public key --- - _, err = DecryptPGP(recipientPriv, encryptedMessage, "recipient-pass", &unexpectedSignerPub) - if err == nil { - t.Fatal("DecryptPGP() did not fail, but verification with an incorrect key was expected to fail.") - } - - if !strings.Contains(err.Error(), "signature from unexpected key") { - t.Errorf("Expected error to contain 'signature from unexpected key', but got: %v", err) - } -} diff --git a/pkg/crypt/openpgp/key.go b/pkg/crypt/openpgp/key.go deleted file mode 100644 index 2a15ad8..0000000 --- a/pkg/crypt/openpgp/key.go +++ /dev/null @@ -1,225 +0,0 @@ -package openpgp - -import ( - "bytes" - "crypto" - "fmt" - "path/filepath" - "time" - - "github.com/ProtonMail/go-crypto/openpgp" - "github.com/ProtonMail/go-crypto/openpgp/armor" - "github.com/ProtonMail/go-crypto/openpgp/packet" - "github.com/Snider/Core/pkg/crypt/lthn" -) - -// CreateKeyPair generates a new OpenPGP key pair. -// The password parameter is optional. If not provided, the private key will not be encrypted. -func CreateKeyPair(username string, passwords ...string) (*KeyPair, error) { - var password string - if len(passwords) > 0 { - password = passwords[0] - } - - entity, err := openpgp.NewEntity(username, "Lethean Desktop", "", &packet.Config{ - RSABits: 4096, - DefaultHash: crypto.SHA256, - }) - if err != nil { - return nil, fmt.Errorf("failed to create new entity: %w", err) - } - - // The private key is initially unencrypted after NewEntity. - // Generate revocation certificate while the private key is unencrypted. - revocationCert, err := createRevocationCertificate(entity) - if err != nil { - revocationCert = "" // Non-critical, proceed without it if it fails - } - - // Encrypt the private key only if a password is provided, after revocation cert generation. - if password != "" { - if err := entity.PrivateKey.Encrypt([]byte(password)); err != nil { - return nil, fmt.Errorf("failed to encrypt private key: %w", err) - } - } - - publicKey, err := serializeEntity(entity, openpgp.PublicKeyType, "") // Public key doesn't need password - if err != nil { - return nil, err - } - - // Private key serialization. The key is already in its final encrypted/unencrypted state. - privateKey, err := serializeEntity(entity, openpgp.PrivateKeyType, "") // No password needed here for serialization - if err != nil { - return nil, err - } - - return &KeyPair{ - PublicKey: publicKey, - PrivateKey: privateKey, - RevocationCertificate: revocationCert, - }, nil -} - -// CreateServerKeyPair creates and stores a key pair for the server in a specific directory. -func CreateServerKeyPair(keysDir string) error { - serverKeyPath := filepath.Join(keysDir, "server.lthn.pub") - // Passphrase is derived from the path itself, consistent with original logic. - passphrase := lthn.Hash(serverKeyPath) - return createAndStoreKeyPair("server", passphrase, keysDir) -} - -// GetPublicKey retrieves an armored public key for a given ID. -func GetPublicKey(path string) (*openpgp.Entity, error) { - return readEntity(path) -} - -// GetPrivateKey retrieves and decrypts an armored private key. -func GetPrivateKey(path, passphrase string) (*openpgp.Entity, error) { - entity, err := readEntity(path) - if err != nil { - return nil, err - } - - if entity.PrivateKey == nil { - return nil, fmt.Errorf("no private key found for path %s", path) - } - - if entity.PrivateKey.Encrypted { - if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil { - return nil, fmt.Errorf("failed to decrypt private key for path %s: %w", path, err) - } - } - - var primaryIdentity *openpgp.Identity - for _, identity := range entity.Identities { - if identity.SelfSignature.IsPrimaryId != nil && *identity.SelfSignature.IsPrimaryId { - primaryIdentity = identity - break - } - } - if primaryIdentity == nil { - for _, identity := range entity.Identities { - primaryIdentity = identity - break - } - } - - if primaryIdentity == nil { - return nil, fmt.Errorf("key for %s has no identity", path) - } - - if primaryIdentity.SelfSignature.KeyLifetimeSecs != nil { - if primaryIdentity.SelfSignature.CreationTime.Add(time.Duration(*primaryIdentity.SelfSignature.KeyLifetimeSecs) * time.Second).Before(time.Now()) { - return nil, fmt.Errorf("key for %s has expired", path) - } - } - - return entity, nil -} - -// --- Helper Functions --- - -func createAndStoreKeyPair(id, password, dir string) error { - //var keyPair *KeyPair - var err error - - //if password != "" { - // keyPair, err = CreateKeyPair(id, password) - //} else { - // keyPair, err = CreateKeyPair(id) - //} - - if err != nil { - return fmt.Errorf("failed to create key pair for id %s: %w", id, err) - } - - //if err := io.Local.EnsureDir(dir); err != nil { - // return fmt.Errorf("failed to ensure key directory exists: %w", err) - //} - // - //files := map[string]string{ - // filepath.Join(dir, fmt.Sprintf("%s.lthn.pub", id)): keyPair.PublicKey, - // filepath.Join(dir, fmt.Sprintf("%s.lthn.key", id)): keyPair.PrivateKey, - // filepath.Join(dir, fmt.Sprintf("%s.lthn.rev", id)): keyPair.RevocationCertificate, // Re-enabled - //} - // - //for path, content := range files { - // if content == "" { - // continue - // } - // if err := io.Local.Write(path, content); err != nil { - // return fmt.Errorf("failed to write key file %s: %w", path, err) - // } - //} - return nil -} - -func readEntity(path string) (*openpgp.Entity, error) { - //keyArmored, err := m.Read(path) - //if err != nil { - // return nil, fmt.Errorf("failed to read key file %s: %w", path, err) - //} - - //entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(keyArmored)) - //if err != nil { - // return nil, fmt.Errorf("failed to parse key file %s: %w", path, err) - //} - //if len(entityList) == 0 { - // return nil, fmt.Errorf("no entity found in key file %s", path) - //} - //return entityList[0], nil - return nil, nil -} - -func serializeEntity(entity *openpgp.Entity, keyType string, password string) (string, error) { - buf := new(bytes.Buffer) - writer, err := armor.Encode(buf, keyType, nil) - if err != nil { - return "", fmt.Errorf("failed to create armor encoder: %w", err) - } - - if keyType == openpgp.PrivateKeyType { - // Serialize the private key in its current in-memory state. - // Encryption is handled by CreateKeyPair before this function is called. - err = entity.SerializePrivateWithoutSigning(writer, nil) - } else { - err = entity.Serialize(writer) - } - - if err != nil { - return "", fmt.Errorf("failed to serialize entity: %w", err) - } - if err := writer.Close(); err != nil { - return "", fmt.Errorf("failed to close armor writer: %w", err) - } - return buf.String(), nil -} - -func createRevocationCertificate(entity *openpgp.Entity) (string, error) { - buf := new(bytes.Buffer) - writer, err := armor.Encode(buf, openpgp.SignatureType, nil) - if err != nil { - return "", fmt.Errorf("failed to create armor encoder for revocation: %w", err) - } - - sig := &packet.Signature{ - SigType: packet.SigTypeKeyRevocation, - PubKeyAlgo: entity.PrimaryKey.PubKeyAlgo, - Hash: crypto.SHA256, - CreationTime: time.Now(), - IssuerKeyId: &entity.PrimaryKey.KeyId, - } - - // SignKey requires an unencrypted private key. - if err := sig.SignKey(entity.PrimaryKey, entity.PrivateKey, nil); err != nil { - return "", fmt.Errorf("failed to sign revocation: %w", err) - } - if err := sig.Serialize(writer); err != nil { - return "", fmt.Errorf("failed to serialize revocation signature: %w", err) - } - if err := writer.Close(); err != nil { - return "", fmt.Errorf("failed to close revocation writer: %w", err) - } - return buf.String(), nil -} diff --git a/pkg/crypt/openpgp/openpgp.go b/pkg/crypt/openpgp/openpgp.go deleted file mode 100644 index 1e604a5..0000000 --- a/pkg/crypt/openpgp/openpgp.go +++ /dev/null @@ -1,12 +0,0 @@ -package openpgp - -// pgpMessageHeader is the standard armor header for PGP messages. -const pgpMessageHeader = "PGP MESSAGE" - -// KeyPair holds the generated armored keys and revocation certificate. -// This is the primary data structure representing a user's PGP identity within the system. -type KeyPair struct { - PublicKey string - PrivateKey string - RevocationCertificate string -} diff --git a/pkg/crypt/openpgp/sign.go b/pkg/crypt/openpgp/sign.go deleted file mode 100644 index a853350..0000000 --- a/pkg/crypt/openpgp/sign.go +++ /dev/null @@ -1,38 +0,0 @@ -package openpgp - -import ( - "bytes" - "fmt" - "strings" - - "github.com/ProtonMail/go-crypto/openpgp" -) - -// Sign creates a detached signature for the data. -func Sign(data, privateKeyPath, passphrase string) (string, error) { - signer, err := GetPrivateKey(privateKeyPath, passphrase) - if err != nil { - return "", fmt.Errorf("failed to get private key for signing: %w", err) - } - - buf := new(bytes.Buffer) - if err := openpgp.ArmoredDetachSign(buf, signer, strings.NewReader(data), nil); err != nil { - return "", fmt.Errorf("failed to create detached signature: %w", err) - } - - return buf.String(), nil -} - -// Verify checks a detached signature. -func Verify(data, signature, publicKeyPath string) (bool, error) { - keyring, err := GetPublicKey(publicKeyPath) - if err != nil { - return false, fmt.Errorf("failed to get public key for verification: %w", err) - } - - _, err = openpgp.CheckArmoredDetachedSignature(openpgp.EntityList{keyring}, strings.NewReader(data), strings.NewReader(signature), nil) - if err != nil { - return false, fmt.Errorf("signature verification failed: %w", err) - } - return true, nil -} diff --git a/pkg/crypt/openpgp/test_util.go b/pkg/crypt/openpgp/test_util.go deleted file mode 100644 index fd239c8..0000000 --- a/pkg/crypt/openpgp/test_util.go +++ /dev/null @@ -1,96 +0,0 @@ -package openpgp - -import ( - "os" - "path/filepath" - "testing" -) - -// encryptedPrivateKey is a pre-generated, armored PGP private key, encrypted with the passphrase "test-passphrase". -// This key is used in tests where programmatic key generation and encryption is not feasible due to library limitations. -const encryptedPrivateKey = `-----BEGIN PGP PRIVATE KEY BLOCK----- - -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 -hw== -=u7WH ------END PGP PRIVATE KEY BLOCK-----` - -// createEncryptedKeyFile creates a temporary file containing a pre-generated, encrypted private key. -// It returns the path to the temporary file and a cleanup function to remove the temporary directory. -func createEncryptedKeyFile(t *testing.T) (string, func()) { - t.Helper() - - tempDir, err := os.MkdirTemp("", "pgp-test-key-*") - if err != nil { - t.Fatalf("test setup: failed to create temp dir for encrypted key: %v", err) - } - - privKeyPath := filepath.Join(tempDir, "encrypted-key.asc") - err = os.WriteFile(privKeyPath, []byte(encryptedPrivateKey), 0600) - if err != nil { - t.Fatalf("test setup: failed to write encrypted key to file: %v", err) - } - - cleanup := func() { os.RemoveAll(tempDir) } - return privKeyPath, cleanup -} diff --git a/pkg/io/client.go b/pkg/io/client.go deleted file mode 100644 index 85d48fa..0000000 --- a/pkg/io/client.go +++ /dev/null @@ -1,45 +0,0 @@ -package io - -import ( - "github.com/Snider/Core/pkg/io/sftp" - "github.com/Snider/Core/pkg/io/webdav" -) - -// NewSFTPMedium creates and returns a new SFTP medium. -func NewSFTPMedium(cfg sftp.ConnectionConfig) (Medium, error) { - return sftp.New(cfg) -} - -// NewWebDAVMedium creates and returns a new WebDAV medium. -func NewWebDAVMedium(cfg webdav.ConnectionConfig) (Medium, error) { - return webdav.New(cfg) -} - -// Read retrieves the content of a file from the given medium. -func Read(m Medium, path string) (string, error) { - return m.Read(path) -} - -// Write saves content to a file on the given medium. -func Write(m Medium, path, content string) error { - return m.Write(path, content) -} - -// EnsureDir ensures a directory exists on the given medium. -func EnsureDir(m Medium, path string) error { - return m.EnsureDir(path) -} - -// IsFile checks if a path is a file on the given medium. -func IsFile(m Medium, path string) bool { - return m.IsFile(path) -} - -// Copy copies a file from a source medium to a destination medium. -func Copy(sourceMedium Medium, sourcePath string, destMedium Medium, destPath string) error { - content, err := sourceMedium.Read(sourcePath) - if err != nil { - return err - } - return destMedium.Write(destPath, content) -} diff --git a/pkg/io/client_test.go b/pkg/io/client_test.go deleted file mode 100644 index ad1f4d9..0000000 --- a/pkg/io/client_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package io - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestRead(t *testing.T) { - m := NewMockMedium() - m.Files["test.txt"] = "hello" - content, err := Read(m, "test.txt") - assert.NoError(t, err) - assert.Equal(t, "hello", content) -} - -func TestWrite(t *testing.T) { - m := NewMockMedium() - err := Write(m, "test.txt", "hello") - assert.NoError(t, err) - assert.Equal(t, "hello", m.Files["test.txt"]) -} - -func TestCopy(t *testing.T) { - source := NewMockMedium() - dest := NewMockMedium() - source.Files["test.txt"] = "hello" - err := Copy(source, "test.txt", dest, "test.txt") - assert.NoError(t, err) - assert.Equal(t, "hello", dest.Files["test.txt"]) -} diff --git a/pkg/io/io.go b/pkg/io/io.go deleted file mode 100644 index df02342..0000000 --- a/pkg/io/io.go +++ /dev/null @@ -1,27 +0,0 @@ -package io - -// Medium defines the standard interface for a storage backend. -// This allows for different implementations (e.g., local disk, S3, SFTP) -// to be used interchangeably. -type Medium interface { - // Read retrieves the content of a file as a string. - Read(path string) (string, error) - - // Write saves the given content to a file, overwriting it if it exists. - Write(path, content string) error - - // EnsureDir makes sure a directory exists, creating it if necessary. - EnsureDir(path string) error - - // IsFile checks if a path exists and is a regular file. - IsFile(path string) bool - - // FileGet is a convenience function that reads a file from the medium. - FileGet(path string) (string, error) - - // FileSet is a convenience function that writes a file to the medium. - FileSet(path, content string) error -} - -// Pre-initialized, sandboxed medium for the local filesystem. -var Local Medium diff --git a/pkg/io/io_test.go b/pkg/io/io_test.go deleted file mode 100644 index aad8db1..0000000 --- a/pkg/io/io_test.go +++ /dev/null @@ -1,87 +0,0 @@ -package io - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestIO_Read_Good(t *testing.T) { - medium := NewMockMedium() - medium.Files["test.txt"] = "hello" - - content, err := Read(medium, "test.txt") - assert.NoError(t, err) - assert.Equal(t, "hello", content) -} - -func TestIO_Read_Bad(t *testing.T) { - medium := NewMockMedium() - - _, err := Read(medium, "nonexistent.txt") - assert.Error(t, err) -} - -func TestIO_Write_Good(t *testing.T) { - medium := NewMockMedium() - - err := Write(medium, "test.txt", "hello") - assert.NoError(t, err) - - writtenContent, ok := medium.Files["test.txt"] - assert.True(t, ok) - assert.Equal(t, "hello", writtenContent) -} - -// TODO: The current MockMedium cannot simulate a write error. -// func TestIO_Write_Bad(t *testing.T) { -// medium := NewMockMedium() -// // How to make Write fail? -// err := Write(medium, "test.txt", "hello") -// assert.Error(t, err) -// } - -func TestIO_EnsureDir_Good(t *testing.T) { - medium := NewMockMedium() - err := EnsureDir(medium, "testdir") - assert.NoError(t, err) - exists := medium.Dirs["testdir"] - assert.True(t, exists) -} - -// TODO: The current MockMedium cannot simulate an EnsureDir error. -// func TestIO_EnsureDir_Bad(t *testing.T) { -// medium := NewMockMedium() -// // How to make EnsureDir fail? -// err := EnsureDir(medium, "testdir") -// assert.Error(t, err) -// } - -func TestIO_IsFile_Good(t *testing.T) { - medium := NewMockMedium() - medium.Files["test.txt"] = "content" - assert.True(t, IsFile(medium, "test.txt")) - assert.False(t, IsFile(medium, "nonexistent.txt")) -} - -func TestIO_Copy_Good(t *testing.T) { - source := NewMockMedium() - source.Files["source.txt"] = "hello" - - dest := NewMockMedium() - - err := Copy(source, "source.txt", dest, "dest.txt") - assert.NoError(t, err) - - copiedContent, ok := dest.Files["dest.txt"] - assert.True(t, ok) - assert.Equal(t, "hello", copiedContent) -} - -func TestIO_Copy_Bad(t *testing.T) { - source := NewMockMedium() // No source file - dest := NewMockMedium() - - err := Copy(source, "source.txt", dest, "dest.txt") - assert.Error(t, err) -} diff --git a/pkg/io/local/client.go b/pkg/io/local/client.go deleted file mode 100644 index 0efe171..0000000 --- a/pkg/io/local/client.go +++ /dev/null @@ -1,83 +0,0 @@ -package local - -import ( - "fmt" - "os" - "path/filepath" - "strings" -) - -// New creates a new instance of the local storage medium. -// It requires a root path to sandbox all file operations. -func New(rootPath string) (*Medium, error) { - if err := os.MkdirAll(rootPath, os.ModePerm); err != nil { - return nil, fmt.Errorf("could not create root directory at %s: %w", rootPath, err) - } - return &Medium{root: rootPath}, nil -} - -// path returns a full, safe path within the medium's root. -func (m *Medium) path(subpath string) (string, error) { - if strings.Contains(subpath, "..") { - return "", fmt.Errorf("path traversal attempt detected") - } - return filepath.Join(m.root, subpath), nil -} - -// Read retrieves the content of a file from the local disk. -func (m *Medium) Read(path string) (string, error) { - safePath, err := m.path(path) - if err != nil { - return "", err - } - data, err := os.ReadFile(safePath) - if err != nil { - return "", err - } - return string(data), nil -} - -// Write saves the given content to a file on the local disk. -func (m *Medium) Write(path, content string) error { - safePath, err := m.path(path) - if err != nil { - return err - } - dir := filepath.Dir(safePath) - if err := os.MkdirAll(dir, os.ModePerm); err != nil { - return err - } - return os.WriteFile(safePath, []byte(content), 0644) -} - -// EnsureDir makes sure a directory exists on the local disk. -func (m *Medium) EnsureDir(path string) error { - safePath, err := m.path(path) - if err != nil { - return err - } - return os.MkdirAll(safePath, os.ModePerm) -} - -// IsFile checks if a path exists and is a regular file on the local disk. -func (m *Medium) IsFile(path string) bool { - safePath, err := m.path(path) - if err != nil { - return false - } - info, err := os.Stat(safePath) - if os.IsNotExist(err) { - return false - } - return !info.IsDir() -} - -// FileGet is a convenience function that reads a file from the medium. -func (m *Medium) FileGet(path string) (string, error) { - return m.Read(path) -} - -// FileSet is a convenience function that writes a file to the medium. -func (m *Medium) FileSet(path, content string) error { - return m.Write(path, content) -} diff --git a/pkg/io/local/client_test.go b/pkg/io/local/client_test.go deleted file mode 100644 index ff3dce7..0000000 --- a/pkg/io/local/client_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package local - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestNew(t *testing.T) { - // Create a temporary directory for testing - testRoot, err := os.MkdirTemp("", "local_test_root") - assert.NoError(t, err) - defer os.RemoveAll(testRoot) // Clean up after the test - - // Test successful creation - medium, err := New(testRoot) - assert.NoError(t, err) - assert.NotNil(t, medium) - assert.Equal(t, testRoot, medium.root) - - // Verify the root directory exists - info, err := os.Stat(testRoot) - assert.NoError(t, err) - assert.True(t, info.IsDir()) - - // Test creating a new instance with an existing directory (should not error) - medium2, err := New(testRoot) - assert.NoError(t, err) - assert.NotNil(t, medium2) -} - -func TestPath(t *testing.T) { - testRoot := "/tmp/test_root" - medium := &Medium{root: testRoot} - - // Valid path - validPath, err := medium.path("file.txt") - assert.NoError(t, err) - assert.Equal(t, filepath.Join(testRoot, "file.txt"), validPath) - - // Subdirectory path - subDirPath, err := medium.path("dir/sub/file.txt") - assert.NoError(t, err) - assert.Equal(t, filepath.Join(testRoot, "dir", "sub", "file.txt"), subDirPath) - - // Path traversal attempt - _, err = medium.path("../secret.txt") - assert.Error(t, err) - assert.Contains(t, err.Error(), "path traversal attempt detected") - - _, err = medium.path("dir/../../secret.txt") - assert.Error(t, err) - assert.Contains(t, err.Error(), "path traversal attempt detected") -} - -func TestReadWrite(t *testing.T) { - testRoot, err := os.MkdirTemp("", "local_read_write_test") - assert.NoError(t, err) - defer os.RemoveAll(testRoot) - - medium, err := New(testRoot) - assert.NoError(t, err) - - fileName := "testfile.txt" - filePath := filepath.Join("subdir", fileName) - content := "Hello, Gopher!\nThis is a test file." - - // Test Write - err = medium.Write(filePath, content) - assert.NoError(t, err) - - // Verify file content by reading directly from OS - readContent, err := os.ReadFile(filepath.Join(testRoot, filePath)) - assert.NoError(t, err) - assert.Equal(t, content, string(readContent)) - - // Test Read - readByMedium, err := medium.Read(filePath) - assert.NoError(t, err) - assert.Equal(t, content, readByMedium) - - // Test Read non-existent file - _, err = medium.Read("nonexistent.txt") - assert.Error(t, err) - assert.True(t, os.IsNotExist(err)) - - // Test Write to a path with traversal attempt - writeErr := medium.Write("../badfile.txt", "malicious content") - assert.Error(t, writeErr) - assert.Contains(t, writeErr.Error(), "path traversal attempt detected") -} - -func TestEnsureDir(t *testing.T) { - testRoot, err := os.MkdirTemp("", "local_ensure_dir_test") - assert.NoError(t, err) - defer os.RemoveAll(testRoot) - - medium, err := New(testRoot) - assert.NoError(t, err) - - dirName := "newdir/subdir" - dirPath := filepath.Join(testRoot, dirName) - - // Test creating a new directory - err = medium.EnsureDir(dirName) - assert.NoError(t, err) - info, err := os.Stat(dirPath) - assert.NoError(t, err) - assert.True(t, info.IsDir()) - - // Test ensuring an existing directory (should not error) - err = medium.EnsureDir(dirName) - assert.NoError(t, err) - - // Test ensuring a directory with path traversal attempt - err = medium.EnsureDir("../bad_dir") - assert.Error(t, err) - assert.Contains(t, err.Error(), "path traversal attempt detected") -} - -func TestIsFile(t *testing.T) { - testRoot, err := os.MkdirTemp("", "local_is_file_test") - assert.NoError(t, err) - defer os.RemoveAll(testRoot) - - medium, err := New(testRoot) - assert.NoError(t, err) - - // Create a test file - fileName := "existing_file.txt" - filePath := filepath.Join(testRoot, fileName) - err = os.WriteFile(filePath, []byte("content"), 0644) - assert.NoError(t, err) - - // Create a test directory - dirName := "existing_dir" - dirPath := filepath.Join(testRoot, dirName) - err = os.Mkdir(dirPath, 0755) - assert.NoError(t, err) - - // Test with an existing file - assert.True(t, medium.IsFile(fileName)) - - // Test with a non-existent file - assert.False(t, medium.IsFile("nonexistent_file.txt")) - - // Test with a directory - assert.False(t, medium.IsFile(dirName)) - - // Test with path traversal attempt - assert.False(t, medium.IsFile("../bad_file.txt")) -} diff --git a/pkg/io/local/local.go b/pkg/io/local/local.go deleted file mode 100644 index f833975..0000000 --- a/pkg/io/local/local.go +++ /dev/null @@ -1,6 +0,0 @@ -package local - -// Medium implements the io.Medium interface for the local disk. -type Medium struct { - root string -} diff --git a/pkg/io/mock.go b/pkg/io/mock.go deleted file mode 100644 index bb0da8e..0000000 --- a/pkg/io/mock.go +++ /dev/null @@ -1,47 +0,0 @@ -package io - -import "github.com/stretchr/testify/assert" - -// MockMedium implements the Medium interface for testing purposes. -type MockMedium struct { - Files map[string]string - Dirs map[string]bool -} - -func NewMockMedium() *MockMedium { - return &MockMedium{ - Files: make(map[string]string), - Dirs: make(map[string]bool), - } -} - -func (m *MockMedium) Read(path string) (string, error) { - content, ok := m.Files[path] - if !ok { - return "", assert.AnError // Simulate file not found error - } - return content, nil -} - -func (m *MockMedium) Write(path, content string) error { - m.Files[path] = content - return nil -} - -func (m *MockMedium) EnsureDir(path string) error { - m.Dirs[path] = true - return nil -} - -func (m *MockMedium) IsFile(path string) bool { - _, ok := m.Files[path] - return ok -} - -func (m *MockMedium) FileGet(path string) (string, error) { - return m.Read(path) -} - -func (m *MockMedium) FileSet(path, content string) error { - return m.Write(path, content) -} diff --git a/pkg/io/sftp/client.go b/pkg/io/sftp/client.go deleted file mode 100644 index 271e6a2..0000000 --- a/pkg/io/sftp/client.go +++ /dev/null @@ -1,139 +0,0 @@ -package sftp - -import ( - "fmt" - "io" - "net" - "os" - "path/filepath" - "strconv" - "time" - - "github.com/pkg/sftp" - "github.com/skeema/knownhosts" - "golang.org/x/crypto/ssh" -) - -// New creates a new, connected instance of the SFTP storage medium. -func New(cfg ConnectionConfig) (*Medium, error) { - // Validate port - port, err := strconv.Atoi(cfg.Port) - if err != nil || port < 1 || port > 65535 { - return nil, fmt.Errorf("invalid port: %s", cfg.Port) - } - - var authMethods []ssh.AuthMethod - - if cfg.KeyFile != "" { - key, err := os.ReadFile(cfg.KeyFile) - if err != nil { - return nil, fmt.Errorf("unable to read private key: %w", err) - } - signer, err := ssh.ParsePrivateKey(key) - if err != nil { - return nil, fmt.Errorf("unable to parse private key: %w", err) - } - authMethods = append(authMethods, ssh.PublicKeys(signer)) - } else if cfg.Password != "" { - authMethods = append(authMethods, ssh.Password(cfg.Password)) - } else { - return nil, fmt.Errorf("no authentication method provided (password or keyfile)") - } - - kh, err := knownhosts.New(filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts")) - if err != nil { - return nil, fmt.Errorf("failed to read known_hosts: %w", err) - } - - // Set a default timeout if one is not provided. - if cfg.Timeout == 0 { - cfg.Timeout = 30 * time.Second - } - - sshConfig := &ssh.ClientConfig{ - User: cfg.User, - Auth: authMethods, - HostKeyCallback: kh.HostKeyCallback(), - Timeout: cfg.Timeout, - } - - addr := net.JoinHostPort(cfg.Host, cfg.Port) - conn, err := ssh.Dial("tcp", addr, sshConfig) - if err != nil { - return nil, fmt.Errorf("failed to dial ssh: %w", err) - } - - sftpClient, err := sftp.NewClient(conn) - if err != nil { - // Ensure the underlying ssh connection is closed on failure - conn.Close() - return nil, fmt.Errorf("failed to create sftp client: %w", err) - } - - return &Medium{client: sftpClient}, nil -} - -// Read retrieves the content of a file from the SFTP server. -func (m *Medium) Read(path string) (string, error) { - file, err := m.client.Open(path) - if err != nil { - return "", fmt.Errorf("sftp: failed to open file %s: %w", path, err) - } - defer file.Close() - - data, err := io.ReadAll(file) - if err != nil { - return "", fmt.Errorf("sftp: failed to read file %s: %w", path, err) - } - - return string(data), nil -} - -// Write saves the given content to a file on the SFTP server. -func (m *Medium) Write(path, content string) error { - // Ensure the remote directory exists first. - dir := filepath.Dir(path) - if err := m.EnsureDir(dir); err != nil { - return err - } - - file, err := m.client.Create(path) - if err != nil { - return fmt.Errorf("sftp: failed to create file %s: %w", path, err) - } - defer file.Close() - - if _, err := file.Write([]byte(content)); err != nil { - return fmt.Errorf("sftp: failed to write to file %s: %w", path, err) - } - - return nil -} - -// EnsureDir makes sure a directory exists on the SFTP server. -func (m *Medium) EnsureDir(path string) error { - // MkdirAll is idempotent, so it won't error if the path already exists. - return m.client.MkdirAll(path) -} - -// IsFile checks if a path exists and is a regular file on the SFTP server. -func (m *Medium) IsFile(path string) bool { - info, err := m.client.Stat(path) - if err != nil { - // If the error is "not found", it's definitely not a file. - // For any other error, we also conservatively say it's not a file. - return false - } - // Return true only if it's not a directory. - return !info.IsDir() -} - -// FileGet is a convenience function that reads a file from the medium. -func (m *Medium) FileGet(path string) (string, error) { - return m.Read(path) -} - -// FileSet is a convenience function that writes a file to the medium. -func (m *Medium) FileSet(path, content string) error { - return m.Write(path, content) -} diff --git a/pkg/io/sftp/sftp.go b/pkg/io/sftp/sftp.go deleted file mode 100644 index 2c4a629..0000000 --- a/pkg/io/sftp/sftp.go +++ /dev/null @@ -1,25 +0,0 @@ -package sftp - -import ( - "time" - - "github.com/pkg/sftp" -) - -// Medium implements the io.Medium interface for the SFTP protocol. -type Medium struct { - client *sftp.Client -} - -// ConnectionConfig holds the necessary details to connect to an SFTP server. -type ConnectionConfig struct { - Host string - Port string - User string - Password string // For password-based auth - KeyFile string // Path to a private key for key-based auth - - // Timeout specifies the duration for the network connection. If set to 0, - // a default timeout of 30 seconds will be used. - Timeout time.Duration -} diff --git a/pkg/io/sftp/sftp_test.go b/pkg/io/sftp/sftp_test.go deleted file mode 100644 index 3c7873d..0000000 --- a/pkg/io/sftp/sftp_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package sftp - -import ( - "os" - "path/filepath" - "testing" - "time" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// setupTest creates a temporary home directory and a dummy known_hosts file -// to prevent tests from failing in CI environments where the file doesn't exist. -func setupTest(t *testing.T) { - t.Helper() - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - sshDir := filepath.Join(homeDir, ".ssh") - err := os.Mkdir(sshDir, 0700) - require.NoError(t, err) - knownHostsFile := filepath.Join(sshDir, "known_hosts") - err = os.WriteFile(knownHostsFile, []byte{}, 0600) - require.NoError(t, err) -} - -func TestNew(t *testing.T) { - setupTest(t) - // Provide a dummy ConnectionConfig for testing. - // Since we are not setting up a real SFTP server, we expect an error during connection. - cfg := ConnectionConfig{ - Host: "localhost", - Port: "22", - User: "testuser", - // No password or keyfile provided, so connection should fail. - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service, "New() should return a nil service instance on connection error") - assert.Contains(t, err.Error(), "no authentication method provided", "Expected authentication error") -} - -func TestNew_InvalidHost(t *testing.T) { - setupTest(t) - cfg := ConnectionConfig{ - Host: "non-resolvable-host.domain.invalid", - Port: "22", - User: "testuser", - Password: "password", - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service) - assert.Contains(t, err.Error(), "lookup non-resolvable-host.domain.invalid") -} - -func TestNew_InvalidPort(t *testing.T) { - setupTest(t) - cfg := ConnectionConfig{ - Host: "localhost", - Port: "99999", // Invalid port number - User: "testuser", - Password: "password", - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service) - assert.Contains(t, err.Error(), "invalid port") -} - -func TestNew_ConnectionTimeout(t *testing.T) { - setupTest(t) - cfg := ConnectionConfig{ - Host: "192.0.2.0", // Non-routable IP to simulate timeout - Port: "22", - User: "testuser", - Password: "password", - Timeout: 100 * time.Millisecond, - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service) - assert.Contains(t, err.Error(), "i/o timeout") -} - -func TestNew_AuthFailure_NonexistentKeyfile(t *testing.T) { - setupTest(t) - cfg := ConnectionConfig{ - Host: "localhost", - Port: "22", - User: "testuser", - KeyFile: "/path/to/nonexistent/keyfile", - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service) - assert.ErrorIs(t, err, os.ErrNotExist) -} - -func TestNew_AuthFailure_InvalidKeyFormat(t *testing.T) { - setupTest(t) - // Create a temporary file with invalid key content - tmpFile, err := os.CreateTemp("", "invalid_key") - require.NoError(t, err) - defer func(name string) { - err := os.Remove(name) - if err != nil { - t.Logf("Failed to remove temporary file: %v", err) - } - }(tmpFile.Name()) - - _, err = tmpFile.WriteString("not a valid ssh key") - require.NoError(t, err) - err = tmpFile.Close() - require.NoError(t, err) - - cfg := ConnectionConfig{ - Host: "localhost", - Port: "22", - User: "testuser", - KeyFile: tmpFile.Name(), - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service) - assert.Contains(t, err.Error(), "unable to parse private key") -} - -func TestNew_MultipleAuthMethods(t *testing.T) { - setupTest(t) - // Create a temporary file with invalid key content to ensure key-based auth is attempted - tmpFile, err := os.CreateTemp("", "dummy_key") - require.NoError(t, err) - defer func(name string) { - err := os.Remove(name) - if err != nil { - t.Logf("Failed to remove temporary file: %v", err) - } - }(tmpFile.Name()) - - _, err = tmpFile.WriteString("not a valid ssh key") - require.NoError(t, err) - err = tmpFile.Close() - require.NoError(t, err) - - cfg := ConnectionConfig{ - Host: "localhost", - Port: "22", - User: "testuser", - Password: "password", - KeyFile: tmpFile.Name(), - } - - service, err := New(cfg) - assert.Error(t, err) - assert.Nil(t, service) - // We expect the key file to be prioritized, so we should get a parse error, not a "no auth method" error. - assert.Contains(t, err.Error(), "unable to parse private key") -} diff --git a/pkg/io/webdav/client.go b/pkg/io/webdav/client.go deleted file mode 100644 index 1c6aa52..0000000 --- a/pkg/io/webdav/client.go +++ /dev/null @@ -1,16 +0,0 @@ -package webdav - -import "net/http" - -// Medium implements the io.Medium interface for the WebDAV protocol. -type Medium struct { - client *http.Client - baseURL string // e.g., https://dav.example.com/remote.php/dav/files/username/ -} - -// ConnectionConfig holds the necessary details to connect to a WebDAV server. -type ConnectionConfig struct { - URL string // The full base URL of the WebDAV share. - User string - Password string -} diff --git a/pkg/io/webdav/webdav.go b/pkg/io/webdav/webdav.go deleted file mode 100644 index db0ac66..0000000 --- a/pkg/io/webdav/webdav.go +++ /dev/null @@ -1,183 +0,0 @@ -package webdav - -import ( - "bytes" - _ "context" - "fmt" - "io" - "net/http" - "path" - "strings" -) - -// New creates a new, connected instance of the WebDAV storage medium. -func New(cfg ConnectionConfig) (*Medium, error) { - transport := &authTransport{ - Username: cfg.User, - Password: cfg.Password, - Wrapped: http.DefaultTransport, - } - - httpClient := &http.Client{Transport: transport} - - // Ping the server to ensure the connection and credentials are valid. - // We do a PROPFIND on the root, which is a standard WebDAV operation. - req, err := http.NewRequest("PROPFIND", cfg.URL, nil) - if err != nil { - return nil, fmt.Errorf("webdav: failed to create ping request: %w", err) - } - req.Header.Set("Depth", "0") - resp, err := httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("webdav: connection test failed: %w", err) - } - resp.Body.Close() - if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("webdav: connection test failed with status %s", resp.Status) - } - - return &Medium{ - client: httpClient, - baseURL: cfg.URL, - }, nil -} - -// Read retrieves the content of a file from the WebDAV server. -func (m *Medium) Read(p string) (string, error) { - url := m.resolveURL(p) - resp, err := m.client.Get(url) - if err != nil { - return "", fmt.Errorf("webdav: GET request for %s failed: %w", p, err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("webdav: failed to read %s, status: %s", p, resp.Status) - } - - data, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("webdav: failed to read response body for %s: %w", p, err) - } - - return string(data), nil -} - -// Write saves the given content to a file on the WebDAV server. -func (m *Medium) Write(p, content string) error { - // Ensure the parent directory exists first. - dir := path.Dir(p) - if dir != "." && dir != "/" { - if err := m.EnsureDir(dir); err != nil { - return err // This will be a detailed error from EnsureDir - } - } - - url := m.resolveURL(p) - req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(content))) - if err != nil { - return fmt.Errorf("webdav: failed to create PUT request: %w", err) - } - - resp, err := m.client.Do(req) - if err != nil { - return fmt.Errorf("webdav: PUT request for %s failed: %w", p, err) - } - defer resp.Body.Close() - - // StatusCreated (201) or StatusNoContent (204) are success codes for PUT. - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("webdav: failed to write %s, status: %s", p, resp.Status) - } - - return nil -} - -// EnsureDir makes sure a directory exists on the WebDAV server, creating parent dirs as needed. -func (m *Medium) EnsureDir(p string) error { - // To mimic MkdirAll, we create each part of the path sequentially. - parts := strings.Split(p, "/") - currentPath := "" - for _, part := range parts { - if part == "" { - continue - } - currentPath = path.Join(currentPath, part) - url := m.resolveURL(currentPath) + "/" // MKCOL needs a trailing slash - - req, err := http.NewRequest("MKCOL", url, nil) - if err != nil { - return fmt.Errorf("webdav: failed to create MKCOL request for %s: %w", currentPath, err) - } - - resp, err := m.client.Do(req) - if err != nil { - return fmt.Errorf("webdav: MKCOL request for %s failed: %w", currentPath, err) - } - resp.Body.Close() - - // 405 Method Not Allowed means it already exists, which is fine for us. - // 201 Created is a success. - if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusMethodNotAllowed { - return fmt.Errorf("webdav: failed to create directory %s, status: %s", currentPath, resp.Status) - } - } - return nil -} - -// IsFile checks if a path exists and is a regular file on the WebDAV server. -func (m *Medium) IsFile(p string) bool { - url := m.resolveURL(p) - req, err := http.NewRequest("PROPFIND", url, nil) - if err != nil { - return false - } - req.Header.Set("Depth", "0") - - resp, err := m.client.Do(req) - if err != nil { - return false - } - defer resp.Body.Close() - - // If we get anything other than a Multi-Status, it's probably not a file. - if resp.StatusCode != http.StatusMultiStatus { - return false - } - - // A simple check: if the response body contains the string for a collection, it's a directory. - // A more robust implementation would parse the XML response. - body, err := io.ReadAll(resp.Body) - if err != nil { - return false - } - - return !strings.Contains(string(body), "") -} - -// resolveURL joins the base URL with a path segment, ensuring correct slashes. -func (m *Medium) resolveURL(p string) string { - return strings.TrimSuffix(m.baseURL, "/") + "/" + strings.TrimPrefix(p, "/") -} - -// authTransport is a custom http.RoundTripper to inject Basic Auth. -type authTransport struct { - Username string - Password string - Wrapped http.RoundTripper -} - -func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { - req.SetBasicAuth(t.Username, t.Password) - return t.Wrapped.RoundTrip(req) -} - -// FileGet is a convenience function that reads a file from the medium. -func (m *Medium) FileGet(path string) (string, error) { - return m.Read(path) -} - -// FileSet is a convenience function that writes a file to the medium. -func (m *Medium) FileSet(path, content string) error { - return m.Write(path, content) -} diff --git a/pkg/io/webdav/webdav_test.go b/pkg/io/webdav/webdav_test.go deleted file mode 100644 index a0b2602..0000000 --- a/pkg/io/webdav/webdav_test.go +++ /dev/null @@ -1,155 +0,0 @@ -package webdav - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/assert" -) - -// mockWebDAVServer creates a test HTTP server that mimics a WebDAV server. -func mockWebDAVServer() *httptest.Server { - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case "PROPFIND": - if r.URL.Path == "/" { - w.WriteHeader(http.StatusMultiStatus) - return - } - // For IsFile test - if r.URL.Path == "/test.txt" { - w.WriteHeader(http.StatusMultiStatus) - fmt.Fprint(w, ` - - - /test.txt - - - - - HTTP/1.1 200 OK - - -`) - return - } - if r.URL.Path == "/testdir/" { - w.WriteHeader(http.StatusMultiStatus) - fmt.Fprint(w, ` - - - /testdir/ - - - - - HTTP/1.1 200 OK - - -`) - return - } - http.NotFound(w, r) - case "GET": - if r.URL.Path == "/test.txt" { - w.WriteHeader(http.StatusOK) - fmt.Fprint(w, "Hello, WebDAV!") - return - } - http.NotFound(w, r) - case "PUT": - if r.URL.Path == "/test.txt" { - w.WriteHeader(http.StatusCreated) - return - } - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - case "MKCOL": - if r.URL.Path == "/testdir/" { - w.WriteHeader(http.StatusCreated) - return - } - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - default: - http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) - } - }) - return httptest.NewServer(handler) -} - -func TestNew_Success(t *testing.T) { - server := mockWebDAVServer() - defer server.Close() - - cfg := ConnectionConfig{ - URL: server.URL, - User: "user", - Password: "password", - } - - medium, err := New(cfg) - assert.NoError(t, err) - assert.NotNil(t, medium) -} - -func TestRead(t *testing.T) { - server := mockWebDAVServer() - defer server.Close() - - cfg := ConnectionConfig{ - URL: server.URL, - User: "user", - Password: "password", - } - medium, err := New(cfg) - assert.NoError(t, err) - content, err := medium.Read("test.txt") - assert.NoError(t, err) - assert.Equal(t, "Hello, WebDAV!", content) -} - -func TestWrite(t *testing.T) { - server := mockWebDAVServer() - defer server.Close() - - cfg := ConnectionConfig{ - URL: server.URL, - User: "user", - Password: "password", - } - medium, err := New(cfg) - assert.NoError(t, err) - err = medium.Write("test.txt", "Hello, WebDAV!") - assert.NoError(t, err) -} - -func TestEnsureDir(t *testing.T) { - server := mockWebDAVServer() - defer server.Close() - - cfg := ConnectionConfig{ - URL: server.URL, - User: "user", - Password: "password", - } - medium, err := New(cfg) - assert.NoError(t, err) - err = medium.EnsureDir("testdir") - assert.NoError(t, err) -} - -func TestIsFile(t *testing.T) { - server := mockWebDAVServer() - defer server.Close() - - cfg := ConnectionConfig{ - URL: server.URL, - User: "user", - Password: "password", - } - medium, err := New(cfg) - assert.NoError(t, err) - assert.True(t, medium.IsFile("test.txt")) - assert.False(t, medium.IsFile("testdir")) -} diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go deleted file mode 100644 index dd4329b..0000000 --- a/pkg/runtime/runtime_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package runtime_test - -import ( - "errors" - "testing" - - "github.com/Snider/Core/pkg/crypt" - "github.com/Snider/Core/pkg/runtime" - "github.com/Snider/Core/pkg/workspace" - "github.com/stretchr/testify/assert" - "github.com/wailsapp/wails/v3/pkg/application" -) - -func TestNew(t *testing.T) { - testCases := []struct { - name string - app *application.App - factories map[string]runtime.ServiceFactory - expectErr bool - expectErrStr string - checkRuntime func(*testing.T, *runtime.Runtime) - }{ - { - name: "Good path", - app: nil, - factories: map[string]runtime.ServiceFactory{ - "crypt": func() (any, error) { return &crypt.Service{}, nil }, - "workspace": func() (any, error) { return &workspace.Service{}, nil }, - }, - expectErr: false, - checkRuntime: func(t *testing.T, rt *runtime.Runtime) { - assert.NotNil(t, rt) - assert.NotNil(t, rt.Core) - assert.NotNil(t, rt.Crypt) - assert.NotNil(t, rt.Workspace) - }, - }, - { - name: "Factory returns an error", - app: nil, - factories: map[string]runtime.ServiceFactory{ - "crypt": func() (any, error) { return nil, errors.New("crypt service failed") }, - "workspace": func() (any, error) { return &workspace.Service{}, nil }, - }, - expectErr: true, - expectErrStr: "failed to create service crypt: crypt service failed", - }, - { - name: "Factory returns wrong type", - app: nil, - factories: map[string]runtime.ServiceFactory{ - "crypt": func() (any, error) { return "not a crypt service", nil }, - "workspace": func() (any, error) { return &workspace.Service{}, nil }, - }, - expectErr: true, - expectErrStr: "crypt service has unexpected type", - }, - { - name: "With non-nil app", - app: &application.App{}, - factories: map[string]runtime.ServiceFactory{ - "crypt": func() (any, error) { return &crypt.Service{}, nil }, - "workspace": func() (any, error) { return &workspace.Service{}, nil }, - }, - expectErr: false, - checkRuntime: func(t *testing.T, rt *runtime.Runtime) { - assert.NotNil(t, rt) - assert.NotNil(t, rt.Core) - assert.NotNil(t, rt.Core.App) - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - rt, err := runtime.NewWithFactories(tc.app, tc.factories) - - if tc.expectErr { - assert.Error(t, err) - assert.Contains(t, err.Error(), tc.expectErrStr) - assert.Nil(t, rt) - } else { - assert.NoError(t, err) - if tc.checkRuntime != nil { - tc.checkRuntime(t, rt) - } - } - }) - } -} diff --git a/pkg/workspace/local.go b/pkg/workspace/local.go deleted file mode 100644 index 27769f7..0000000 --- a/pkg/workspace/local.go +++ /dev/null @@ -1,41 +0,0 @@ -package workspace - -import "github.com/Snider/Core/pkg/io" - -// localMedium implements the Medium interface for the local disk. -type localMedium struct{} - -// NewLocalMedium creates a new instance of the local storage medium. -func NewLocalMedium() io.Medium { - return &localMedium{} -} - -// FileGet reads a file from the local disk. -func (m *localMedium) FileGet(path string) (string, error) { - return io.Read(io.Local, path) -} - -// FileSet writes a file to the local disk. -func (m *localMedium) FileSet(path, content string) error { - return io.Write(io.Local, path, content) -} - -// Read reads a file from the local disk. -func (m *localMedium) Read(path string) (string, error) { - return io.Read(io.Local, path) -} - -// Write writes a file to the local disk. -func (m *localMedium) Write(path, content string) error { - return io.Write(io.Local, path, content) -} - -// EnsureDir creates a directory on the local disk. -func (m *localMedium) EnsureDir(path string) error { - return io.EnsureDir(io.Local, path) -} - -// IsFile checks if a path exists and is a file on the local disk. -func (m *localMedium) IsFile(path string) bool { - return io.IsFile(io.Local, path) -} diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go deleted file mode 100644 index 230db81..0000000 --- a/pkg/workspace/workspace.go +++ /dev/null @@ -1,227 +0,0 @@ -package workspace - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - - "github.com/Snider/Core/pkg/core" - "github.com/Snider/Core/pkg/crypt/lthn" - "github.com/Snider/Core/pkg/crypt/openpgp" - "github.com/Snider/Core/pkg/e" - "github.com/Snider/Core/pkg/io" - "github.com/wailsapp/wails/v3/pkg/application" -) - -const ( - defaultWorkspace = "default" - listFile = "list.json" -) - -// Options holds configuration for the workspace service. -type Options struct{} - -// Workspace represents a user's workspace. -type Workspace struct { - Name string - Path string -} - -// Service manages user workspaces. -type Service struct { - *core.Runtime[Options] - activeWorkspace *Workspace - workspaceList map[string]string // Maps Workspace ID to Public Key - medium io.Medium -} - -// newWorkspaceService contains the common logic for initializing a Service struct. -// It no longer takes config and medium as arguments. -func newWorkspaceService() (*Service, error) { - s := &Service{ - workspaceList: make(map[string]string), - } - return s, nil -} - -// New is the constructor for static dependency injection. -// It creates a Service instance without initializing the core.Runtime field. -// Dependencies are passed directly here. -func New() (*Service, error) { - s, err := newWorkspaceService() - if err != nil { - return nil, e.E("workspace.New", "failed to create new workspace service", err) - } - //s.medium = medium - // Initialize the service after creation. - // Note: ServiceStartup will now get config from s.Runtime.Config() - //if err := s.ServiceStartup(context.Background(), application.ServiceOptions{}); err != nil { - // return nil, e.E("workspace.New", "workspace service startup failed", err) - //} - return s, nil -} - -// Register is the constructor for dynamic dependency injection (used with core.WithService). -// It creates a Service instance and initializes its core.Runtime field. -// Dependencies are injected during ServiceStartup. -func Register(c *core.Core) (any, error) { - s, err := newWorkspaceService() - if err != nil { - return nil, e.E("workspace.Register", "failed to create new workspace service", err) - } - s.Runtime = core.NewRuntime(c, Options{}) - return s, nil -} - -// HandleIPCEvents processes IPC messages, including injecting dependencies on startup. -func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { - switch m := msg.(type) { - case map[string]any: - if action, ok := m["action"].(string); ok && action == "workspace.switch_workspace" { - return s.SwitchWorkspace(m["name"].(string)) - } - case core.ActionServiceStartup: - return s.ServiceStartup(context.Background(), application.ServiceOptions{}) - default: - c.App.Logger.Error("Workspace: Unknown message type", "type", fmt.Sprintf("%T", m)) - } - return nil -} - -// getWorkspaceDir retrieves the WorkspaceDir from the config service. -func (s *Service) getWorkspaceDir() (string, error) { - var workspaceDir string - if err := s.Config().Get("workspaceDir", &workspaceDir); err != nil { - return "", e.E("workspace.getWorkspaceDir", "failed to get WorkspaceDir from config", err) - } - return workspaceDir, nil -} - -// ServiceStartup initializes the service, loading the workspace list. -func (s *Service) ServiceStartup(context.Context, application.ServiceOptions) error { - var err error - workspaceDir, err := s.getWorkspaceDir() - if err != nil { - return err - } - - listPath := filepath.Join(workspaceDir, listFile) - if listPath != "" { - } - //if s.medium.IsFile(listPath) { - // content, err := s.medium.FileGet(listPath) - // if err != nil { - // return e.E("workspace.ServiceStartup", "failed to read workspace list", err) - // } - // if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil { - // fmt.Printf("Warning: could not parse workspace list: %v\n", err) - // s.workspaceList = make(map[string]string) - // } - //} - - return s.SwitchWorkspace(defaultWorkspace) -} - -// CreateWorkspace creates a new, obfuscated workspace on the local medium. -func (s *Service) CreateWorkspace(identifier, password string) (string, error) { - workspaceDir, err := s.getWorkspaceDir() - if err != nil { - return "", err - } - - realName := lthn.Hash(identifier) - workspaceID := lthn.Hash(fmt.Sprintf("workspace/%s", realName)) - workspacePath := filepath.Join(workspaceDir, workspaceID) - - if _, exists := s.workspaceList[workspaceID]; exists { - return "", e.E("workspace.CreateWorkspace", "workspace for this identifier already exists", nil) - } - - dirsToCreate := []string{"config", "log", "data", "files", "keys"} - for _, dir := range dirsToCreate { - if err := s.medium.EnsureDir(filepath.Join(workspacePath, dir)); err != nil { - return "", e.E("workspace.CreateWorkspace", fmt.Sprintf("failed to create workspace directory '%s'", dir), err) - } - } - - keyPair, err := openpgp.CreateKeyPair(workspaceID, password) - if err != nil { - return "", e.E("workspace.CreateWorkspace", "failed to create workspace key pair", err) - } - - keyFiles := map[string]string{ - filepath.Join(workspacePath, "keys", "key.pub"): keyPair.PublicKey, - filepath.Join(workspacePath, "keys", "key.priv"): keyPair.PrivateKey, - } - for path, content := range keyFiles { - if err := s.medium.FileSet(path, content); err != nil { - return "", e.E("workspace.CreateWorkspace", fmt.Sprintf("failed to write key file %s", path), err) - } - } - - s.workspaceList[workspaceID] = keyPair.PublicKey - listData, err := json.MarshalIndent(s.workspaceList, "", " ") - if err != nil { - return "", e.E("workspace.CreateWorkspace", "failed to marshal workspace list", err) - } - - listPath := filepath.Join(workspaceDir, listFile) - if err := s.medium.FileSet(listPath, string(listData)); err != nil { - return "", e.E("workspace.CreateWorkspace", "failed to write workspace list file", err) - } - - return workspaceID, nil -} - -// SwitchWorkspace changes the active workspace. -func (s *Service) SwitchWorkspace(name string) error { - workspaceDir, err := s.getWorkspaceDir() - if err != nil { - return err - } - - if name != defaultWorkspace { - if _, exists := s.workspaceList[name]; !exists { - return e.E("workspace.SwitchWorkspace", fmt.Sprintf("workspace '%s' does not exist", name), nil) - } - } - - path := filepath.Join(workspaceDir, name) - //if err := s.medium.EnsureDir(path); err != nil { - // return e.E("workspace.SwitchWorkspace", "failed to ensure workspace directory exists", err) - //} - - s.activeWorkspace = &Workspace{ - Name: name, - Path: path, - } - - return nil -} - -// WorkspaceFileGet retrieves a file from the active workspace. -func (s *Service) WorkspaceFileGet(filename string) (string, error) { - if s.activeWorkspace == nil { - return "", e.E("workspace.WorkspaceFileGet", "no active workspace", nil) - } - path := filepath.Join(s.activeWorkspace.Path, filename) - content, err := s.medium.FileGet(path) - if err != nil { - return "", e.E("workspace.WorkspaceFileGet", "failed to get file", err) - } - return content, nil -} - -// WorkspaceFileSet writes a file to the active workspace. -func (s *Service) WorkspaceFileSet(filename, content string) error { - if s.activeWorkspace == nil { - return e.E("workspace.WorkspaceFileSet", "no active workspace", nil) - } - path := filepath.Join(s.activeWorkspace.Path, filename) - err := s.medium.FileSet(path, content) - if err != nil { - return e.E("workspace.WorkspaceFileSet", "failed to set file", err) - } - return nil -} diff --git a/pkg/workspace/workspace_test.go b/pkg/workspace/workspace_test.go deleted file mode 100644 index 3af293f..0000000 --- a/pkg/workspace/workspace_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package workspace - -import ( - "context" - "encoding/json" - "fmt" - "path/filepath" - "testing" - - "github.com/Snider/Core/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/wailsapp/wails/v3/pkg/application" -) - -// mockConfig is a mock implementation of the core.Config interface for testing. -type mockConfig struct { - values map[string]interface{} -} - -func (m *mockConfig) Get(key string, out any) error { - val, ok := m.values[key] - if !ok { - return fmt.Errorf("key not found: %s", key) - } - // This is a simplified mock; a real one would use reflection to set `out` - switch v := out.(type) { - case *string: - *v = val.(string) - default: - return fmt.Errorf("unsupported type in mock config Get") - } - return nil -} - -func (m *mockConfig) Set(key string, v any) error { - m.values[key] = v - return nil -} - -// MockMedium implements the Medium interface for testing purposes. -type MockMedium struct { - Files map[string]string - Dirs map[string]bool -} - -func NewMockMedium() *MockMedium { - return &MockMedium{ - Files: make(map[string]string), - Dirs: make(map[string]bool), - } -} - -func (m *MockMedium) FileGet(path string) (string, error) { - content, ok := m.Files[path] - if !ok { - return "", assert.AnError // Simulate file not found error - } - return content, nil -} - -func (m *MockMedium) FileSet(path, content string) error { - m.Files[path] = content - return nil -} - -func (m *MockMedium) EnsureDir(path string) error { - m.Dirs[path] = true - return nil -} - -func (m *MockMedium) IsFile(path string) bool { - _, exists := m.Files[path] - return exists -} - -func (m *MockMedium) Read(path string) (string, error) { - return m.FileGet(path) -} - -func (m *MockMedium) Write(path, content string) error { - return m.FileSet(path, content) -} - -// newTestService creates a workspace service instance with mocked dependencies. -func newTestService(t *testing.T, workspaceDir string) (*Service, *MockMedium) { - coreInstance, err := core.New() - assert.NoError(t, err) - - mockCfg := &mockConfig{values: map[string]interface{}{"workspaceDir": workspaceDir}} - coreInstance.RegisterService("config", mockCfg) - - service, err := New() - assert.NoError(t, err) - - service.Runtime = core.NewRuntime(coreInstance, Options{}) - mockMedium := NewMockMedium() - service.medium = mockMedium - - return service, mockMedium -} - -func TestServiceStartup(t *testing.T) { - workspaceDir := "/tmp/workspace" - - t.Run("existing valid list.json", func(t *testing.T) { - service, mockMedium := newTestService(t, workspaceDir) - - expectedWorkspaceList := map[string]string{ - "workspace1": "pubkey1", - "workspace2": "pubkey2", - } - listContent, _ := json.MarshalIndent(expectedWorkspaceList, "", " ") - listPath := filepath.Join(workspaceDir, listFile) - mockMedium.Files[listPath] = string(listContent) - - err := service.ServiceStartup(context.Background(), application.ServiceOptions{}) - - assert.NoError(t, err) - // assert.Equal(t, expectedWorkspaceList, service.workspaceList) // This check is difficult with current implementation - assert.NotNil(t, service.activeWorkspace) - assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name) - }) -} - -func TestCreateAndSwitchWorkspace(t *testing.T) { - workspaceDir := "/tmp/workspace" - service, _ := newTestService(t, workspaceDir) - - // Create - workspaceID, err := service.CreateWorkspace("test", "password") - assert.NoError(t, err) - assert.NotEmpty(t, workspaceID) - - // Switch - err = service.SwitchWorkspace(workspaceID) - assert.NoError(t, err) - assert.Equal(t, workspaceID, service.activeWorkspace.Name) -} diff --git a/pkg/runtime/runtime.go b/runtime/runtime.go similarity index 69% rename from pkg/runtime/runtime.go rename to runtime/runtime.go index 80f315e..7a0a194 100644 --- a/pkg/runtime/runtime.go +++ b/runtime/runtime.go @@ -4,19 +4,15 @@ import ( "context" "fmt" - "github.com/Snider/Core/pkg/core" - "github.com/Snider/Core/pkg/crypt" - "github.com/Snider/Core/pkg/workspace" + "github.com/Snider/Core/core" "github.com/wailsapp/wails/v3/pkg/application" ) // Runtime is the container that holds all instantiated services. // Its fields are the concrete types, allowing Wails to bind them directly. type Runtime struct { - app *application.App - Core *core.Core - Crypt *crypt.Service - Workspace *workspace.Service + app *application.App + Core *core.Core } // ServiceFactory defines a function that creates a service instance. @@ -29,7 +25,7 @@ func NewWithFactories(app *application.App, factories map[string]ServiceFactory) core.WithWails(app), } - for _, name := range []string{"crypt", "workspace"} { + for _, name := range []string{} { factory, ok := factories[name] if !ok { return nil, fmt.Errorf("service %s factory not provided", name) @@ -49,20 +45,10 @@ func NewWithFactories(app *application.App, factories map[string]ServiceFactory) } // --- Type Assertions --- - cryptSvc, ok := services["crypt"].(*crypt.Service) - if !ok { - return nil, fmt.Errorf("crypt service has unexpected type") - } - workspaceSvc, ok := services["workspace"].(*workspace.Service) - if !ok { - return nil, fmt.Errorf("workspace service has unexpected type") - } rt := &Runtime{ - app: app, - Core: coreInstance, - Crypt: cryptSvc, - Workspace: workspaceSvc, + app: app, + Core: coreInstance, } return rt, nil @@ -70,10 +56,7 @@ func NewWithFactories(app *application.App, factories map[string]ServiceFactory) // New creates and wires together all application services. func New(app *application.App) (*Runtime, error) { - return NewWithFactories(app, map[string]ServiceFactory{ - "crypt": func() (any, error) { return crypt.New() }, - "workspace": func() (any, error) { return workspace.New() }, - }) + return NewWithFactories(app, map[string]ServiceFactory{}) } // ServiceName returns the name of the service. This is used by Wails to identify the service. diff --git a/runtime/runtime_test.go b/runtime/runtime_test.go new file mode 100644 index 0000000..3993329 --- /dev/null +++ b/runtime/runtime_test.go @@ -0,0 +1,59 @@ +package runtime_test + +import ( + "testing" + + "github.com/Snider/Core/runtime" + "github.com/stretchr/testify/assert" + "github.com/wailsapp/wails/v3/pkg/application" +) + +func TestNew(t *testing.T) { + testCases := []struct { + name string + app *application.App + factories map[string]runtime.ServiceFactory + expectErr bool + expectErrStr string + checkRuntime func(*testing.T, *runtime.Runtime) + }{ + { + name: "Good path", + app: nil, + factories: map[string]runtime.ServiceFactory{}, + expectErr: false, + checkRuntime: func(t *testing.T, rt *runtime.Runtime) { + assert.NotNil(t, rt) + assert.NotNil(t, rt.Core) + }, + }, + { + name: "With non-nil app", + app: &application.App{}, + factories: map[string]runtime.ServiceFactory{}, + expectErr: false, + checkRuntime: func(t *testing.T, rt *runtime.Runtime) { + assert.NotNil(t, rt) + assert.NotNil(t, rt.Core) + assert.NotNil(t, rt.Core.App) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rt, err := runtime.NewWithFactories(tc.app, tc.factories) + + if tc.expectErr { + assert.Error(t, err) + assert.Contains(t, err.Error(), tc.expectErrStr) + assert.Nil(t, rt) + } else { + assert.NoError(t, err) + if tc.checkRuntime != nil { + tc.checkRuntime(t, rt) + } + } + }) + } +}