mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
692 lines
18 KiB
Go
692 lines
18 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/lightninglabs/aperture/l402"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop/staticaddr/address"
|
|
staticaddrscript "github.com/lightninglabs/loop/staticaddr/script"
|
|
"github.com/lightninglabs/loop/swap"
|
|
"github.com/lightningnetwork/lnd/keychain"
|
|
"github.com/lightningnetwork/lnd/lntypes"
|
|
"golang.org/x/crypto/nacl/secretbox"
|
|
"gopkg.in/macaroon.v2"
|
|
)
|
|
|
|
const (
|
|
backupVersion = 1
|
|
|
|
backupBaseName = "L402_backup"
|
|
|
|
backupFileExt = ".enc"
|
|
|
|
paidTokenFileName = "l402.token"
|
|
)
|
|
|
|
// backupKeyLocator identifies the lnd key used only for deriving the local
|
|
// backup encryption key. The encrypted backup stays tied to the same lnd seed
|
|
// material without adding a separate user-managed password.
|
|
var backupKeyLocator = keychain.KeyLocator{
|
|
Family: keychain.KeyFamily(swap.StaticAddressKeyFamily),
|
|
Index: 0,
|
|
}
|
|
|
|
// backupMagic prefixes encrypted backup files so corrupt or unrelated files can
|
|
// be rejected before attempting to unmarshal JSON payloads.
|
|
var backupMagic = []byte("loopbak1")
|
|
|
|
// StaticAddressManager is the subset of static-address behavior required for
|
|
// creating backups.
|
|
type StaticAddressManager interface {
|
|
// GetStaticAddressParameters returns the concrete legacy static address
|
|
// row that is paired with the current paid L402 generation.
|
|
GetStaticAddressParameters(context.Context) (
|
|
*staticaddrscript.Parameters, error)
|
|
|
|
// CurrentHeight returns the manager's current chain height, which is
|
|
// stored as the future multi-address scan floor for this generation.
|
|
CurrentHeight() int32
|
|
}
|
|
|
|
// Service creates encrypted local backups for Loop static-address and L402
|
|
// state.
|
|
type Service struct {
|
|
dataDir string
|
|
network string
|
|
signer lndclient.SignerClient
|
|
staticAddressManager StaticAddressManager
|
|
}
|
|
|
|
type backupPayload struct {
|
|
Version uint32 `json:"version"`
|
|
Network string `json:"network"`
|
|
L402TokenID string `json:"l402_token_id"`
|
|
L402TokenCreatedAt int64 `json:"l402_token_created_at"`
|
|
StaticAddress *staticAddressBackup `json:"static_address,omitempty"`
|
|
TokenFiles []*l402TokenFileEntry `json:"token_files,omitempty"`
|
|
}
|
|
|
|
// staticAddressBackup contains the legacy single-address data that can be
|
|
// restored directly by future recovery code, plus the stable per-L402
|
|
// multi-address/change branch fields future multi-address recovery will scan
|
|
// from.
|
|
type staticAddressBackup struct {
|
|
ProtocolVersion uint32 `json:"protocol_version"`
|
|
ClientPubKey []byte `json:"client_pubkey,omitempty"`
|
|
ServerPubKey []byte `json:"server_pubkey"`
|
|
Expiry uint32 `json:"expiry"`
|
|
LegacyClientKeyFamily int32 `json:"legacy_client_key_family,omitempty"`
|
|
MainKeyFamily int32 `json:"main_key_family"`
|
|
ChangeKeyFamily int32 `json:"change_key_family"`
|
|
LegacyFirstHeight int32 `json:"legacy_first_height,omitempty"`
|
|
MultiAddressFirstHeight int32 `json:"multi_address_first_height,omitempty"`
|
|
}
|
|
|
|
type l402TokenFileEntry struct {
|
|
Name string `json:"name"`
|
|
Data []byte `json:"data"`
|
|
}
|
|
|
|
type currentTokenState struct {
|
|
TokenID string
|
|
TokenCreatedAt int64
|
|
TokenFiles []*l402TokenFileEntry
|
|
}
|
|
|
|
type paidTokenMetadata struct {
|
|
tokenID string
|
|
tokenCreatedAt int64
|
|
}
|
|
|
|
type backupFileDetails struct {
|
|
tokenID string
|
|
titleTimestamp int64
|
|
}
|
|
|
|
// NewService constructs a backup service for a specific loop network data
|
|
// directory.
|
|
func NewService(dataDir, network string, signer lndclient.SignerClient,
|
|
staticAddressManager StaticAddressManager) *Service {
|
|
|
|
return &Service{
|
|
dataDir: dataDir,
|
|
network: network,
|
|
signer: signer,
|
|
staticAddressManager: staticAddressManager,
|
|
}
|
|
}
|
|
|
|
// WriteBackup writes an encrypted backup file for the current paid-L402 /
|
|
// static-address generation. It returns an empty path when there is no complete
|
|
// generation yet, or when the current L402 already has an immutable backup on
|
|
// disk.
|
|
func (s *Service) WriteBackup(ctx context.Context) (string, error) {
|
|
// A backup is immutable and generation-based, so first collect enough
|
|
// state to prove the current generation is complete: one paid L402 token
|
|
// plus one concrete static address bound to that token.
|
|
payload, hasState, err := s.buildPayload(ctx)
|
|
if err != nil || !hasState {
|
|
return "", err
|
|
}
|
|
|
|
// We need the derived key before checking for existing backups because a
|
|
// filename match alone is not enough. A stale or corrupt file with the same
|
|
// token ID must not suppress writing a valid backup.
|
|
key, err := s.deriveEncryptionKey(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// If a valid backup for the exact token creation time already exists, the
|
|
// generation is already protected and must not be rewritten.
|
|
if backupFile, err := findValidBackupFileForToken(
|
|
s.dataDir, key, s.network, payload.L402TokenID,
|
|
payload.L402TokenCreatedAt,
|
|
); err != nil {
|
|
return "", err
|
|
} else if backupFile != "" {
|
|
return "", nil
|
|
}
|
|
|
|
fileName := backupFilePath(
|
|
s.dataDir, payload.L402TokenID, payload.L402TokenCreatedAt,
|
|
)
|
|
|
|
// The plaintext is never written to disk. It is marshaled in memory,
|
|
// encrypted with the lnd-derived key, then atomically installed.
|
|
plaintext, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
encrypted, err := encryptBackupPayload(key, plaintext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
err = writeFileAtomically(fileName, encrypted)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return fileName, nil
|
|
}
|
|
|
|
func (p *backupPayload) validateNetwork(currentNetwork string) error {
|
|
switch {
|
|
case p.Version != backupVersion:
|
|
return fmt.Errorf("unsupported backup version %d", p.Version)
|
|
|
|
case p.Network == "":
|
|
return fmt.Errorf("backup file is missing a network")
|
|
|
|
case p.L402TokenID == "":
|
|
return fmt.Errorf("backup file is missing an L402 token ID")
|
|
|
|
case p.Network != currentNetwork:
|
|
return fmt.Errorf("backup file network %s does not match "+
|
|
"daemon network %s", p.Network, currentNetwork)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (p *backupPayload) validateCompleteGeneration(
|
|
fileDetails *backupFileDetails) error {
|
|
|
|
// When the caller knows the filename metadata, require it to match the
|
|
// payload. This keeps the immutable filename and encrypted contents bound
|
|
// to the same L402 generation.
|
|
if fileDetails != nil {
|
|
if p.L402TokenID != fileDetails.tokenID {
|
|
return fmt.Errorf("backup file token ID %s does not match "+
|
|
"payload token ID %s", fileDetails.tokenID,
|
|
p.L402TokenID)
|
|
}
|
|
|
|
if p.L402TokenCreatedAt != fileDetails.titleTimestamp {
|
|
return fmt.Errorf("backup file timestamp %d does not "+
|
|
"match payload L402 creation time %d",
|
|
fileDetails.titleTimestamp, p.L402TokenCreatedAt)
|
|
}
|
|
}
|
|
|
|
if len(p.TokenFiles) == 0 {
|
|
return fmt.Errorf("backup file is missing paid L402 token data")
|
|
}
|
|
|
|
if p.StaticAddress == nil {
|
|
return fmt.Errorf("backup file is missing static address " +
|
|
"parameters")
|
|
}
|
|
|
|
// The raw token file is the source of truth for the paid L402. Decode its
|
|
// metadata and make sure it matches the generation named by the payload.
|
|
metadata, err := validatePaidTokenFiles(p.TokenFiles)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if metadata.tokenID != p.L402TokenID {
|
|
return fmt.Errorf("backup L402 token ID %s does not match "+
|
|
"payload token ID %s", metadata.tokenID, p.L402TokenID)
|
|
}
|
|
|
|
if metadata.tokenCreatedAt != p.L402TokenCreatedAt {
|
|
return fmt.Errorf("backup L402 token creation time %d does "+
|
|
"not match payload creation time %d",
|
|
metadata.tokenCreatedAt, p.L402TokenCreatedAt)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) buildPayload(ctx context.Context) (*backupPayload, bool,
|
|
error) {
|
|
|
|
// Backups are only meaningful after the token payment completed. Pending
|
|
// L402 tokens can still change and do not define an immutable generation.
|
|
tokenState, err := s.currentPaidToken()
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if tokenState == nil || s.staticAddressManager == nil {
|
|
return nil, false, nil
|
|
}
|
|
|
|
payload := &backupPayload{
|
|
Version: backupVersion,
|
|
Network: s.network,
|
|
L402TokenID: tokenState.TokenID,
|
|
L402TokenCreatedAt: tokenState.TokenCreatedAt,
|
|
TokenFiles: tokenState.TokenFiles,
|
|
}
|
|
|
|
// The current static-address row supplies the legacy concrete address. The
|
|
// same payload also stores the deterministic families and scan floor future
|
|
// multi-address recovery will use without rewriting this backup.
|
|
addrParams, err := s.staticAddressManager.GetStaticAddressParameters(ctx)
|
|
switch {
|
|
case err == nil:
|
|
multiAddressFirstHeight := s.staticAddressManager.CurrentHeight()
|
|
if multiAddressFirstHeight <= 0 {
|
|
return nil, false, fmt.Errorf(
|
|
"invalid multi-address first height %d",
|
|
multiAddressFirstHeight,
|
|
)
|
|
}
|
|
|
|
payload.StaticAddress = &staticAddressBackup{
|
|
ProtocolVersion: uint32(addrParams.ProtocolVersion),
|
|
ClientPubKey: addrParams.ClientPubkey.
|
|
SerializeCompressed(),
|
|
ServerPubKey: addrParams.ServerPubkey.
|
|
SerializeCompressed(),
|
|
Expiry: addrParams.Expiry,
|
|
LegacyClientKeyFamily: int32(
|
|
addrParams.KeyLocator.Family,
|
|
),
|
|
MainKeyFamily: swap.StaticMultiAddressKeyFamily,
|
|
ChangeKeyFamily: swap.StaticAddressChangeKeyFamily,
|
|
LegacyFirstHeight: addrParams.InitiationHeight,
|
|
MultiAddressFirstHeight: multiAddressFirstHeight,
|
|
}
|
|
|
|
case errors.Is(err, address.ErrNoStaticAddress):
|
|
// The current L402 does not have a complete static-address generation
|
|
// yet, so there is nothing immutable to back up.
|
|
return nil, false, nil
|
|
|
|
default:
|
|
return nil, false, err
|
|
}
|
|
|
|
hasState := payload.StaticAddress != nil && len(payload.TokenFiles) > 0
|
|
|
|
return payload, hasState, nil
|
|
}
|
|
|
|
func (s *Service) currentPaidToken() (*currentTokenState, error) {
|
|
tokenStore, err := l402.NewFileStore(s.dataDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
token, err := tokenStore.CurrentToken()
|
|
switch {
|
|
case err == nil:
|
|
|
|
case errors.Is(err, l402.ErrNoToken):
|
|
return nil, nil
|
|
|
|
default:
|
|
return nil, err
|
|
}
|
|
|
|
// Only fully paid tokens define an immutable generation.
|
|
if token.Preimage == (lntypes.Preimage{}) {
|
|
return nil, nil
|
|
}
|
|
|
|
// Preserve the exact token file bytes instead of reserializing the token.
|
|
// That keeps future restore code compatible with Aperture's token-store
|
|
// format.
|
|
tokenID, err := decodeTokenID(token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
path := filepath.Join(s.dataDir, paidTokenFileName)
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
|
|
return nil, err
|
|
}
|
|
|
|
return ¤tTokenState{
|
|
TokenID: tokenID,
|
|
TokenCreatedAt: token.TimeCreated.UnixNano(),
|
|
TokenFiles: []*l402TokenFileEntry{{
|
|
Name: paidTokenFileName,
|
|
Data: data,
|
|
}},
|
|
}, nil
|
|
}
|
|
|
|
func decodeTokenID(token *l402.Token) (string, error) {
|
|
identifier, err := l402.DecodeIdentifier(
|
|
bytes.NewReader(token.BaseMacaroon().Id()),
|
|
)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return identifier.TokenID.String(), nil
|
|
}
|
|
|
|
func backupFilePath(dataDir, tokenID string, tokenCreatedAt int64) string {
|
|
return filepath.Join(dataDir, backupFileName(tokenID, tokenCreatedAt))
|
|
}
|
|
|
|
func backupFileName(tokenID string, tokenCreatedAt int64) string {
|
|
return fmt.Sprintf(
|
|
"%s_%019d_%s%s", backupBaseName, tokenCreatedAt, tokenID,
|
|
backupFileExt,
|
|
)
|
|
}
|
|
|
|
func backupFileTokenID(name string) (string, bool) {
|
|
details, ok := parseBackupFileName(name)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
|
|
return details.tokenID, true
|
|
}
|
|
|
|
func parseBackupFileName(name string) (*backupFileDetails, bool) {
|
|
if !strings.HasPrefix(name, backupBaseName+"_") ||
|
|
!strings.HasSuffix(name, backupFileExt) {
|
|
|
|
return nil, false
|
|
}
|
|
|
|
remainder := strings.TrimSuffix(
|
|
strings.TrimPrefix(name, backupBaseName+"_"), backupFileExt,
|
|
)
|
|
|
|
parts := strings.SplitN(remainder, "_", 2)
|
|
if len(parts) != 2 {
|
|
return nil, false
|
|
}
|
|
|
|
titleTimestamp, err := strconv.ParseInt(parts[0], 10, 64)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
tokenID := parts[1]
|
|
|
|
_, err = l402.MakeIDFromString(tokenID)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
|
|
return &backupFileDetails{
|
|
tokenID: tokenID,
|
|
titleTimestamp: titleTimestamp,
|
|
}, true
|
|
}
|
|
|
|
func findValidBackupFileForToken(dataDir string, key [32]byte, network,
|
|
tokenID string, tokenCreatedAt int64) (string, error) {
|
|
|
|
dirEntries, err := os.ReadDir(dataDir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
for _, entry := range dirEntries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
|
|
// Search by token ID first, then decrypt to verify the candidate is a
|
|
// valid backup for this exact paid-token generation.
|
|
details, ok := parseBackupFileName(entry.Name())
|
|
if !ok || details.tokenID != tokenID {
|
|
continue
|
|
}
|
|
|
|
path := filepath.Join(dataDir, entry.Name())
|
|
payload, err := readBackupPayload(key, path)
|
|
if err != nil {
|
|
// Invalid same-token files are ignored so WriteBackup can replace a
|
|
// corrupt placeholder with a real backup.
|
|
continue
|
|
}
|
|
|
|
err = payload.validateNetwork(network)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
err = payload.validateCompleteGeneration(details)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if payload.L402TokenCreatedAt != tokenCreatedAt {
|
|
continue
|
|
}
|
|
|
|
return path, nil
|
|
}
|
|
|
|
return "", nil
|
|
}
|
|
|
|
func readBackupPayload(key [32]byte, path string) (*backupPayload, error) {
|
|
ciphertext, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
plaintext, err := decryptBackupPayload(key, ciphertext)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var payload backupPayload
|
|
err = json.Unmarshal(plaintext, &payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &payload, nil
|
|
}
|
|
|
|
func validatePaidTokenFiles(
|
|
backupFiles []*l402TokenFileEntry) (*paidTokenMetadata, error) {
|
|
|
|
var paidTokenData []byte
|
|
for _, file := range backupFiles {
|
|
if !isTokenFileName(file.Name) {
|
|
return nil, fmt.Errorf("unexpected token file name %q",
|
|
file.Name)
|
|
}
|
|
|
|
if paidTokenData != nil {
|
|
return nil, fmt.Errorf("backup contains duplicate paid " +
|
|
"L402 token data")
|
|
}
|
|
|
|
paidTokenData = file.Data
|
|
}
|
|
|
|
if paidTokenData == nil {
|
|
return nil, fmt.Errorf("backup file is missing paid L402 token data")
|
|
}
|
|
|
|
return parsePaidTokenMetadata(paidTokenData)
|
|
}
|
|
|
|
func parsePaidTokenMetadata(data []byte) (*paidTokenMetadata, error) {
|
|
r := bytes.NewReader(data)
|
|
|
|
var macLen uint32
|
|
err := binary.Read(r, binary.BigEndian, &macLen)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token macaroon "+
|
|
"length: %w", err)
|
|
}
|
|
|
|
if uint64(macLen) > uint64(r.Len()) {
|
|
return nil, fmt.Errorf("invalid L402 token macaroon length")
|
|
}
|
|
|
|
macBytes := make([]byte, macLen)
|
|
err = binary.Read(r, binary.BigEndian, &macBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token macaroon: %w",
|
|
err)
|
|
}
|
|
|
|
var paymentHash lntypes.Hash
|
|
err = binary.Read(r, binary.BigEndian, &paymentHash)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token payment hash: %w",
|
|
err)
|
|
}
|
|
|
|
var preimage lntypes.Preimage
|
|
err = binary.Read(r, binary.BigEndian, &preimage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token preimage: %w",
|
|
err)
|
|
}
|
|
|
|
if preimage == (lntypes.Preimage{}) {
|
|
return nil, fmt.Errorf("backup L402 token is not paid")
|
|
}
|
|
|
|
var amountPaid uint64
|
|
err = binary.Read(r, binary.BigEndian, &amountPaid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token amount: %w", err)
|
|
}
|
|
|
|
var routingFeePaid uint64
|
|
err = binary.Read(r, binary.BigEndian, &routingFeePaid)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token routing fee: %w",
|
|
err)
|
|
}
|
|
|
|
var tokenCreatedAt int64
|
|
err = binary.Read(r, binary.BigEndian, &tokenCreatedAt)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to read L402 token creation time: %w",
|
|
err)
|
|
}
|
|
|
|
mac := &macaroon.Macaroon{}
|
|
err = mac.UnmarshalBinary(macBytes)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to unmarshal L402 token "+
|
|
"macaroon: %w", err)
|
|
}
|
|
|
|
identifier, err := l402.DecodeIdentifier(bytes.NewReader(mac.Id()))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unable to decode L402 token ID: %w", err)
|
|
}
|
|
|
|
return &paidTokenMetadata{
|
|
tokenID: identifier.TokenID.String(),
|
|
tokenCreatedAt: tokenCreatedAt,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) deriveEncryptionKey(ctx context.Context) ([32]byte, error) {
|
|
return s.signer.DeriveSharedKey(
|
|
ctx, lndclient.SharedKeyNUMS, &backupKeyLocator,
|
|
)
|
|
}
|
|
|
|
func encryptBackupPayload(key [32]byte, plaintext []byte) ([]byte, error) {
|
|
var nonce [24]byte
|
|
_, err := rand.Read(nonce[:])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cipherText := secretbox.Seal(nil, plaintext, &nonce, &key)
|
|
encoded := make([]byte, 0, len(backupMagic)+len(nonce)+len(cipherText))
|
|
encoded = append(encoded, backupMagic...)
|
|
encoded = append(encoded, nonce[:]...)
|
|
encoded = append(encoded, cipherText...)
|
|
|
|
return encoded, nil
|
|
}
|
|
|
|
func decryptBackupPayload(key [32]byte, ciphertext []byte) ([]byte, error) {
|
|
if len(ciphertext) < len(backupMagic)+24 {
|
|
return nil, fmt.Errorf("backup file is too short")
|
|
}
|
|
if !bytes.Equal(ciphertext[:len(backupMagic)], backupMagic) {
|
|
return nil, fmt.Errorf("backup file has an unknown format")
|
|
}
|
|
|
|
var nonce [24]byte
|
|
copy(nonce[:], ciphertext[len(backupMagic):len(backupMagic)+24])
|
|
|
|
plaintext, ok := secretbox.Open(
|
|
nil, ciphertext[len(backupMagic)+24:], &nonce, &key,
|
|
)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unable to decrypt backup file")
|
|
}
|
|
|
|
return plaintext, nil
|
|
}
|
|
|
|
func writeFileAtomically(path string, data []byte) error {
|
|
tempPath := path + ".tmp"
|
|
|
|
// Write private files through a temp path so a crash cannot leave a
|
|
// partially written backup at the final name.
|
|
file, err := os.OpenFile(
|
|
tempPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = file.Write(data)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(tempPath)
|
|
|
|
return err
|
|
}
|
|
|
|
err = file.Sync()
|
|
if err != nil {
|
|
_ = file.Close()
|
|
_ = os.Remove(tempPath)
|
|
|
|
return err
|
|
}
|
|
|
|
err = file.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tempPath)
|
|
|
|
return err
|
|
}
|
|
|
|
err = os.Rename(tempPath, path)
|
|
if err != nil {
|
|
_ = os.Remove(tempPath)
|
|
}
|
|
|
|
return err
|
|
}
|
|
|
|
func isTokenFileName(name string) bool {
|
|
return filepath.Base(name) == name && name == paidTokenFileName
|
|
}
|