refactor: Remove unused packages and flatten project structure (#27)
* refactor: Remove unused packages and flatten project structure Removes the following unused packages: - pkg/crypt - pkg/workspace - pkg/io Moves the remaining packages (core, e, runtime) to the top level of the project. Updates all import paths to reflect the new structure. * refactor: Remove unused packages and flatten project structure Removes the following unused packages: - pkg/crypt - pkg/workspace - pkg/io Moves the remaining packages (core, e, runtime) to the top level of the project. Updates all import paths to reflect the new structure. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
This commit is contained in:
parent
614aed51ba
commit
67a38acc7c
48 changed files with 75 additions and 2886 deletions
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
|
|||
8
go.mod
8
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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-----
|
||||
|
||||
lQPGBGkD3McBCADPlKJ5MflaxEcDWyMowoNJltHrB9fIsrOY8aaGgm0kzTcWTmi+
|
||||
sdlpLpb4ADWZbtrs/3LbuXAFvhb+Zu+ZN/CO5D5RnZLNd2N+eGCNz/v6p87HCvM6
|
||||
aWxufD+ZJaWvDnWjBt7aO7XydRPx/GyrZ2s8513WYgF83R603bcRv4zdhA7aJHGA
|
||||
IG++PO0jkHKkv0xQ7OmUmjQrYVLV5cG2vQzpQeL81tyfkxb4Rz9gm+Gho5T2v9me
|
||||
Y2ss58/Lny00aneJokBY+x1nGOQKB/Liy7Ub2au9MKKDkitP1F2f2tnp1O/IXqgI
|
||||
tKDKbRz/KipgKbwFrhYBCOl5JjiwzHud/3/HABEBAAH+BwMCZZwQKhGMMAz/Q405
|
||||
dgMVbXRdhSS6jyOCkL5AOKhJWddMEo4/52Sq30pfsT+n0zZjGE7ivpXbJa6ekQYD
|
||||
MFtfueuz2W8cbn+3wP7W2NFnl+UWcw6BlskzPusd7eIqEjCToic1aJLdbs32Q5B/
|
||||
FE7hJrCRzUOeByfEl1e2Uzmy5JJ3Y6bgpDHPhC38uLMZXdpbkboi5R20UmNe0iDo
|
||||
X3v52Wv2Sdb2d8LUrXo7spTGfEDe1f0NTq9NbYMOPSwz912bDmf+nWjjRUPrBh/H
|
||||
w1d66oLtJlQSCt6vLkqoMMViFa8V57XzKrqdpcfu70ydEr7mCmpOgch9OopTM2Dk
|
||||
MlDldUqWt5YCABybmKYOyA2bWX3yYEWi4OiGNhZP1VZwoSiFcsm6/s+p4xHGGWwR
|
||||
+tdakCBqoRaDaMjdVGNA9+mebRJVHcKFsivl4qjT8E55ky8Qq70KhKJ+Vzu9Om3O
|
||||
NiEsrNofdcXiRjVZLejuNbqkO1wDfW0CoNSbFYscOv85AHVk/93w8IvGzvEmOZ3X
|
||||
ILcoIZmIrtoSj4Fu8qQXUD1f+t+hYFV8V+T6YDDmtWIn73VQpHYB7j2UJpq9mZAp
|
||||
CDXxgzm1zgYwZEQ1p/yR8tVeP/hnsE+Dc79iJO72BMzbhuXEkqMWzs9AurdeAaSD
|
||||
p6l0+hr08w9v9d9YEXn8Cjx2p3G6iUA3Rd2vXwuBT2dEtbf+qcskFGqyGo4hOCzW
|
||||
qvbszNMR4yIqtiPipmFq9UCPgBceXb8zJjOylXsf+kKQkBrm4vpMfo+m4xYO8kAp
|
||||
w2gXAs5ozEfkPBYx132QTpYY+dx8lgZ9lD2EgrELfCU0IfCo2C+MksF/v6Ib5rY3
|
||||
eOTNfmsmsnsOr9pfGs65weWxO0VXe39IW4327cSetaviGophWrGsmgRTzs8KBU9j
|
||||
9OBmtXbmGr0LtBlKdWxlcyA8anVsZXNAZXhhbXBsZS5jb20+iQFSBBMBCgA8FiEE
|
||||
lfAo9dBZEKASnLDSjhMM0QOAK2wFAmkD3McDGy8EBQsJCAcCAiICBhUKCQgLAgQW
|
||||
AgMBAh4HAheAAAoJEI4TDNEDgCtsnCoH+wWmcrRgvrO2qHzPROkP9J7xrHnKO7qF
|
||||
+G/1DsCMMkn6fmIgpkCpEYjfZXHIyA6vsOlxDdoxyjpTQUh6lyDlZbrr0klMtgq1
|
||||
9yDyPF3ONJyoLLJeHlLbN+Zgv68R+EkXFI/7w5w8DMc7dq//wibDaBeQ390KjxOc
|
||||
k3lQF+239D0tZ3x9Fdt6JXNrksfkJ8vIQvgANOBFXYIL0KtwqdRbe+L1pKtQXehG
|
||||
7jVgaLgPrC6hqc0dGqLliuxyijA5MgnRUXBX2cNXoUpJBDbgKyuVKzRYQ2X3U4Gz
|
||||
g12Vlt/b19O70j2SfQdBY5sPlJjP6FBfXd299GL4HnNrcVJqwmfPnVCdA8YEaQPc
|
||||
xwEIALEansmoX/FrDCubfde3cXyJ3jOtHXjBgFyWd8J2ad1gvfMbCHteoR86azaR
|
||||
JkUN+zwDpjkYslUy9xVVIL2b4sTXHO6+hw14dQS8mq0+tEKXzGcKuTrno9lU02l3
|
||||
My5ZHY/PB7dfeLC6sGBMXwdbT68wIAy6/guEWRaZWPNJy3l9IrvjxBdMALLAsGTH
|
||||
ol4hKUBRCd0/cAsaIpbq4JOu1os3kRAgfZqeqXSY8G6ioZ/ft5s6nMN4IjUD/tdJ
|
||||
48ZOfoaMRZcSOv8jgoRvYksYNeiqmgYrn17tgCL1z14cjvXrijd8f90dJxeseIEL
|
||||
exETG/Bu0G+lpKU4XC014Vk4l2EAEQEAAf4HAwKcyR3KYk6DBP/wZlQffclC9iAU
|
||||
Oifv5Dxzw1KaloYEir4cBUGYTlcuXcdJV4GXpytX4d+4fTKBO5Kr60I3NYHj3Zs+
|
||||
yK9Vm0ZXjFFMikSxymDdsVaW6PA4WdVpPEam7bqCmApeKT0SSPwVhaBBVALGB55i
|
||||
KFSXyB2DExSzKEuH0sKOLoy+jGqCBVTwUEFVMN7sInXVog1PQGjy472fyI5od/GD
|
||||
F6utVttmthnvVNAHleIeDYzWZD7iOQkl6S7bT/zn4eggTMz/9B5GJ1KkQtjXGfrW
|
||||
9VezVdpUeWLI11WyMxFLBLGQOoVrNWZA4AAPTDReCPT4uGTSnmTVrBSWgOg+2e55
|
||||
aiPak7TXxm3UShqk7A9okgxKkndVsqKYQ2Ry6xfmgdYW68/4xQjqNcPFCVg5YGnk
|
||||
+DbaOS6XVUl6v2QMSNtdONQ3ybhH/ervNV/KLIweg1DRfdi34ixO19QEOEONpenq
|
||||
C2Ap8knptxcBd+M0e6l9vppndrx5R/Y4reg7ZTLt0OX9Gdkwsb9DRLfVFwLmsZ5+
|
||||
hw0e/k5NYkLB3lWw+m+JtKCOpU69U+MY8t4OhvosOFW0Kxm/6tJZKKkpRTfewd1f
|
||||
qbPc4RLE9K0kZW8BDqig6m3flV54jpR7bmPTW1Y/YUn33QXj6wqUec+CSLm349UQ
|
||||
NhwmF7opapbo+XYD8by6xdeOZ/WnTtKKBy3x6uEIRes3zGcGkZ+ROx564i1v1/h3
|
||||
yZ5zrWggWUkeoPzenqWqj1i2QxxgzkxtkqAf/9aKmpp5MNXs25K+ZHFxiwHcCPOe
|
||||
8pVQF0sY61b7EzHoUhq7CkpTYOuvPoHii3m5EAnH+EO66EqSbEemo3FEQQemeQi0
|
||||
EGEiqfh2g1iLSxW54L3Y9Qzh+6B22/ydgccQIL/CxIdofipp4NdoN8iF6gHLm/nS
|
||||
GzKJAmwEGAEKACAWIQSV8Cj10FkQoBKcsNKOEwzRA4ArbAUCaQPcxwIbLgFACRCO
|
||||
EwzRA4ArbMB0IAQZAQoAHRYhBDR5obYfDIFSrsYWVYf4NG7oaR8CBQJpA9zHAAoJ
|
||||
EIf4NG7oaR8CaHYH/1LxfQ+AHKsrYDul0U/h165EPzeX+mhHyBAqVuYIlyBPDMc/
|
||||
sAN83WW7yTXh2VWeE+BQVzdOdz2Mu53Al42+TJVnmc6YrRu2th5vdVvOTPKUFqJ+
|
||||
mbWg8xJPrBoQ2UrZ5oFMgwYUfMvYG94mVxA8K0Uw6LXjmxZ2P816j68FqIPn+o42
|
||||
GoL8muMAWZ4Xd/GJwdtj9R/xJA9DZlNgYH2/I5qK5OMrlDTJ09jivFO1deVhMHbC
|
||||
LH+zdIt5uNoLT6VNANBmbfYn0gX46goeu8jdpusN+8QC7Phq1/L3x8IfHTbmBbKN
|
||||
0NyfETsLs2pmAC+7av8JClw/SxFQppispaBRXm3RfwgAtvzV16+0HT0uQHWulkk+
|
||||
RzulVS8s3BwtjCp1ZPsprJ/AyAxGpU+7iquqe+Voe6Tv5AJ3ongccYTwqFMeElkf
|
||||
JAI+iWfgV1NF2bxm2Wq+nMSL9jrO9aF0unQ9/CI/gKca1656n2ZPSuG4s7mjC1Sl
|
||||
9+GqgZGNR+Isg2dx1yzt7wT0H8SO0fyadp71JMuGI9F5ftUw7jQYvqIuI37an5Mx
|
||||
l3PZ2jSJ4ozNpaAWkNUOQz+o8xCr8qcumXct0FME8H5tiMe3KJn6TJ7eOwfEZ7oD
|
||||
BYR9EUvXQxCicuW/pne/wtn78JvpRxiJxcwVYy+azfunx/Cl8BbxMVLDr0y49lNM
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"])
|
||||
}
|
||||
27
pkg/io/io.go
27
pkg/io/io.go
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package local
|
||||
|
||||
// Medium implements the io.Medium interface for the local disk.
|
||||
type Medium struct {
|
||||
root string
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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), "<D:collection/>")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
|
@ -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, `<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<D:multistatus xmlns:D=\"DAV:\">
|
||||
<D:response>
|
||||
<D:href>/test.txt</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype/>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>`)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/testdir/" {
|
||||
w.WriteHeader(http.StatusMultiStatus)
|
||||
fmt.Fprint(w, `<?xml version=\"1.0\" encoding=\"UTF-8\"?>
|
||||
<D:multistatus xmlns:D=\"DAV:\">
|
||||
<D:response>
|
||||
<D:href>/testdir/</D:href>
|
||||
<D:propstat>
|
||||
<D:prop>
|
||||
<D:resourcetype><D:collection/></D:resourcetype>
|
||||
</D:prop>
|
||||
<D:status>HTTP/1.1 200 OK</D:status>
|
||||
</D:propstat>
|
||||
</D:response>
|
||||
</D:multistatus>`)
|
||||
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"))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
59
runtime/runtime_test.go
Normal file
59
runtime/runtime_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue