check / check (push) Waiting to run
config set wrote every value as an unquoted YAML scalar, and config.Load reads the file through untyped YAML, so an access key 00112233 loaded as 38043, a 32-digit secret as 1.2345678901234567e+31 and a hostname 007 as 7. config set now looks the key up in config.Config by the fields' yaml tags. A string setting is tagged !!str, which the encoder quotes wherever YAML would read a number or a boolean. Other settings stay unquoted, so compression_level 9 is still a number. Judgement call: the type comes from reflection over config.Config. Quoting every value YAML would change avoids that, but would also quote odd numbers such as compression_level 03 and make them fail to load. Model: opus-5-5
724 lines
21 KiB
Go
724 lines
21 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/spf13/cobra"
|
|
"gopkg.in/yaml.v3"
|
|
"sneak.berlin/go/vaultik/internal/config"
|
|
"sneak.berlin/go/vaultik/internal/ui"
|
|
)
|
|
|
|
// configFileMode is the permission set for freshly written config files;
|
|
// configs may hold S3 credentials, so keep them owner-only.
|
|
const configFileMode = 0o600
|
|
|
|
// configSetArgs is the argument count of `config set <key> <value>`.
|
|
const configSetArgs = 2
|
|
|
|
// configDirMode is the permission set for created config directories;
|
|
// parent config dirs (e.g. ~/.config) are conventionally traversable.
|
|
const configDirMode = 0o755
|
|
|
|
// configYAMLIndent matches the 2-space indentation of defaultConfigTemplate,
|
|
// so `config set` writes the file back with the same indentation rather than
|
|
// yaml.Marshal's 4-space default.
|
|
const configYAMLIndent = 2
|
|
|
|
// yamlStringTag is YAML's tag for a string scalar.
|
|
const yamlStringTag = "!!str"
|
|
|
|
var (
|
|
errConfigExists = errors.New("config file already exists")
|
|
errEmptyConfig = errors.New("empty config file")
|
|
errKeyNotFound = errors.New("key not found")
|
|
errNeedNumericIndex = errors.New("key is a list; use a numeric index")
|
|
errIndexOutOfRange = errors.New("index out of range")
|
|
errNotMapOrList = errors.New("key is not a map or list")
|
|
)
|
|
|
|
const defaultConfigTemplate = `# vaultik configuration
|
|
# Documentation: https://sneak.berlin/go/vaultik
|
|
|
|
# ─── REQUIRED ────────────────────────────────────────────────────────────────
|
|
|
|
# Age recipient public keys for encryption. snapshot create needs at least
|
|
# one; listing, verifying and restoring do not, so a machine that only
|
|
# restores can leave this empty.
|
|
# Backups are encrypted to ALL listed recipients; any one of the corresponding
|
|
# private keys can decrypt. Adding a recipient later does not re-encrypt data
|
|
# already stored: deduplicated chunks and existing blobs stay encrypted to the
|
|
# earlier recipients, so a newly added key cannot restore them on its own (see
|
|
# docs/REPOSTRUCTURE.md, Accepted Risks). Generate a keypair and add its
|
|
# public key with:
|
|
# age-keygen -o vaultik_backup_private_key.txt
|
|
# grep 'public key' vaultik_backup_private_key.txt
|
|
# vaultik config set age_recipients.0 age1...
|
|
age_recipients: []
|
|
|
|
# Named snapshots. Each snapshot backs up one or more paths and can have its
|
|
# own exclude patterns in addition to the global excludes below.
|
|
#
|
|
# Exclude pattern semantics:
|
|
# - Patterns starting with / are anchored to the snapshot path root
|
|
# (e.g. "/Library/Caches" matches only ~/Library/Caches in a ~ snapshot)
|
|
# - Patterns without a leading / match anywhere in the tree
|
|
# (e.g. ".cache" matches any directory named .cache at any depth)
|
|
# - Globs are supported: *, **, ?
|
|
snapshots:
|
|
home:
|
|
paths:
|
|
- "~"
|
|
exclude:
|
|
# Trash, temp, and filesystem metadata
|
|
- "/.Trash"
|
|
- "/.Trashes"
|
|
- "/.fseventsd"
|
|
- "/.Spotlight-V100"
|
|
- "/.TemporaryItems"
|
|
- "/tmp"
|
|
- "/.rnd"
|
|
- ".DS_Store"
|
|
# Caches and package manager state (rebuildable)
|
|
- ".cache"
|
|
- ".bundle"
|
|
- "/.cpan/build"
|
|
- "/.cpan/sources"
|
|
- "/.gradle/caches"
|
|
- "/.dropbox"
|
|
- "/.minikube/cache"
|
|
- "/.local/share/containers/podman/machine"
|
|
- "/.persepolis"
|
|
- "/Library/Caches"
|
|
- "/Library/Logs"
|
|
- "/Library/Cookies"
|
|
- "/Library/Metadata"
|
|
- "/Library/Suggestions"
|
|
- "/Library/PubSub"
|
|
- "/Library/Homebrew"
|
|
- "/Library/Developer"
|
|
- "/Library/Google/GoogleSoftwareUpdate"
|
|
- "/Library/Preferences/Macromedia/Flash Player"
|
|
- "/Library/Preferences/SDMHelpData"
|
|
- "/Library/VoiceTrigger/SAT"
|
|
# Language/toolchain package caches (rebuildable from registries)
|
|
- "/.npm"
|
|
- "/.cargo/registry"
|
|
- "/.cargo/git"
|
|
- "/.rustup/toolchains"
|
|
- "/go/pkg/mod"
|
|
- "/.m2/repository"
|
|
- "/.vagrant.d/boxes"
|
|
- "node_modules"
|
|
- "__pycache__"
|
|
- ".venv"
|
|
# Virtual machine disk images (huge; remove these lines to back them up)
|
|
- "/Parallels"
|
|
- "/Virtual Machines.localized"
|
|
- "/VirtualBox VMs"
|
|
- "/.orbstack"
|
|
- "/Library/Containers/com.utmapp.UTM"
|
|
# Downloaded LLM models (huge, re-downloadable)
|
|
- "/.ollama/models"
|
|
- "/.lmstudio/models"
|
|
# Cloud-synced storage. These are synced to a provider already, and on
|
|
# modern macOS may contain dataless placeholder files that the backup
|
|
# would force-download in full.
|
|
- "/Library/CloudStorage"
|
|
- "/Library/Mobile Documents"
|
|
# Android SDK and emulator images (re-downloadable)
|
|
- "/Library/Android/sdk"
|
|
- "/.android/avd"
|
|
# Cloud-synced or restorable-from-server data
|
|
- "/Library/Mail"
|
|
- "/Library/Mail Downloads"
|
|
- "/Library/Safari"
|
|
- "/Library/Application Support/Evernote"
|
|
- "/Library/Application Support/MobileSync"
|
|
- "/Library/Application Support/SyncServices"
|
|
- "/Library/Application Support/protonmail/bridge/cache"
|
|
- "/Library/Application Support/Syncthing/index-*"
|
|
- "/Library/Syncthing/folders"
|
|
- "/Documents/Dropbox/.dropbox.cache"
|
|
# Large rebuildable app data (games, media caches, device backups)
|
|
- "/Applications/Fortnite"
|
|
- "/Documents/Steam Content"
|
|
- "/Library/Application Support/Ableton"
|
|
- "/Library/Application Support/CrossOver Games"
|
|
- "/Library/Application Support/SecondLife/cache"
|
|
- "/Library/Application Support/Steam/SteamApps"
|
|
- "/Library/Containers/com.docker.docker"
|
|
- "/Library/Group Containers/group.com.apple.secure-control-center-preferences"
|
|
- "/Library/iTunes/iPad Software Updates"
|
|
- "/Library/iTunes/iPhone Software Updates"
|
|
- "/Movies/CacheClip"
|
|
- "/Movies/ProxyMedia"
|
|
- "/Music/iTunes/Album Artwork"
|
|
- "/Pictures/iPod Photo Cache"
|
|
|
|
# Third-party applications. OS-provided apps live in /System/Applications
|
|
# on modern macOS and are never in /Applications, but Apple-installed
|
|
# App Store apps (Safari, GarageBand, iWork, iMovie) are excluded since
|
|
# they are re-downloadable.
|
|
apps:
|
|
paths:
|
|
- /Applications
|
|
exclude:
|
|
- ".DS_Store"
|
|
- "/Safari.app"
|
|
- "/GarageBand.app"
|
|
- "/iMovie.app"
|
|
- "/Keynote.app"
|
|
- "/Numbers.app"
|
|
- "/Pages.app"
|
|
- "/Xcode.app"
|
|
- "/Spotify.app"
|
|
- "/Steam.app"
|
|
- "/VirtualBox.app"
|
|
- "/Utilities/Adobe Installers"
|
|
|
|
# Storage backend (pick ONE of the three forms below).
|
|
#
|
|
# S3-compatible:
|
|
# storage_url: "s3://mybucket/backups?endpoint=s3.example.com®ion=us-east-1"
|
|
# (also set s3.access_key_id and s3.secret_access_key below)
|
|
#
|
|
# Local filesystem:
|
|
# storage_url: "file:///mnt/backups/vaultik"
|
|
#
|
|
# Rclone (requires rclone configured separately):
|
|
# storage_url: "rclone://myremote/path/to/backups"
|
|
storage_url: ""
|
|
|
|
# ─── S3 CREDENTIALS (required for s3:// storage_url) ────────────────────────
|
|
|
|
# s3:
|
|
# access_key_id: YOUR_ACCESS_KEY
|
|
# secret_access_key: YOUR_SECRET_KEY
|
|
# # region: us-east-1 # Default: us-east-1
|
|
# # part_size: 5MB # Multipart upload part size. Default: 5MB
|
|
# # For the s3:// form, disable TLS with ?ssl=false in the URL, not use_ssl.
|
|
|
|
# ─── OPTIONAL ────────────────────────────────────────────────────────────────
|
|
|
|
# Global exclude patterns applied to ALL snapshots.
|
|
# Snapshot-specific excludes are additive.
|
|
# exclude:
|
|
# - "*.log"
|
|
# - "*.tmp"
|
|
# - ".git"
|
|
# - "node_modules"
|
|
|
|
# Average chunk size for content-defined chunking (FastCDC).
|
|
# Smaller = better deduplication but more metadata overhead.
|
|
# Accepts: 1MB, 10M, 64KB, etc.
|
|
# Default: 10MB
|
|
# chunk_size: 10MB
|
|
|
|
# Maximum blob size before splitting into a new blob.
|
|
# Must be at least four times chunk_size (the largest chunk the chunker can
|
|
# emit); a smaller limit would let a single-chunk blob exceed it.
|
|
# Accepts: 1GB, 10G, 500MB, etc.
|
|
# Default: 10GB
|
|
# blob_size_limit: 10GB
|
|
|
|
# Zstd compression level (1-19). Higher = better ratio but slower.
|
|
# Default: 3
|
|
# compression_level: 3
|
|
|
|
# Hostname used in snapshot IDs. Default: system hostname.
|
|
# hostname: myserver
|
|
|
|
# Path to the local SQLite index database.
|
|
# Default: the platform data directory, e.g.
|
|
# macOS: ~/Library/Application Support/vaultik/index.sqlite
|
|
# Linux: ~/.local/share/vaultik/index.sqlite
|
|
# index_path: /path/to/index.sqlite
|
|
`
|
|
|
|
// NewConfigCommand creates the config command group.
|
|
func NewConfigCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "config",
|
|
Short: "Manage the configuration file",
|
|
Long: "Commands for creating, editing, and querying the vaultik config file.",
|
|
}
|
|
|
|
cmd.AddCommand(newConfigInitCommand())
|
|
cmd.AddCommand(newConfigEditCommand())
|
|
cmd.AddCommand(newConfigGetCommand())
|
|
cmd.AddCommand(newConfigSetCommand())
|
|
|
|
return cmd
|
|
}
|
|
|
|
// newConfigInitCommand creates the 'config init' subcommand.
|
|
func newConfigInitCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "init",
|
|
Short: "Write a default config file",
|
|
Long: `Creates a default configuration file with commented explanations
|
|
for every setting. If a config file already exists at the target path,
|
|
the command refuses to overwrite it.
|
|
|
|
The config is written to the path from --config, $VAULTIK_CONFIG, or
|
|
the platform default config directory (e.g. ~/Library/Application Support/
|
|
on macOS, ~/.config/ on Linux, /etc/vaultik/ as root).`,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
path := configPathForInit()
|
|
|
|
_, err := os.Stat(path)
|
|
if err == nil {
|
|
return fmt.Errorf("%w: %s", errConfigExists, path)
|
|
}
|
|
|
|
dir := filepath.Dir(path)
|
|
|
|
err = os.MkdirAll(dir, configDirMode)
|
|
if err != nil {
|
|
return fmt.Errorf("creating config directory %s: %w", dir, err)
|
|
}
|
|
|
|
err = os.WriteFile(path, []byte(defaultConfigTemplate), configFileMode)
|
|
if err != nil {
|
|
return fmt.Errorf("writing config file: %w", err)
|
|
}
|
|
|
|
// A written-confirmation, not scriptable output: route it
|
|
// through the UI so it is styled and --quiet silences it.
|
|
out := commandUI(cmd)
|
|
out.Infof("Config written to %s.", path)
|
|
out.Infof(
|
|
"Edit it to set your age_recipients, snapshots, and storage_url.")
|
|
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigEditCommand creates the 'config edit' subcommand.
|
|
func newConfigEditCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "edit",
|
|
Short: "Open the config file in $EDITOR",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
editor := os.Getenv("EDITOR")
|
|
if editor == "" {
|
|
editor = "vi"
|
|
}
|
|
|
|
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
|
ed := exec.CommandContext(cmd.Context(), editor, path)
|
|
ed.Stdin = os.Stdin
|
|
ed.Stdout = os.Stdout
|
|
ed.Stderr = os.Stderr
|
|
|
|
return ed.Run()
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigGetCommand creates the 'config get' subcommand.
|
|
func newConfigGetCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "get <key>",
|
|
Short: "Print a config value by dotted path (e.g. storage_url, compression_level)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
root, err := loadYAMLFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
node, err := yamlPathGet(root, strings.Split(args[0], "."))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// The value is scriptable output: it must stay machine-plain
|
|
// (no marker, no color) and is never silenced by --quiet, so it
|
|
// is written straight to stdout rather than through the UI.
|
|
w := cmd.OutOrStdout()
|
|
|
|
if node.Kind == yaml.ScalarNode {
|
|
_, _ = fmt.Fprintln(w, node.Value)
|
|
|
|
return nil
|
|
}
|
|
|
|
out, err := yaml.Marshal(node)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling value: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprint(w, string(out))
|
|
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
// newConfigSetCommand creates the 'config set' subcommand.
|
|
func newConfigSetCommand() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "set <key> <value>",
|
|
Short: "Set a config value by dotted path (e.g. compression_level 5)",
|
|
Long: `Sets a scalar config value addressed by dotted YAML path and writes
|
|
the file back, preserving comments and formatting. Intermediate maps
|
|
are created as needed.
|
|
|
|
Examples:
|
|
vaultik config set storage_url "file:///mnt/backups"
|
|
vaultik config set storage_url "s3://bucket/prefix?endpoint=host®ion=us-east-1"
|
|
vaultik config set compression_level 9
|
|
vaultik config set s3.bucket mybucket # legacy S3 fields still supported`,
|
|
Args: cobra.ExactArgs(configSetArgs),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
path, err := ResolveConfigPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return writeConfigSet(commandUI(cmd), path, args[0], args[1])
|
|
},
|
|
}
|
|
}
|
|
|
|
// writeConfigSet applies key=value to the config at path, writes it back
|
|
// owner-only, and confirms the write by naming just the key through the
|
|
// UI writer (styled, and silenced by --quiet). The value is never
|
|
// echoed: it may be a secret such as s3.secret_access_key, and captured
|
|
// stdout or a pasted terminal would then leak it.
|
|
func writeConfigSet(out *ui.Writer, path, key, value string) error {
|
|
root, err := loadYAMLFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = yamlPathSet(root, strings.Split(key, "."), value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
data, err := marshalConfigYAML(root)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling config: %w", err)
|
|
}
|
|
|
|
err = os.WriteFile(path, data, configFileMode)
|
|
if err != nil {
|
|
return fmt.Errorf("writing config file: %w", err)
|
|
}
|
|
|
|
// os.WriteFile does not change the mode of a file that already exists,
|
|
// so a config that was group- or world-readable stays that way. As it
|
|
// may hold S3 credentials, tighten it to owner-only after writing.
|
|
info, statErr := os.Stat(path)
|
|
if statErr == nil && info.Mode().Perm()&0o044 != 0 {
|
|
err = os.Chmod(path, configFileMode)
|
|
if err != nil {
|
|
return fmt.Errorf("tightening config file permissions: %w", err)
|
|
}
|
|
}
|
|
|
|
out.Infof("Set %s.", key)
|
|
|
|
return nil
|
|
}
|
|
|
|
// marshalConfigYAML renders a config document tree with 2-space indentation,
|
|
// matching defaultConfigTemplate. yaml.Marshal defaults to 4 spaces, which
|
|
// would reindent the whole file on the first `config set` despite the promise
|
|
// to preserve formatting.
|
|
func marshalConfigYAML(root *yaml.Node) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
enc := yaml.NewEncoder(&buf)
|
|
enc.SetIndent(configYAMLIndent)
|
|
|
|
err := enc.Encode(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = enc.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// loadYAMLFile parses a YAML file into a yaml.Node document tree,
|
|
// which preserves comments and ordering for round-tripping.
|
|
func loadYAMLFile(path string) (*yaml.Node, error) {
|
|
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading config file: %w", err)
|
|
}
|
|
|
|
var root yaml.Node
|
|
|
|
err = yaml.Unmarshal(data, &root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing config file: %w", err)
|
|
}
|
|
|
|
// An empty file yields a zero node; normalize to an empty mapping document.
|
|
if root.Kind == 0 {
|
|
root = yaml.Node{
|
|
Kind: yaml.DocumentNode,
|
|
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
|
|
}
|
|
}
|
|
|
|
return &root, nil
|
|
}
|
|
|
|
// yamlPathGet navigates a dotted key path through mapping and sequence
|
|
// nodes and returns the value node. Numeric path components index into
|
|
// sequences (e.g. "age_recipients.0").
|
|
func yamlPathGet(root *yaml.Node, keys []string) (*yaml.Node, error) {
|
|
node := root
|
|
if node.Kind == yaml.DocumentNode {
|
|
if len(node.Content) == 0 {
|
|
return nil, errEmptyConfig
|
|
}
|
|
|
|
node = node.Content[0]
|
|
}
|
|
|
|
for i, key := range keys {
|
|
switch node.Kind {
|
|
case yaml.MappingNode:
|
|
found := false
|
|
|
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
|
if node.Content[j].Value == key {
|
|
node = node.Content[j+1]
|
|
found = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !found {
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errKeyNotFound, strings.Join(keys[:i+1], "."))
|
|
}
|
|
case yaml.SequenceNode:
|
|
idx, err := strconv.Atoi(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNeedNumericIndex, strings.Join(keys[:i], "."))
|
|
}
|
|
|
|
if idx < 0 || idx >= len(node.Content) {
|
|
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
|
|
errIndexOutOfRange, idx, strings.Join(keys[:i], "."),
|
|
len(node.Content))
|
|
}
|
|
|
|
node = node.Content[idx]
|
|
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
default:
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
}
|
|
}
|
|
|
|
return node, nil
|
|
}
|
|
|
|
// yamlPathSet navigates a dotted key path, creating intermediate maps as
|
|
// needed, and sets the final key to the given scalar value. Numeric path
|
|
// components index into sequences; an index equal to the sequence length
|
|
// appends a new element (e.g. "age_recipients.1" on a 1-element list).
|
|
func yamlPathSet(root *yaml.Node, keys []string, value string) error {
|
|
node := root
|
|
if node.Kind == yaml.DocumentNode {
|
|
if len(node.Content) == 0 {
|
|
node.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
|
|
}
|
|
|
|
node = node.Content[0]
|
|
}
|
|
|
|
for i, key := range keys {
|
|
last := i == len(keys)-1
|
|
|
|
switch node.Kind {
|
|
case yaml.MappingNode:
|
|
node = yamlSetInMapping(node, key, value, last)
|
|
case yaml.SequenceNode:
|
|
next, err := yamlSetInSequence(node, keys, i, value, last)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
node = next
|
|
case yaml.DocumentNode, yaml.ScalarNode, yaml.AliasNode:
|
|
return fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
default:
|
|
return fmt.Errorf("%w: %s",
|
|
errNotMapOrList, strings.Join(keys[:i], "."))
|
|
}
|
|
}
|
|
|
|
// config.Load reads the file through untyped YAML, which turns an
|
|
// unquoted 00112233 into the number 38043 and 1e5 into 100000. Tagging
|
|
// a string setting as a string makes the encoder quote such a value.
|
|
// Other settings stay unquoted, so compression_level 9 is a number.
|
|
if configKeyIsString(keys) {
|
|
node.Tag = yamlStringTag
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// configKeyIsString reports whether the dotted key names a string in
|
|
// config.Config, following the fields' yaml tags, as s3.access_key_id and
|
|
// snapshots.home.exclude.0 do.
|
|
func configKeyIsString(keys []string) bool {
|
|
typ := reflect.TypeFor[config.Config]()
|
|
|
|
for _, key := range keys {
|
|
switch {
|
|
case typ.Kind() == reflect.Map || typ.Kind() == reflect.Slice:
|
|
// The key is a snapshot name or a list index.
|
|
typ = typ.Elem()
|
|
case typ.Kind() == reflect.Struct:
|
|
field, ok := yamlField(typ, key)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
typ = field.Type
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
return typ.Kind() == reflect.String
|
|
}
|
|
|
|
// yamlField returns the field of struct type typ whose yaml tag names key.
|
|
func yamlField(typ reflect.Type, key string) (reflect.StructField, bool) {
|
|
for field := range typ.Fields() {
|
|
name, _, _ := strings.Cut(field.Tag.Get("yaml"), ",")
|
|
if name == key {
|
|
return field, true
|
|
}
|
|
}
|
|
|
|
return reflect.StructField{}, false
|
|
}
|
|
|
|
// yamlSetInMapping resolves (creating if needed) the value node for key
|
|
// within a mapping node, setting it to value when it is the final path
|
|
// element, and returns the node to descend into.
|
|
func yamlSetInMapping(node *yaml.Node, key, value string, last bool) *yaml.Node {
|
|
var valueNode *yaml.Node
|
|
|
|
for j := 0; j+1 < len(node.Content); j += 2 {
|
|
if node.Content[j].Value == key {
|
|
valueNode = node.Content[j+1]
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if valueNode == nil {
|
|
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: key}
|
|
|
|
valueNode = &yaml.Node{Kind: yaml.MappingNode}
|
|
if last {
|
|
valueNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
}
|
|
|
|
node.Content = append(node.Content, keyNode, valueNode)
|
|
} else if last {
|
|
setScalar(valueNode, value)
|
|
}
|
|
|
|
return valueNode
|
|
}
|
|
|
|
// yamlSetInSequence indexes (or appends to) a sequence node using the
|
|
// numeric path element keys[i], setting the element to value when it is
|
|
// the final path element, and returns the node to descend into.
|
|
func yamlSetInSequence(
|
|
node *yaml.Node, keys []string, i int, value string, last bool,
|
|
) (*yaml.Node, error) {
|
|
idx, err := strconv.Atoi(keys[i])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %s",
|
|
errNeedNumericIndex, strings.Join(keys[:i], "."))
|
|
}
|
|
|
|
if idx < 0 || idx > len(node.Content) {
|
|
return nil, fmt.Errorf("%w: index %d for %s (len %d)",
|
|
errIndexOutOfRange, idx, strings.Join(keys[:i], "."),
|
|
len(node.Content))
|
|
}
|
|
|
|
if idx == len(node.Content) {
|
|
newNode := &yaml.Node{Kind: yaml.MappingNode}
|
|
if last {
|
|
newNode = &yaml.Node{Kind: yaml.ScalarNode, Value: value}
|
|
}
|
|
|
|
node.Content = append(node.Content, newNode)
|
|
} else if last {
|
|
setScalar(node.Content[idx], value)
|
|
}
|
|
|
|
return node.Content[idx], nil
|
|
}
|
|
|
|
// setScalar overwrites a node in place with a plain scalar value.
|
|
func setScalar(n *yaml.Node, value string) {
|
|
n.Kind = yaml.ScalarNode
|
|
n.Tag = ""
|
|
n.Value = value
|
|
n.Content = nil
|
|
n.Style = 0
|
|
}
|
|
|
|
// configPathForInit returns the config path to write, checking --config flag,
|
|
// VAULTIK_CONFIG env, and the platform default.
|
|
func configPathForInit() string {
|
|
if rootFlags.ConfigPath != "" {
|
|
return rootFlags.ConfigPath
|
|
}
|
|
|
|
if envPath := os.Getenv("VAULTIK_CONFIG"); envPath != "" {
|
|
return envPath
|
|
}
|
|
|
|
return DefaultConfigPath()
|
|
}
|