- Implement deterministic blob hashing using double SHA256 of uncompressed plaintext data, enabling deduplication even after local DB is cleared - Add Stat() check before blob upload to skip existing blobs in storage - Add rclone storage backend for additional remote storage options - Add 'vaultik database purge' command to erase local state DB - Add 'vaultik remote check' command to verify remote connectivity - Show configured snapshots in 'vaultik snapshot list' output - Skip macOS resource fork files (._*) when listing remote snapshots - Use multi-threaded zstd compression (CPUs - 2 threads) - Add writer tests for double hashing behavior
119 lines
2.9 KiB
Go
119 lines
2.9 KiB
Go
package storage
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// StorageURL represents a parsed storage URL.
|
|
type StorageURL struct {
|
|
Scheme string // "s3", "file", or "rclone"
|
|
Bucket string // S3 bucket name (empty for file/rclone)
|
|
Prefix string // Path within bucket or filesystem base path
|
|
Endpoint string // S3 endpoint (optional, default AWS)
|
|
Region string // S3 region (optional)
|
|
UseSSL bool // Use HTTPS for S3 (default true)
|
|
RcloneRemote string // rclone remote name (for rclone:// URLs)
|
|
}
|
|
|
|
// ParseStorageURL parses a storage URL string.
|
|
// Supported formats:
|
|
// - s3://bucket/prefix?endpoint=host®ion=us-east-1&ssl=true
|
|
// - file:///absolute/path/to/backup
|
|
// - rclone://remote/path/to/backups
|
|
func ParseStorageURL(rawURL string) (*StorageURL, error) {
|
|
if rawURL == "" {
|
|
return nil, fmt.Errorf("storage URL is empty")
|
|
}
|
|
|
|
// Handle file:// URLs
|
|
if strings.HasPrefix(rawURL, "file://") {
|
|
path := strings.TrimPrefix(rawURL, "file://")
|
|
if path == "" {
|
|
return nil, fmt.Errorf("file URL path is empty")
|
|
}
|
|
return &StorageURL{
|
|
Scheme: "file",
|
|
Prefix: path,
|
|
}, nil
|
|
}
|
|
|
|
// Handle s3:// URLs
|
|
if strings.HasPrefix(rawURL, "s3://") {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
bucket := u.Host
|
|
if bucket == "" {
|
|
return nil, fmt.Errorf("s3 URL missing bucket name")
|
|
}
|
|
|
|
prefix := strings.TrimPrefix(u.Path, "/")
|
|
|
|
query := u.Query()
|
|
useSSL := true
|
|
if query.Get("ssl") == "false" {
|
|
useSSL = false
|
|
}
|
|
|
|
return &StorageURL{
|
|
Scheme: "s3",
|
|
Bucket: bucket,
|
|
Prefix: prefix,
|
|
Endpoint: query.Get("endpoint"),
|
|
Region: query.Get("region"),
|
|
UseSSL: useSSL,
|
|
}, nil
|
|
}
|
|
|
|
// Handle rclone:// URLs
|
|
if strings.HasPrefix(rawURL, "rclone://") {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid URL: %w", err)
|
|
}
|
|
|
|
remote := u.Host
|
|
if remote == "" {
|
|
return nil, fmt.Errorf("rclone URL missing remote name")
|
|
}
|
|
|
|
path := strings.TrimPrefix(u.Path, "/")
|
|
|
|
return &StorageURL{
|
|
Scheme: "rclone",
|
|
Prefix: path,
|
|
RcloneRemote: remote,
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("unsupported URL scheme: must start with s3://, file://, or rclone://")
|
|
}
|
|
|
|
// String returns a human-readable representation of the storage URL.
|
|
func (u *StorageURL) String() string {
|
|
switch u.Scheme {
|
|
case "file":
|
|
return fmt.Sprintf("file://%s", u.Prefix)
|
|
case "s3":
|
|
endpoint := u.Endpoint
|
|
if endpoint == "" {
|
|
endpoint = "s3.amazonaws.com"
|
|
}
|
|
if u.Prefix != "" {
|
|
return fmt.Sprintf("s3://%s/%s (endpoint: %s)", u.Bucket, u.Prefix, endpoint)
|
|
}
|
|
return fmt.Sprintf("s3://%s (endpoint: %s)", u.Bucket, endpoint)
|
|
case "rclone":
|
|
if u.Prefix != "" {
|
|
return fmt.Sprintf("rclone://%s/%s", u.RcloneRemote, u.Prefix)
|
|
}
|
|
return fmt.Sprintf("rclone://%s", u.RcloneRemote)
|
|
default:
|
|
return fmt.Sprintf("%s://?", u.Scheme)
|
|
}
|
|
}
|