Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
424
native/desktop/maplefile/pkg/crypto/crypto.go
Normal file
424
native/desktop/maplefile/pkg/crypto/crypto.go
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
// monorepo/native/desktop/maplefile-cli/pkg/crypto/crypto.go
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
|
||||
"github.com/tyler-smith/go-bip39"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/chacha20poly1305"
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
)
|
||||
|
||||
const (
|
||||
// Key sizes
|
||||
MasterKeySize = 32 // 256-bit
|
||||
KeyEncryptionKeySize = 32
|
||||
RecoveryKeySize = 32
|
||||
CollectionKeySize = 32
|
||||
FileKeySize = 32
|
||||
|
||||
// ChaCha20-Poly1305 (symmetric encryption) constants
|
||||
ChaCha20Poly1305KeySize = 32 // ChaCha20 key size
|
||||
ChaCha20Poly1305NonceSize = 12 // ChaCha20-Poly1305 nonce size
|
||||
ChaCha20Poly1305Overhead = 16 // Poly1305 authentication tag size
|
||||
|
||||
// Legacy naming for backward compatibility
|
||||
SecretBoxKeySize = ChaCha20Poly1305KeySize
|
||||
SecretBoxNonceSize = ChaCha20Poly1305NonceSize
|
||||
SecretBoxOverhead = ChaCha20Poly1305Overhead
|
||||
|
||||
// Box (asymmetric encryption) constants
|
||||
BoxPublicKeySize = 32
|
||||
BoxSecretKeySize = 32
|
||||
BoxNonceSize = 24
|
||||
BoxOverhead = box.Overhead
|
||||
BoxSealOverhead = BoxPublicKeySize + BoxOverhead
|
||||
|
||||
// Argon2 parameters - must match between platforms
|
||||
Argon2IDAlgorithm = "argon2id"
|
||||
Argon2MemLimit = 4 * 1024 * 1024 // 4 MB (matching your internal/common/crypto settings)
|
||||
Argon2OpsLimit = 1 // 1 iteration (matching your settings)
|
||||
Argon2Parallelism = 1
|
||||
Argon2KeySize = 32
|
||||
Argon2SaltSize = 16
|
||||
|
||||
// Encryption algorithm identifiers
|
||||
ChaCha20Poly1305Algorithm = "chacha20poly1305"
|
||||
BoxSealAlgorithm = "box_seal"
|
||||
)
|
||||
|
||||
// EncryptedData represents encrypted data with its nonce
|
||||
type EncryptedData struct {
|
||||
Ciphertext []byte
|
||||
Nonce []byte
|
||||
}
|
||||
|
||||
// GenerateRandomBytes generates cryptographically secure random bytes
|
||||
func GenerateRandomBytes(size int) ([]byte, error) {
|
||||
if size <= 0 {
|
||||
return nil, errors.New("size must be positive")
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
_, err := io.ReadFull(rand.Reader, buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// GenerateVerificationID creates a human-readable representation of a public key
|
||||
// JavaScript equivalent: The same BIP39 mnemonic implementation
|
||||
// Generate VerificationID from public key (deterministic)
|
||||
func GenerateVerificationID(publicKey []byte) (string, error) {
|
||||
if publicKey == nil {
|
||||
err := fmt.Errorf("no public key entered")
|
||||
log.Printf("pkg.crypto.VerifyVerificationID - Failed to generate verification ID with error: %v\n", err)
|
||||
return "", fmt.Errorf("failed to generate verification ID: %w", err)
|
||||
}
|
||||
|
||||
// 1. Hash the public key with SHA256
|
||||
hash := sha256.Sum256(publicKey[:])
|
||||
|
||||
// 2. Use the hash as entropy for BIP39
|
||||
mnemonic, err := bip39.NewMnemonic(hash[:])
|
||||
if err != nil {
|
||||
log.Printf("pkg.crypto.VerifyVerificationID - Failed to generate verification ID with error: %v\n", err)
|
||||
return "", fmt.Errorf("failed to generate verification ID: %w", err)
|
||||
}
|
||||
|
||||
return mnemonic, nil
|
||||
}
|
||||
|
||||
// Verify VerificationID matches public key
|
||||
func VerifyVerificationID(publicKey []byte, verificationID string) bool {
|
||||
expectedID, err := GenerateVerificationID(publicKey)
|
||||
if err != nil {
|
||||
log.Printf("pkg.crypto.VerifyVerificationID - Failed to generate verification ID with error: %v\n", err)
|
||||
return false
|
||||
}
|
||||
return expectedID == verificationID
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates a NaCl box keypair for asymmetric encryption
|
||||
func GenerateKeyPair() (publicKey []byte, privateKey []byte, verificationID string, err error) {
|
||||
pubKey, privKey, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to generate key pair: %w", err)
|
||||
}
|
||||
|
||||
if pubKey == nil {
|
||||
return nil, nil, "", fmt.Errorf("public key is empty")
|
||||
}
|
||||
|
||||
// Generate deterministic verification ID
|
||||
verificationID, err = GenerateVerificationID(pubKey[:])
|
||||
if err != nil {
|
||||
return nil, nil, "", err
|
||||
}
|
||||
|
||||
return pubKey[:], privKey[:], verificationID, nil
|
||||
}
|
||||
|
||||
// DeriveKeyFromPassword derives a key from a password using Argon2id
|
||||
// This matches the parameters used in your registration and login flows
|
||||
func DeriveKeyFromPassword(password string, salt []byte) ([]byte, error) {
|
||||
if len(salt) != Argon2SaltSize {
|
||||
return nil, fmt.Errorf("invalid salt size: expected %d, got %d", Argon2SaltSize, len(salt))
|
||||
}
|
||||
|
||||
key := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
Argon2OpsLimit,
|
||||
Argon2MemLimit,
|
||||
Argon2Parallelism,
|
||||
Argon2KeySize,
|
||||
)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// EncryptWithSecretBox encrypts data with a symmetric key using ChaCha20-Poly1305
|
||||
func EncryptWithSecretBox(data, key []byte) (*EncryptedData, error) {
|
||||
if len(key) != ChaCha20Poly1305KeySize {
|
||||
return nil, fmt.Errorf("invalid key size: expected %d, got %d", ChaCha20Poly1305KeySize, len(key))
|
||||
}
|
||||
|
||||
// Create ChaCha20-Poly1305 cipher
|
||||
cipher, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
nonce, err := GenerateRandomBytes(ChaCha20Poly1305NonceSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
ciphertext := cipher.Seal(nil, nonce, data, nil)
|
||||
|
||||
return &EncryptedData{
|
||||
Ciphertext: ciphertext,
|
||||
Nonce: nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// EncryptDataWithKey is a helper that encrypts data and returns ciphertext and nonce separately
|
||||
// This is for backward compatibility with existing code
|
||||
func EncryptDataWithKey(data, key []byte) (ciphertext []byte, nonce []byte, err error) {
|
||||
encData, err := EncryptWithSecretBox(data, key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return encData.Ciphertext, encData.Nonce, nil
|
||||
}
|
||||
|
||||
// DecryptWithSecretBox decrypts data with a symmetric key using ChaCha20-Poly1305
|
||||
func DecryptWithSecretBox(ciphertext, nonce, key []byte) ([]byte, error) {
|
||||
if len(key) != ChaCha20Poly1305KeySize {
|
||||
return nil, fmt.Errorf("invalid key size: expected %d, got %d", ChaCha20Poly1305KeySize, len(key))
|
||||
}
|
||||
|
||||
if len(nonce) != ChaCha20Poly1305NonceSize {
|
||||
return nil, fmt.Errorf("invalid nonce size: expected %d, got %d", ChaCha20Poly1305NonceSize, len(nonce))
|
||||
}
|
||||
|
||||
// Create ChaCha20-Poly1305 cipher
|
||||
cipher, err := chacha20poly1305.New(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
plaintext, err := cipher.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// EncryptWithBox encrypts data using NaCl box with the recipient's public key and sender's private key
|
||||
func EncryptWithBox(message []byte, recipientPublicKey, senderPrivateKey []byte) (*EncryptedData, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
if len(senderPrivateKey) != BoxSecretKeySize {
|
||||
return nil, fmt.Errorf("sender private key must be %d bytes", BoxSecretKeySize)
|
||||
}
|
||||
|
||||
// Generate nonce
|
||||
nonce, err := GenerateRandomBytes(BoxNonceSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Create fixed-size arrays
|
||||
var recipientPubKey [32]byte
|
||||
var senderPrivKey [32]byte
|
||||
var nonceArray [24]byte
|
||||
copy(recipientPubKey[:], recipientPublicKey)
|
||||
copy(senderPrivKey[:], senderPrivateKey)
|
||||
copy(nonceArray[:], nonce)
|
||||
|
||||
// Encrypt
|
||||
ciphertext := box.Seal(nil, message, &nonceArray, &recipientPubKey, &senderPrivKey)
|
||||
|
||||
return &EncryptedData{
|
||||
Ciphertext: ciphertext,
|
||||
Nonce: nonce,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecryptWithBox decrypts data using NaCl box with the sender's public key and recipient's private key
|
||||
func DecryptWithBox(ciphertext, nonce []byte, senderPublicKey, recipientPrivateKey []byte) ([]byte, error) {
|
||||
if len(senderPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("sender public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
if len(recipientPrivateKey) != BoxSecretKeySize {
|
||||
return nil, fmt.Errorf("recipient private key must be %d bytes", BoxSecretKeySize)
|
||||
}
|
||||
if len(nonce) != BoxNonceSize {
|
||||
return nil, fmt.Errorf("nonce must be %d bytes", BoxNonceSize)
|
||||
}
|
||||
|
||||
// Create fixed-size arrays
|
||||
var senderPubKey [32]byte
|
||||
var recipientPrivKey [32]byte
|
||||
var nonceArray [24]byte
|
||||
copy(senderPubKey[:], senderPublicKey)
|
||||
copy(recipientPrivKey[:], recipientPrivateKey)
|
||||
copy(nonceArray[:], nonce)
|
||||
|
||||
// Decrypt
|
||||
plaintext, ok := box.Open(nil, ciphertext, &nonceArray, &senderPubKey, &recipientPrivKey)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to decrypt: invalid keys, nonce, or corrupted ciphertext")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// EncryptWithBoxSeal encrypts data with a recipient's public key using anonymous sender (sealed box)
|
||||
// This is used for encrypting data where the sender doesn't need to be authenticated
|
||||
func EncryptWithBoxSeal(message []byte, recipientPublicKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
|
||||
// Create a fixed-size array for the recipient's public key
|
||||
var recipientPubKey [32]byte
|
||||
copy(recipientPubKey[:], recipientPublicKey)
|
||||
|
||||
// Generate an ephemeral keypair
|
||||
ephemeralPubKey, ephemeralPrivKey, err := box.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate ephemeral keypair: %w", err)
|
||||
}
|
||||
|
||||
// Generate a random nonce
|
||||
nonce, err := GenerateRandomBytes(BoxNonceSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate nonce: %w", err)
|
||||
}
|
||||
var nonceArray [24]byte
|
||||
copy(nonceArray[:], nonce)
|
||||
|
||||
// Encrypt the message
|
||||
ciphertext := box.Seal(nil, message, &nonceArray, &recipientPubKey, ephemeralPrivKey)
|
||||
|
||||
// Result format: ephemeral_public_key || nonce || ciphertext
|
||||
result := make([]byte, BoxPublicKeySize+BoxNonceSize+len(ciphertext))
|
||||
copy(result[:BoxPublicKeySize], ephemeralPubKey[:])
|
||||
copy(result[BoxPublicKeySize:BoxPublicKeySize+BoxNonceSize], nonce)
|
||||
copy(result[BoxPublicKeySize+BoxNonceSize:], ciphertext)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptWithBoxSeal decrypts data that was encrypted with EncryptWithBoxSeal
|
||||
func DecryptWithBoxSeal(sealedData []byte, recipientPublicKey, recipientPrivateKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
if len(recipientPrivateKey) != BoxSecretKeySize {
|
||||
return nil, fmt.Errorf("recipient private key must be %d bytes", BoxSecretKeySize)
|
||||
}
|
||||
if len(sealedData) < BoxPublicKeySize+BoxNonceSize+box.Overhead {
|
||||
return nil, errors.New("sealed data too short")
|
||||
}
|
||||
|
||||
// Extract components
|
||||
ephemeralPublicKey := sealedData[:BoxPublicKeySize]
|
||||
nonce := sealedData[BoxPublicKeySize : BoxPublicKeySize+BoxNonceSize]
|
||||
ciphertext := sealedData[BoxPublicKeySize+BoxNonceSize:]
|
||||
|
||||
// Create fixed-size arrays
|
||||
var ephemeralPubKey [32]byte
|
||||
var recipientPrivKey [32]byte
|
||||
var nonceArray [24]byte
|
||||
copy(ephemeralPubKey[:], ephemeralPublicKey)
|
||||
copy(recipientPrivKey[:], recipientPrivateKey)
|
||||
copy(nonceArray[:], nonce)
|
||||
|
||||
// Decrypt
|
||||
plaintext, ok := box.Open(nil, ciphertext, &nonceArray, &ephemeralPubKey, &recipientPrivKey)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to decrypt sealed box: invalid keys or corrupted ciphertext")
|
||||
}
|
||||
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// DecryptWithBoxAnonymous decrypts data that was encrypted anonymously (without nonce in the data)
|
||||
// This is used in the login flow for decrypting challenges
|
||||
func DecryptWithBoxAnonymous(encryptedData []byte, recipientPublicKey, recipientPrivateKey []byte) ([]byte, error) {
|
||||
if len(recipientPublicKey) != BoxPublicKeySize {
|
||||
return nil, fmt.Errorf("recipient public key must be %d bytes", BoxPublicKeySize)
|
||||
}
|
||||
if len(recipientPrivateKey) != BoxSecretKeySize {
|
||||
return nil, fmt.Errorf("recipient private key must be %d bytes", BoxSecretKeySize)
|
||||
}
|
||||
|
||||
// Create fixed-size arrays
|
||||
var pubKeyArray, privKeyArray [32]byte
|
||||
copy(pubKeyArray[:], recipientPublicKey)
|
||||
copy(privKeyArray[:], recipientPrivateKey)
|
||||
|
||||
// Decrypt the sealed box challenge
|
||||
decryptedData, ok := box.OpenAnonymous(nil, encryptedData, &pubKeyArray, &privKeyArray)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to decrypt anonymous box: invalid keys or corrupted data")
|
||||
}
|
||||
|
||||
return decryptedData, nil
|
||||
}
|
||||
|
||||
// EncodeToBase64 encodes bytes to base64 standard encoding
|
||||
func EncodeToBase64(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// EncodeToBase64URL encodes bytes to base64 URL-safe encoding without padding
|
||||
func EncodeToBase64URL(data []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// DecodeFromBase64 decodes a base64 standard encoded string to bytes
|
||||
func DecodeFromBase64(s string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// DecodeFromBase64URL decodes a base64 URL-safe encoded string without padding to bytes
|
||||
func DecodeFromBase64URL(s string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// CombineNonceAndCiphertext combines nonce and ciphertext into a single byte slice
|
||||
// This is useful for storing encrypted data as a single blob
|
||||
func CombineNonceAndCiphertext(nonce, ciphertext []byte) []byte {
|
||||
combined := make([]byte, len(nonce)+len(ciphertext))
|
||||
copy(combined[:len(nonce)], nonce)
|
||||
copy(combined[len(nonce):], ciphertext)
|
||||
return combined
|
||||
}
|
||||
|
||||
// SplitNonceAndCiphertext splits a combined byte slice into nonce and ciphertext
|
||||
// For ChaCha20-Poly1305, the nonce size is 12 bytes
|
||||
func SplitNonceAndCiphertext(combined []byte, nonceSize int) (nonce []byte, ciphertext []byte, err error) {
|
||||
if len(combined) < nonceSize {
|
||||
return nil, nil, fmt.Errorf("combined data too short: expected at least %d bytes, got %d", nonceSize, len(combined))
|
||||
}
|
||||
|
||||
nonce = combined[:nonceSize]
|
||||
ciphertext = combined[nonceSize:]
|
||||
return nonce, ciphertext, nil
|
||||
}
|
||||
|
||||
// Helper function to convert EncryptedData to separate slices (for backward compatibility)
|
||||
func (ed *EncryptedData) Separate() (ciphertext []byte, nonce []byte) {
|
||||
return ed.Ciphertext, ed.Nonce
|
||||
}
|
||||
|
||||
// ClearBytes overwrites a byte slice with zeros
|
||||
// This should be called on sensitive data like keys when they're no longer needed
|
||||
func ClearBytes(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
func HashSHA256(proofData []byte) []byte {
|
||||
hash := sha256.Sum256(proofData)
|
||||
return hash[:]
|
||||
}
|
||||
146
native/desktop/maplefile/pkg/httperror/httperror.go
Normal file
146
native/desktop/maplefile/pkg/httperror/httperror.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package httperror
|
||||
|
||||
// This package introduces a new `error` type that combines an HTTP status code and a message.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HTTPError represents an http error that occurred while handling a request
|
||||
type HTTPError struct {
|
||||
Code int `json:"-"` // HTTP Status code. We use `-` to skip json marshaling.
|
||||
Errors *map[string]string `json:"-"` // The original error. Same reason as above.
|
||||
}
|
||||
|
||||
// New creates a new HTTPError instance with a multi-field errors.
|
||||
func New(statusCode int, errorsMap *map[string]string) error {
|
||||
return HTTPError{
|
||||
Code: statusCode,
|
||||
Errors: errorsMap,
|
||||
}
|
||||
}
|
||||
|
||||
// NewForSingleField create a new HTTPError instance for a single field. This is a convinience constructor.
|
||||
func NewForSingleField(statusCode int, field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: statusCode,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForBadRequest create a new HTTPError instance pertaining to 403 bad requests with the multi-errors. This is a convinience constructor.
|
||||
func NewForBadRequest(err *map[string]string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusBadRequest,
|
||||
Errors: err,
|
||||
}
|
||||
}
|
||||
|
||||
// NewForBadRequestWithSingleField create a new HTTPError instance pertaining to 403 bad requests for a single field. This is a convinience constructor.
|
||||
func NewForBadRequestWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusBadRequest,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
func NewForInternalServerErrorWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusInternalServerError,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForNotFoundWithSingleField create a new HTTPError instance pertaining to 404 not found for a single field. This is a convinience constructor.
|
||||
func NewForNotFoundWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusNotFound,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForServiceUnavailableWithSingleField create a new HTTPError instance pertaining service unavailable for a single field. This is a convinience constructor.
|
||||
func NewForServiceUnavailableWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusServiceUnavailable,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForLockedWithSingleField create a new HTTPError instance pertaining to 424 locked for a single field. This is a convinience constructor.
|
||||
func NewForLockedWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusLocked,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForForbiddenWithSingleField create a new HTTPError instance pertaining to 403 bad requests for a single field. This is a convinience constructor.
|
||||
func NewForForbiddenWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusForbidden,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForUnauthorizedWithSingleField create a new HTTPError instance pertaining to 401 unauthorized for a single field. This is a convinience constructor.
|
||||
func NewForUnauthorizedWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusUnauthorized,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// NewForGoneWithSingleField create a new HTTPError instance pertaining to 410 gone for a single field. This is a convinience constructor.
|
||||
func NewForGoneWithSingleField(field string, message string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusGone,
|
||||
Errors: &map[string]string{field: message},
|
||||
}
|
||||
}
|
||||
|
||||
// Error function used to implement the `error` interface for returning errors.
|
||||
func (err HTTPError) Error() string {
|
||||
b, e := json.Marshal(err.Errors)
|
||||
if e != nil { // Defensive code
|
||||
return e.Error()
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// ResponseError function returns the HTTP error response based on the httpcode used.
|
||||
func ResponseError(rw http.ResponseWriter, err error) {
|
||||
// Copied from:
|
||||
// https://dev.to/tigorlazuardi/go-creating-custom-error-wrapper-and-do-proper-error-equality-check-11k7
|
||||
|
||||
rw.Header().Set("Content-Type", "Application/json")
|
||||
|
||||
//
|
||||
// CASE 1 OF 2: Handle API Errors.
|
||||
//
|
||||
|
||||
var ew HTTPError
|
||||
if errors.As(err, &ew) {
|
||||
rw.WriteHeader(ew.Code)
|
||||
_ = json.NewEncoder(rw).Encode(ew.Errors)
|
||||
return
|
||||
}
|
||||
|
||||
//
|
||||
// CASE 2 OF 2: Handle non ErrorWrapper types.
|
||||
//
|
||||
|
||||
rw.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
_ = json.NewEncoder(rw).Encode(err.Error())
|
||||
}
|
||||
|
||||
// NewForInternalServerError create a new HTTPError instance pertaining to 500 internal server error with the multi-errors. This is a convinience constructor.
|
||||
func NewForInternalServerError(err string) error {
|
||||
return HTTPError{
|
||||
Code: http.StatusInternalServerError,
|
||||
Errors: &map[string]string{"message": err},
|
||||
}
|
||||
}
|
||||
327
native/desktop/maplefile/pkg/httperror/httperror_test.go
Normal file
327
native/desktop/maplefile/pkg/httperror/httperror_test.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package httperror
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
errors map[string]string
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "basic error",
|
||||
code: http.StatusBadRequest,
|
||||
errors: map[string]string{"field": "error message"},
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "empty errors map",
|
||||
code: http.StatusNotFound,
|
||||
errors: map[string]string{},
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "multiple errors",
|
||||
code: http.StatusBadRequest,
|
||||
errors: map[string]string{"field1": "error1", "field2": "error2"},
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := New(tt.code, &tt.errors)
|
||||
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != tt.wantCode {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, tt.wantCode)
|
||||
}
|
||||
for k, v := range tt.errors {
|
||||
if (*httpErr.Errors)[k] != v {
|
||||
t.Errorf("Errors[%s] = %v, want %v", k, (*httpErr.Errors)[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForBadRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errors map[string]string
|
||||
}{
|
||||
{
|
||||
name: "single error",
|
||||
errors: map[string]string{"field": "error"},
|
||||
},
|
||||
{
|
||||
name: "multiple errors",
|
||||
errors: map[string]string{"field1": "error1", "field2": "error2"},
|
||||
},
|
||||
{
|
||||
name: "empty errors",
|
||||
errors: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewForBadRequest(&tt.errors)
|
||||
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != http.StatusBadRequest {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, http.StatusBadRequest)
|
||||
}
|
||||
for k, v := range tt.errors {
|
||||
if (*httpErr.Errors)[k] != v {
|
||||
t.Errorf("Errors[%s] = %v, want %v", k, (*httpErr.Errors)[k], v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForSingleField(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code int
|
||||
field string
|
||||
message string
|
||||
}{
|
||||
{
|
||||
name: "basic error",
|
||||
code: http.StatusBadRequest,
|
||||
field: "test",
|
||||
message: "error",
|
||||
},
|
||||
{
|
||||
name: "empty field",
|
||||
code: http.StatusNotFound,
|
||||
field: "",
|
||||
message: "error",
|
||||
},
|
||||
{
|
||||
name: "empty message",
|
||||
code: http.StatusBadRequest,
|
||||
field: "field",
|
||||
message: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := NewForSingleField(tt.code, tt.field, tt.message)
|
||||
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != tt.code {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, tt.code)
|
||||
}
|
||||
if (*httpErr.Errors)[tt.field] != tt.message {
|
||||
t.Errorf("Errors[%s] = %v, want %v", tt.field, (*httpErr.Errors)[tt.field], tt.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errors map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid json",
|
||||
errors: map[string]string{"field": "error"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty map",
|
||||
errors: map[string]string{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := HTTPError{
|
||||
Code: http.StatusBadRequest,
|
||||
Errors: &tt.errors,
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
var jsonMap map[string]string
|
||||
if jsonErr := json.Unmarshal([]byte(errStr), &jsonMap); (jsonErr != nil) != tt.wantErr {
|
||||
t.Errorf("Error() json.Unmarshal error = %v, wantErr %v", jsonErr, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if !tt.wantErr {
|
||||
for k, v := range tt.errors {
|
||||
if jsonMap[k] != v {
|
||||
t.Errorf("Error() jsonMap[%s] = %v, want %v", k, jsonMap[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
wantCode int
|
||||
wantContent string
|
||||
}{
|
||||
{
|
||||
name: "http error",
|
||||
err: NewForBadRequestWithSingleField("field", "invalid"),
|
||||
wantCode: http.StatusBadRequest,
|
||||
wantContent: `{"field":"invalid"}`,
|
||||
},
|
||||
{
|
||||
name: "standard error",
|
||||
err: fmt.Errorf("standard error"),
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantContent: `"standard error"`,
|
||||
},
|
||||
{
|
||||
name: "nil error",
|
||||
err: errors.New("<nil>"),
|
||||
wantCode: http.StatusInternalServerError,
|
||||
wantContent: `"\u003cnil\u003e"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
ResponseError(rr, tt.err)
|
||||
|
||||
// Check status code
|
||||
if rr.Code != tt.wantCode {
|
||||
t.Errorf("ResponseError() code = %v, want %v", rr.Code, tt.wantCode)
|
||||
}
|
||||
|
||||
// Check content type
|
||||
if ct := rr.Header().Get("Content-Type"); ct != "Application/json" {
|
||||
t.Errorf("ResponseError() Content-Type = %v, want Application/json", ct)
|
||||
}
|
||||
|
||||
// Trim newline from response for comparison
|
||||
got := rr.Body.String()
|
||||
got = got[:len(got)-1] // Remove trailing newline added by json.Encoder
|
||||
if got != tt.wantContent {
|
||||
t.Errorf("ResponseError() content = %v, want %v", got, tt.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorWrapping(t *testing.T) {
|
||||
originalErr := errors.New("original error")
|
||||
wrappedErr := fmt.Errorf("wrapped: %w", originalErr)
|
||||
httpErr := NewForBadRequestWithSingleField("field", wrappedErr.Error())
|
||||
|
||||
// Test error unwrapping
|
||||
if !errors.Is(httpErr, httpErr) {
|
||||
t.Error("errors.Is failed for same error")
|
||||
}
|
||||
|
||||
var targetErr HTTPError
|
||||
if !errors.As(httpErr, &targetErr) {
|
||||
t.Error("errors.As failed to get HTTPError")
|
||||
}
|
||||
}
|
||||
|
||||
// Test all convenience constructors
|
||||
func TestConvenienceConstructors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
create func() error
|
||||
wantCode int
|
||||
}{
|
||||
{
|
||||
name: "NewForBadRequestWithSingleField",
|
||||
create: func() error {
|
||||
return NewForBadRequestWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "NewForNotFoundWithSingleField",
|
||||
create: func() error {
|
||||
return NewForNotFoundWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusNotFound,
|
||||
},
|
||||
{
|
||||
name: "NewForServiceUnavailableWithSingleField",
|
||||
create: func() error {
|
||||
return NewForServiceUnavailableWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusServiceUnavailable,
|
||||
},
|
||||
{
|
||||
name: "NewForLockedWithSingleField",
|
||||
create: func() error {
|
||||
return NewForLockedWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusLocked,
|
||||
},
|
||||
{
|
||||
name: "NewForForbiddenWithSingleField",
|
||||
create: func() error {
|
||||
return NewForForbiddenWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "NewForUnauthorizedWithSingleField",
|
||||
create: func() error {
|
||||
return NewForUnauthorizedWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "NewForGoneWithSingleField",
|
||||
create: func() error {
|
||||
return NewForGoneWithSingleField("field", "message")
|
||||
},
|
||||
wantCode: http.StatusGone,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.create()
|
||||
httpErr, ok := err.(HTTPError)
|
||||
if !ok {
|
||||
t.Fatal("expected HTTPError type")
|
||||
}
|
||||
if httpErr.Code != tt.wantCode {
|
||||
t.Errorf("Code = %v, want %v", httpErr.Code, tt.wantCode)
|
||||
}
|
||||
if (*httpErr.Errors)["field"] != "message" {
|
||||
t.Errorf("Error message = %v, want 'message'", (*httpErr.Errors)["field"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
29
native/desktop/maplefile/pkg/storage/interface.go
Normal file
29
native/desktop/maplefile/pkg/storage/interface.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package storage
|
||||
|
||||
// Storage interface defines the methods that can be used to interact with a key-value database.
|
||||
type Storage interface {
|
||||
// Get returns the value associated with the specified key, or an error if the key is not found.
|
||||
Get(key string) ([]byte, error)
|
||||
|
||||
// Set sets the value associated with the specified key.
|
||||
// If the key already exists, its value is updated.
|
||||
Set(key string, val []byte) error
|
||||
|
||||
// Delete removes the value associated with the specified key from the database.
|
||||
Delete(key string) error
|
||||
|
||||
// Iterate is similar to View, but allows the iteration to start from a specific key prefix.
|
||||
// The seekThenIterateKey parameter can be used to specify a key to seek to before starting the iteration.
|
||||
Iterate(processFunc func(key, value []byte) error) error
|
||||
|
||||
IterateWithFilterByKeys(ks []string, processFunc func(key, value []byte) error) error
|
||||
|
||||
// Close closes the database, releasing any system resources it holds.
|
||||
Close() error
|
||||
|
||||
OpenTransaction() error
|
||||
|
||||
CommitTransaction() error
|
||||
|
||||
DiscardTransaction()
|
||||
}
|
||||
26
native/desktop/maplefile/pkg/storage/leveldb/config.go
Normal file
26
native/desktop/maplefile/pkg/storage/leveldb/config.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package leveldb
|
||||
|
||||
type LevelDBConfigurationProvider interface {
|
||||
GetDBPath() string
|
||||
GetDBName() string
|
||||
}
|
||||
|
||||
type LevelDBConfigurationProviderImpl struct {
|
||||
dbPath string
|
||||
dbName string
|
||||
}
|
||||
|
||||
func NewLevelDBConfigurationProvider(dbPath string, dbName string) LevelDBConfigurationProvider {
|
||||
return &LevelDBConfigurationProviderImpl{
|
||||
dbPath: dbPath,
|
||||
dbName: dbName,
|
||||
}
|
||||
}
|
||||
|
||||
func (me *LevelDBConfigurationProviderImpl) GetDBPath() string {
|
||||
return me.dbPath
|
||||
}
|
||||
|
||||
func (me *LevelDBConfigurationProviderImpl) GetDBName() string {
|
||||
return me.dbName
|
||||
}
|
||||
218
native/desktop/maplefile/pkg/storage/leveldb/leveldb.go
Normal file
218
native/desktop/maplefile/pkg/storage/leveldb/leveldb.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package leveldb
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/syndtr/goleveldb/leveldb"
|
||||
dberr "github.com/syndtr/goleveldb/leveldb/errors"
|
||||
"github.com/syndtr/goleveldb/leveldb/filter"
|
||||
"github.com/syndtr/goleveldb/leveldb/opt"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage"
|
||||
)
|
||||
|
||||
// storageImpl implements the db.Database interface.
|
||||
// It uses a LevelDB database to store key-value pairs.
|
||||
type storageImpl struct {
|
||||
// The LevelDB database instance.
|
||||
db *leveldb.DB
|
||||
transaction *leveldb.Transaction
|
||||
}
|
||||
|
||||
// NewDiskStorage creates a new instance of the storageImpl.
|
||||
// It opens the database file at the specified path and returns an error if it fails.
|
||||
func NewDiskStorage(provider LevelDBConfigurationProvider, logger *zap.Logger) storage.Storage {
|
||||
logger = logger.Named("leveldb")
|
||||
|
||||
if provider == nil {
|
||||
log.Fatal("NewDiskStorage: missing LevelDB configuration provider\n")
|
||||
}
|
||||
if provider.GetDBPath() == "" {
|
||||
log.Fatal("NewDiskStorage: cannot have empty filepath for the database\n")
|
||||
}
|
||||
if provider.GetDBName() == "" {
|
||||
log.Fatal("NewDiskStorage: cannot have empty db name for the database\n")
|
||||
}
|
||||
|
||||
o := &opt.Options{
|
||||
Filter: filter.NewBloomFilter(10),
|
||||
}
|
||||
|
||||
filePath := provider.GetDBPath() + "/" + provider.GetDBName()
|
||||
|
||||
db, err := leveldb.OpenFile(filePath, o)
|
||||
if err != nil {
|
||||
log.Fatalf("NewDiskStorage: failed loading up key value storer adapter at %v with error: %v\n", filePath, err)
|
||||
}
|
||||
return &storageImpl{
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the database by its key.
|
||||
// It returns an error if the key is not found.
|
||||
func (impl *storageImpl) Get(k string) ([]byte, error) {
|
||||
if impl.transaction == nil {
|
||||
bin, err := impl.db.Get([]byte(k), nil)
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
bin, err := impl.transaction.Get([]byte(k), nil)
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
// Set sets a value in the database by its key.
|
||||
// It returns an error if the operation fails.
|
||||
func (impl *storageImpl) Set(k string, val []byte) error {
|
||||
if impl.transaction == nil {
|
||||
impl.db.Delete([]byte(k), nil)
|
||||
err := impl.db.Put([]byte(k), val, nil)
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
impl.transaction.Delete([]byte(k), nil)
|
||||
err := impl.transaction.Put([]byte(k), val, nil)
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes a value from the database by its key.
|
||||
// It returns an error if the operation fails.
|
||||
func (impl *storageImpl) Delete(k string) error {
|
||||
if impl.transaction == nil {
|
||||
err := impl.db.Delete([]byte(k), nil)
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err := impl.transaction.Delete([]byte(k), nil)
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Iterate iterates over the key-value pairs in the database, starting from the specified key prefix.
|
||||
// It calls the provided function for each pair.
|
||||
// It returns an error if the iteration fails.
|
||||
func (impl *storageImpl) Iterate(processFunc func(key, value []byte) error) error {
|
||||
if impl.transaction == nil {
|
||||
iter := impl.db.NewIterator(nil, nil)
|
||||
for ok := iter.First(); ok; ok = iter.Next() {
|
||||
// Call the passed function for each key-value pair.
|
||||
err := processFunc(iter.Key(), iter.Value())
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err // Exit early if the processing function returns an error.
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
return iter.Error()
|
||||
}
|
||||
|
||||
iter := impl.transaction.NewIterator(nil, nil)
|
||||
for ok := iter.First(); ok; ok = iter.Next() {
|
||||
// Call the passed function for each key-value pair.
|
||||
err := processFunc(iter.Key(), iter.Value())
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err // Exit early if the processing function returns an error.
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
return iter.Error()
|
||||
}
|
||||
|
||||
func (impl *storageImpl) IterateWithFilterByKeys(ks []string, processFunc func(key, value []byte) error) error {
|
||||
if impl.transaction == nil {
|
||||
iter := impl.db.NewIterator(nil, nil)
|
||||
for ok := iter.First(); ok; ok = iter.Next() {
|
||||
// Iterate over our keys to search by.
|
||||
for _, k := range ks {
|
||||
searchKey := strings.ToLower(k)
|
||||
targetKey := strings.ToLower(string(iter.Key()))
|
||||
|
||||
// If the item we currently have matches our keys then execute.
|
||||
if searchKey == targetKey {
|
||||
// Call the passed function for each key-value pair.
|
||||
err := processFunc(iter.Key(), iter.Value())
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err // Exit early if the processing function returns an error.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
return iter.Error()
|
||||
}
|
||||
|
||||
iter := impl.transaction.NewIterator(nil, nil)
|
||||
for ok := iter.First(); ok; ok = iter.Next() {
|
||||
// Call the passed function for each key-value pair.
|
||||
err := processFunc(iter.Key(), iter.Value())
|
||||
if err == dberr.ErrNotFound {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err // Exit early if the processing function returns an error.
|
||||
}
|
||||
}
|
||||
iter.Release()
|
||||
return iter.Error()
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
// It returns an error if the operation fails.
|
||||
func (impl *storageImpl) Close() error {
|
||||
if impl.transaction != nil {
|
||||
impl.transaction.Discard()
|
||||
}
|
||||
return impl.db.Close()
|
||||
}
|
||||
|
||||
func (impl *storageImpl) OpenTransaction() error {
|
||||
transaction, err := impl.db.OpenTransaction()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
impl.transaction = transaction
|
||||
return nil
|
||||
}
|
||||
|
||||
func (impl *storageImpl) CommitTransaction() error {
|
||||
defer func() {
|
||||
impl.transaction = nil
|
||||
}()
|
||||
|
||||
// Commit the snapshot to the database
|
||||
return impl.transaction.Commit()
|
||||
}
|
||||
|
||||
func (impl *storageImpl) DiscardTransaction() {
|
||||
defer func() {
|
||||
impl.transaction = nil
|
||||
}()
|
||||
impl.transaction.Discard()
|
||||
}
|
||||
479
native/desktop/maplefile/pkg/storage/leveldb/leveldb_test.gox
Normal file
479
native/desktop/maplefile/pkg/storage/leveldb/leveldb_test.gox
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
package leveldb
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// MockLevelDBConfigProvider is a mock implementation of a presumed LevelDBConfigurationProvider interface.
|
||||
// This is created to satisfy the changed signature of NewDiskStorage.
|
||||
type MockLevelDBConfigProvider struct {
|
||||
Path string
|
||||
// Add other fields here if the actual LevelDBConfigurationProvider interface
|
||||
// and NewDiskStorage function require more configuration options (e.g., LevelDB options).
|
||||
}
|
||||
|
||||
// GetDBPath returns the database path. This method is assumed to be part of the
|
||||
// LevelDBConfigurationProvider interface.
|
||||
func (m *MockLevelDBConfigProvider) GetDBPath() string {
|
||||
return m.Path
|
||||
}
|
||||
|
||||
// If LevelDBConfigurationProvider requires other methods (e.g., GetOptions()),
|
||||
// they would need to be implemented here as well. For this repair, we assume
|
||||
// GetDBPath is sufficient based on the original parameters of NewDiskStorage.
|
||||
|
||||
// testDir creates a temporary directory for testing
|
||||
func testDir(t *testing.T) string {
|
||||
dir, err := os.MkdirTemp("", "leveldb-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// cleanup removes the test directory and its contents
|
||||
func cleanup(t *testing.T, dir string) {
|
||||
err := os.RemoveAll(dir)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to cleanup test dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewDiskStorage tests the creation of a new storage instance
|
||||
func TestNewDiskStorage(t *testing.T) {
|
||||
dir := testDir(t)
|
||||
defer cleanup(t, dir)
|
||||
|
||||
dbName := "test.db"
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
logger := zap.NewExample()
|
||||
storage := NewDiskStorage(config, logger)
|
||||
|
||||
if storage == nil {
|
||||
t.Fatal("Expected non-nil storage instance")
|
||||
}
|
||||
|
||||
// Type assertion to verify we get the correct implementation
|
||||
impl, ok := storage.(*storageImpl)
|
||||
if !ok {
|
||||
t.Fatal("Expected storageImpl instance")
|
||||
}
|
||||
|
||||
if impl.db == nil {
|
||||
t.Fatal("Expected non-nil leveldb instance")
|
||||
}
|
||||
|
||||
// It's generally better to call Close on the interface, but if impl.Close() has
|
||||
// specific behavior being tested or is necessary for some reason, it can stay.
|
||||
// However, a defer storage.Close() would be more idiomatic if not for specific impl testing.
|
||||
err := storage.Close() // Changed from impl.Close() to storage.Close() for consistency
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to close storage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBasicOperations tests the basic Set/Get/Delete operations
|
||||
func TestBasicOperations(t *testing.T) {
|
||||
dir := testDir(t)
|
||||
defer cleanup(t, dir)
|
||||
|
||||
dbName := "test.db"
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
storage := NewDiskStorage(config, zap.NewExample())
|
||||
defer storage.Close()
|
||||
|
||||
// Test Set and Get
|
||||
t.Run("Set and Get", func(t *testing.T) {
|
||||
key := "test-key"
|
||||
value := []byte("test-value")
|
||||
|
||||
err := storage.Set(key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := storage.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(retrieved, value) {
|
||||
t.Errorf("Retrieved value doesn't match: got %v, want %v", retrieved, value)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Get with non-existent key
|
||||
t.Run("Get Non-existent", func(t *testing.T) {
|
||||
val, err := storage.Get("non-existent")
|
||||
// LevelDB's Get typically returns leveldb.ErrNotFound for non-existent keys.
|
||||
// If the wrapper converts this to (nil, nil), the original check is fine.
|
||||
// If it propagates ErrNotFound, then err should be checked for that specific error.
|
||||
// Assuming the wrapper intends (nil, nil) for not found.
|
||||
if err != nil {
|
||||
t.Fatalf("Expected nil error or specific 'not found' error for non-existent key, got: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Errorf("Expected nil value for non-existent key, got: %v", val)
|
||||
}
|
||||
})
|
||||
|
||||
// Test Delete
|
||||
t.Run("Delete", func(t *testing.T) {
|
||||
key := "delete-test"
|
||||
value := []byte("delete-value")
|
||||
|
||||
// First set a value
|
||||
err := storage.Set(key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
}
|
||||
|
||||
// Delete it
|
||||
err = storage.Delete(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's gone
|
||||
val, err := storage.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after delete failed: %v", err) // Similar to "Get Non-existent"
|
||||
}
|
||||
if val != nil {
|
||||
t.Error("Expected nil value after deletion")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestIteration tests the iteration functionality
|
||||
func TestIteration(t *testing.T) {
|
||||
dir := testDir(t)
|
||||
defer cleanup(t, dir)
|
||||
|
||||
dbName := "test.db"
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
storage := NewDiskStorage(config, zap.NewExample())
|
||||
defer storage.Close()
|
||||
|
||||
// Prepare test data
|
||||
testData := map[string][]byte{
|
||||
"key1": []byte("value1"),
|
||||
"key2": []byte("value2"),
|
||||
"key3": []byte("value3"),
|
||||
}
|
||||
|
||||
// Insert test data
|
||||
for k, v := range testData {
|
||||
if err := storage.Set(k, v); err != nil {
|
||||
t.Fatalf("Failed to set test data: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Test basic iteration
|
||||
t.Run("Basic Iteration", func(t *testing.T) {
|
||||
found := make(map[string][]byte)
|
||||
|
||||
err := storage.Iterate(func(key, value []byte) error {
|
||||
valueCopy := make([]byte, len(value))
|
||||
copy(valueCopy, value)
|
||||
found[string(key)] = valueCopy
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Iteration failed: %v", err)
|
||||
}
|
||||
|
||||
if len(found) != len(testData) {
|
||||
t.Errorf("Iteration found %d items, expected %d", len(found), len(testData))
|
||||
}
|
||||
|
||||
for k, expectedValue := range testData {
|
||||
actualValue, exists := found[k]
|
||||
if !exists {
|
||||
t.Errorf("Key %q not found in iteration results", k)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(actualValue, expectedValue) {
|
||||
t.Errorf("Value mismatch for key %q: got %q, want %q",
|
||||
k, string(actualValue), string(expectedValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Test filtered iteration
|
||||
t.Run("Filtered Iteration", func(t *testing.T) {
|
||||
filterKeys := []string{"key1", "key3"}
|
||||
expectedFilteredData := make(map[string][]byte)
|
||||
for _, k := range filterKeys {
|
||||
if v, ok := testData[k]; ok {
|
||||
expectedFilteredData[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
found := make(map[string][]byte)
|
||||
|
||||
err := storage.IterateWithFilterByKeys(filterKeys, func(key, value []byte) error {
|
||||
valueCopy := make([]byte, len(value))
|
||||
copy(valueCopy, value)
|
||||
found[string(key)] = valueCopy
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Filtered iteration failed: %v", err)
|
||||
}
|
||||
|
||||
if len(found) != len(expectedFilteredData) {
|
||||
t.Errorf("Filtered iteration found %d items, expected %d", len(found), len(expectedFilteredData))
|
||||
}
|
||||
|
||||
for _, k := range filterKeys {
|
||||
expectedValue, dataExists := testData[k]
|
||||
if !dataExists { // Should not happen if filterKeys are from testData
|
||||
t.Errorf("Test data sanity check: key %q from filterKeys not in testData", k)
|
||||
continue
|
||||
}
|
||||
|
||||
actualValue, exists := found[k]
|
||||
if !exists {
|
||||
t.Errorf("Key %q not found in filtered results", k)
|
||||
continue
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actualValue, expectedValue) {
|
||||
t.Errorf("Value mismatch for key %q: got %q, want %q",
|
||||
k, string(actualValue), string(expectedValue))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestTransactions tests the transaction functionality
|
||||
func TestTransactions(t *testing.T) {
|
||||
dir := testDir(t)
|
||||
defer cleanup(t, dir)
|
||||
|
||||
dbName := "test.db"
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
storage := NewDiskStorage(config, zap.NewExample())
|
||||
defer storage.Close()
|
||||
|
||||
t.Run("Transaction Commit", func(t *testing.T) {
|
||||
err := storage.OpenTransaction()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open transaction: %v", err)
|
||||
}
|
||||
|
||||
key := "tx-test"
|
||||
value := []byte("tx-value")
|
||||
|
||||
err = storage.Set(key, value) // Inside transaction
|
||||
if err != nil {
|
||||
storage.DiscardTransaction() // Ensure transaction is cleaned up on failure
|
||||
t.Fatalf("Failed to set in transaction: %v", err)
|
||||
}
|
||||
|
||||
err = storage.CommitTransaction()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to commit transaction: %v", err)
|
||||
}
|
||||
|
||||
retrieved, err := storage.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get after commit: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(retrieved, value) {
|
||||
t.Errorf("Retrieved value doesn't match after commit: got %v, want %v", retrieved, value)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Transaction Discard", func(t *testing.T) {
|
||||
err := storage.OpenTransaction()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to open transaction: %v", err)
|
||||
}
|
||||
|
||||
key := "discard-test"
|
||||
value := []byte("discard-value")
|
||||
|
||||
// Set a value outside transaction first to ensure Get behavior is clear
|
||||
outerKey := "outer-key-discard"
|
||||
outerValue := []byte("outer-value-discard")
|
||||
if err := storage.Set(outerKey, outerValue); err != nil {
|
||||
storage.DiscardTransaction()
|
||||
t.Fatalf("Setup: Failed to set outer key: %v", err)
|
||||
}
|
||||
|
||||
err = storage.Set(key, value) // Inside transaction
|
||||
if err != nil {
|
||||
storage.DiscardTransaction()
|
||||
t.Fatalf("Failed to set in transaction: %v", err)
|
||||
}
|
||||
|
||||
storage.DiscardTransaction()
|
||||
|
||||
// Verify changes were not persisted for 'key'
|
||||
val, err := storage.Get(key)
|
||||
if err != nil { // Assuming (nil, nil) for not found, or check for specific not-found error
|
||||
t.Fatalf("Get after discard failed for transactional key: %v", err)
|
||||
}
|
||||
if val != nil {
|
||||
t.Error("Expected nil value for transactional key after discarding transaction")
|
||||
}
|
||||
|
||||
// Verify outer key is still accessible (if transactions don't block normal ops)
|
||||
// This depends on the implementation detail of how transactions interact with non-transactional ops.
|
||||
// If OpenTransaction acquires a lock, this Get might behave differently.
|
||||
// For now, assuming Get outside transaction should work.
|
||||
retrievedOuter, err := storage.Get(outerKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after discard failed for outer key: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(retrievedOuter, outerValue) {
|
||||
t.Errorf("Outer key value changed or became inaccessible after discard: got %v, want %v", retrievedOuter, outerValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPersistence verifies that data persists after closing and reopening the database
|
||||
func TestPersistence(t *testing.T) {
|
||||
dir := testDir(t)
|
||||
defer cleanup(t, dir)
|
||||
|
||||
dbName := "persist.db"
|
||||
key := "persist-key"
|
||||
value := []byte("persist-value")
|
||||
|
||||
// First session: write data
|
||||
func() {
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
storage := NewDiskStorage(config, zap.NewExample())
|
||||
defer storage.Close()
|
||||
|
||||
err := storage.Set(key, value)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set value: %v", err)
|
||||
}
|
||||
}() // storage is closed here
|
||||
|
||||
// Second session: verify data
|
||||
func() {
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
storage := NewDiskStorage(config, zap.NewExample())
|
||||
defer storage.Close()
|
||||
|
||||
retrieved, err := storage.Get(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get value: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(retrieved, value) {
|
||||
t.Errorf("Retrieved value doesn't match after reopen: got %v, want %v", retrieved, value)
|
||||
}
|
||||
}() // storage is closed here
|
||||
}
|
||||
|
||||
// TestDatabaseError tests error handling for invalid database operations
|
||||
func TestDatabaseError(t *testing.T) {
|
||||
dir := testDir(t)
|
||||
defer cleanup(t, dir)
|
||||
|
||||
dbName := "test.db"
|
||||
config := &MockLevelDBConfigProvider{
|
||||
Path: filepath.Join(dir, dbName),
|
||||
}
|
||||
storage := NewDiskStorage(config, zap.NewExample())
|
||||
|
||||
// Test argument validation (should ideally be tested on an open DB,
|
||||
// but original test implies testing on closed DB or combined effects)
|
||||
// If these are meant to be pure argument validation, they should be done before Close().
|
||||
// For now, keeping original structure.
|
||||
|
||||
// Test Set with empty key (on an open DB first, then closed)
|
||||
// Let's assume the Set method itself validates this, regardless of DB state.
|
||||
// If Set returns ErrClosed when DB is closed, it might mask empty key error.
|
||||
// To test empty key specifically:
|
||||
if err := storage.Set("", []byte("value")); err == nil { // This might be an error from the underlying DB for empty key
|
||||
t.Error("Expected error setting empty key (on open DB or for argument validation)")
|
||||
}
|
||||
// Test Set with nil value
|
||||
if err := storage.Set("key", nil); err == nil { // This might be an error from underlying DB for nil value
|
||||
t.Error("Expected error setting nil value (on open DB or for argument validation)")
|
||||
}
|
||||
|
||||
// Now, close the database to force errors for subsequent operations
|
||||
errClose := storage.Close()
|
||||
if errClose != nil {
|
||||
t.Fatalf("Failed to close storage for error testing: %v", errClose)
|
||||
}
|
||||
|
||||
// Test database operations on a closed DB
|
||||
t.Run("Operations on Closed DB", func(t *testing.T) {
|
||||
if err := storage.Set("another-key", []byte("value")); err == nil {
|
||||
t.Error("Expected error setting on a closed database")
|
||||
}
|
||||
|
||||
if _, err := storage.Get("another-key"); err == nil {
|
||||
t.Error("Expected error getting from a closed database")
|
||||
}
|
||||
|
||||
if err := storage.Delete("another-key"); err == nil {
|
||||
t.Error("Expected error deleting from a closed database")
|
||||
}
|
||||
|
||||
if err := storage.Iterate(func(k, v []byte) error { return nil }); err == nil {
|
||||
t.Error("Expected error iterating on a closed database")
|
||||
}
|
||||
|
||||
// Test OpenTransaction on a closed DB
|
||||
// Original comment: "OpenTransaction returns nil even on error per implementation"
|
||||
// This behavior is unusual. A robust implementation should return an error.
|
||||
err := storage.OpenTransaction()
|
||||
// If the contract is that it returns nil error even if DB is closed:
|
||||
if err != nil {
|
||||
t.Errorf("OpenTransaction returned an error (%v), expected nil error even when db is closed based on original test comment", err)
|
||||
}
|
||||
// Even if OpenTransaction returns nil error, it shouldn't have actually created a transaction.
|
||||
if impl, ok := storage.(*storageImpl); ok {
|
||||
if impl.transaction != nil {
|
||||
t.Error("Transaction should be nil after OpenTransaction on a closed DB, even if no error was returned by OpenTransaction")
|
||||
}
|
||||
} else {
|
||||
t.Fatal("Expected storageImpl instance for checking transaction state")
|
||||
}
|
||||
|
||||
// CommitTransaction on a closed DB (and no active transaction)
|
||||
if err := storage.CommitTransaction(); err == nil {
|
||||
t.Error("Expected error committing transaction on a closed database / no active transaction")
|
||||
}
|
||||
|
||||
// DiscardTransaction on a closed DB (and no active transaction)
|
||||
// Discard is often idempotent, so it might not error.
|
||||
storage.DiscardTransaction() // Assuming this is safe to call.
|
||||
|
||||
// Closing an already closed DB
|
||||
if err := storage.Close(); err == nil {
|
||||
// This depends on the desired behavior of Close(). Some are idempotent, others error.
|
||||
// t.Error("Expected error when closing an already closed database, if that's the contract")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue