Initial release of bdrclone

This commit is contained in:
2026-08-13 23:02:25 +08:00
commit d6d1956050
23 changed files with 3042 additions and 0 deletions

127
internal/config/config.go Normal file
View File

@@ -0,0 +1,127 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const DefaultUserAgent = "pan.baidu.com"
type Config struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RedirectURI string `json:"redirect_uri"`
AccessToken string `json:"access_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
Root string `json:"root"`
UserAgent string `json:"user_agent"`
UploadParts int `json:"upload_parts"`
PartSize int64 `json:"part_size,omitempty"`
}
func DefaultPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("find config directory: %w", err)
}
return filepath.Join(dir, "bdrclone", "config.json"), nil
}
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("config %q does not exist; run `bdrclone config` first", path)
}
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := json.Unmarshal(b, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
cfg.applyDefaults()
return &cfg, cfg.Validate(false)
}
func Save(path string, cfg *Config) error {
cfg.applyDefaults()
if err := cfg.Validate(false); err != nil {
return err
}
b, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("encode config: %w", err)
}
b = append(b, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create config directory: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".config-*")
if err != nil {
return fmt.Errorf("create temporary config: %w", err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return fmt.Errorf("secure config: %w", err)
}
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return fmt.Errorf("write config: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close config: %w", err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replace config: %w", err)
}
return nil
}
func (c *Config) Validate(requireToken bool) error {
if strings.TrimSpace(c.ClientID) == "" || strings.TrimSpace(c.ClientSecret) == "" {
return errors.New("client_id and client_secret are required")
}
if requireToken && c.RefreshToken == "" && c.AccessToken == "" {
return errors.New("no OAuth token; run `bdrclone auth` first")
}
if c.UploadParts < 1 || c.UploadParts > 32 {
return errors.New("upload_parts must be between 1 and 32")
}
if c.PartSize != 0 && c.PartSize < 4<<20 {
return errors.New("part_size must be 0 or at least 4 MiB")
}
if c.PartSize > 32<<20 {
return errors.New("part_size cannot exceed Baidu's 32 MiB maximum")
}
return nil
}
func (c *Config) applyDefaults() {
if c.RedirectURI == "" {
c.RedirectURI = "http://127.0.0.1:53682/callback"
}
if c.Root == "" {
c.Root = "/"
}
if !strings.HasPrefix(c.Root, "/") {
c.Root = "/" + c.Root
}
c.Root = strings.TrimSuffix(c.Root, "/")
if c.Root == "" {
c.Root = "/"
}
if c.UserAgent == "" {
c.UserAgent = DefaultUserAgent
}
if c.UploadParts == 0 {
c.UploadParts = 3
}
}

View File

@@ -0,0 +1,32 @@
package config
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func TestSaveLoadSecureConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "config.json")
cfg := &Config{ClientID: "id", ClientSecret: "secret"}
if err := Save(path, cfg); err != nil {
t.Fatal(err)
}
loaded, err := Load(path)
if err != nil {
t.Fatal(err)
}
if loaded.Root != "/" || loaded.UserAgent != DefaultUserAgent || loaded.UploadParts != 3 {
t.Fatalf("defaults not applied: %+v", loaded)
}
if runtime.GOOS != "windows" {
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("config permissions = %o", info.Mode().Perm())
}
}
}