mirror of
https://github.com/project-zot/zot.git
synced 2026-06-18 05:28:07 +08:00
fa92366009
feat(cli): add typed ~/.zot config layer and strict validation
Introduce pkg/cli/client/config.go with ZliConfigFile/ZliConfig and
ReadZliConfigFile, replacing the loose map[string]any load/save path in
config_cmd.go.
Parsing now rejects malformed JSON with ErrCliBadConfig and requires a
non-null configs array (ErrCliMissingConfigsField when wrapped). Each
profile must have non-empty _name and url.
Config commands delegate to typed helpers (Find, AddEntry, RemoveEntry,
GetVar/SetVar/ResetVar, FormatNames, WriteFile). Fresh or minimal files
still behave as empty via isFreshCliRead (ErrEmptyJSON or missing configs).
Tests: prefer t.Setenv("HOME", t.TempDir()) where CLI resolution uses --url
only; align CVE/client/search tests with mandatory profile URL and HOME
isolation.
Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
330 lines
7.8 KiB
Go
330 lines
7.8 KiB
Go
//go:build search
|
|
|
|
package client
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
jsoniter "github.com/json-iterator/go"
|
|
|
|
zerr "zotregistry.dev/zot/v2/errors"
|
|
)
|
|
|
|
const (
|
|
defaultConfigPerms = 0o644
|
|
defaultFilePerms = 0o600
|
|
|
|
nameKey = "_name"
|
|
showspinnerConfig = "showspinner"
|
|
verifyTLSConfig = "verify-tls"
|
|
)
|
|
|
|
// ZliConfigFile is the on-disk JSON shape for ~/.zot (zli CLI registry profiles).
|
|
type ZliConfigFile struct {
|
|
Configs []ZliConfig `json:"configs"`
|
|
}
|
|
|
|
// ZliConfig is one named registry profile inside ZliConfigFile.Configs.
|
|
type ZliConfig struct {
|
|
Name string `json:"_name"` //nolint:tagliatelle // persisted ~/.zot schema uses `_name`
|
|
URL string `json:"url"`
|
|
ShowSpinner any `json:"showspinner,omitempty"`
|
|
VerifyTLS any `json:"verify-tls,omitempty"` //nolint:tagliatelle // hyphenated ~/.zot key
|
|
}
|
|
|
|
// isConfigUnavailable reports errors that mean there is no usable ~/.zot config yet (empty file
|
|
// or JSON without a "configs" field). Those cases are treated like an empty config for some commands.
|
|
func isConfigUnavailable(err error) bool {
|
|
return errors.Is(err, zerr.ErrEmptyJSON) || errors.Is(err, zerr.ErrCliMissingConfigsField)
|
|
}
|
|
|
|
// ReadZliConfigFile reads and validates ~/.zot JSON from path.
|
|
//
|
|
// If the path does not exist yet, OpenFile(..., O_CREATE) creates an empty file first so later
|
|
// ReadFile sees zero bytes and returns [ErrEmptyJSON] ("fresh" CLI state). Adding entries via
|
|
// `zli config add` still works without relying on this side effect.
|
|
func ReadZliConfigFile(filePath string) (*ZliConfigFile, error) {
|
|
file, err := os.OpenFile(filePath, os.O_RDONLY|os.O_CREATE, defaultConfigPerms)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := file.Close(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data, err := os.ReadFile(filePath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(bytes.TrimSpace(data)) == 0 {
|
|
return nil, zerr.ErrEmptyJSON
|
|
}
|
|
|
|
json := jsoniter.ConfigCompatibleWithStandardLibrary
|
|
|
|
var jsonMap map[string]any
|
|
if unmarshalErr := json.Unmarshal(data, &jsonMap); unmarshalErr != nil {
|
|
return nil, fmt.Errorf("%w: %w", zerr.ErrCliBadConfig, unmarshalErr)
|
|
}
|
|
|
|
if _, ok := jsonMap["configs"]; !ok || jsonMap["configs"] == nil {
|
|
return nil, fmt.Errorf("%w: %w", zerr.ErrCliBadConfig, zerr.ErrCliMissingConfigsField)
|
|
}
|
|
|
|
configsAny, ok := jsonMap["configs"].([]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
`%w: field "configs" must be a JSON array, got %T`,
|
|
zerr.ErrCliBadConfig, jsonMap["configs"])
|
|
}
|
|
|
|
out := &ZliConfigFile{Configs: make([]ZliConfig, 0, len(configsAny))}
|
|
|
|
for i, v := range configsAny {
|
|
configMap, ok := v.(map[string]any)
|
|
if !ok {
|
|
return nil, fmt.Errorf(
|
|
`%w: configs[%d] must be a JSON object, got %T`,
|
|
zerr.ErrCliBadConfig, i, v)
|
|
}
|
|
|
|
c, err := zliConfigFromMap(configMap, i)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out.Configs = append(out.Configs, c)
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func zliConfigFromMap(configMap map[string]any, i int) (ZliConfig, error) {
|
|
nameRaw, nameOk := configMap[nameKey]
|
|
nameStr, nameIsStr := nameRaw.(string)
|
|
if !nameOk || !nameIsStr || strings.TrimSpace(nameStr) == "" {
|
|
return ZliConfig{}, fmt.Errorf(
|
|
`%w: configs[%d]: "_name" must be a non-empty string`,
|
|
zerr.ErrCliBadConfig, i)
|
|
}
|
|
|
|
urlRaw, urlOk := configMap[URLFlag]
|
|
urlStr, urlIsStr := urlRaw.(string)
|
|
if !urlOk || !urlIsStr || strings.TrimSpace(urlStr) == "" {
|
|
return ZliConfig{}, fmt.Errorf(
|
|
`%w: configs[%d]: "url" must be a non-empty string`,
|
|
zerr.ErrCliBadConfig, i)
|
|
}
|
|
|
|
profile := ZliConfig{
|
|
Name: nameStr,
|
|
URL: urlStr,
|
|
}
|
|
|
|
if showSpinner, ok := configMap[showspinnerConfig]; ok {
|
|
profile.ShowSpinner = showSpinner
|
|
}
|
|
|
|
if verifyTLS, ok := configMap[verifyTLSConfig]; ok {
|
|
profile.VerifyTLS = verifyTLS
|
|
}
|
|
|
|
return profile, nil
|
|
}
|
|
|
|
// WriteFile marshals this config to path with standard zli permissions.
|
|
func (f *ZliConfigFile) WriteFile(filePath string) error {
|
|
json := jsoniter.ConfigCompatibleWithStandardLibrary
|
|
|
|
cfg := *f
|
|
if cfg.Configs == nil {
|
|
cfg.Configs = []ZliConfig{}
|
|
}
|
|
|
|
marshalled, err := json.MarshalIndent(&cfg, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.WriteFile(filePath, marshalled, defaultFilePerms); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Find returns the profile with the given name, applying defaults to the matched entry.
|
|
func (f *ZliConfigFile) Find(configName string) (*ZliConfig, error) {
|
|
for i := range f.Configs {
|
|
if f.Configs[i].Name == configName {
|
|
f.Configs[i].ApplyDefaults()
|
|
|
|
return &f.Configs[i], nil
|
|
}
|
|
}
|
|
|
|
return nil, zerr.ErrConfigNotFound
|
|
}
|
|
|
|
// HasEntry reports whether a profile name already exists.
|
|
func (f *ZliConfigFile) HasEntry(configName string) bool {
|
|
return slices.ContainsFunc(f.Configs, func(c ZliConfig) bool {
|
|
return c.Name == configName
|
|
})
|
|
}
|
|
|
|
// AddEntry appends a new profile after validating URL and duplicate names.
|
|
func (f *ZliConfigFile) AddEntry(configName, urlStr string) error {
|
|
if err := validateURL(urlStr); err != nil {
|
|
return err
|
|
}
|
|
|
|
if f.HasEntry(configName) {
|
|
return zerr.ErrDuplicateConfigName
|
|
}
|
|
|
|
c := ZliConfig{
|
|
Name: configName,
|
|
URL: urlStr,
|
|
}
|
|
c.ApplyDefaults()
|
|
f.Configs = append(f.Configs, c)
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveEntry removes a profile by name.
|
|
func (f *ZliConfigFile) RemoveEntry(configName string) error {
|
|
for i, c := range f.Configs {
|
|
if c.Name != configName {
|
|
continue
|
|
}
|
|
|
|
f.Configs = append(f.Configs[:i], f.Configs[i+1:]...)
|
|
|
|
return nil
|
|
}
|
|
|
|
return zerr.ErrConfigNotFound
|
|
}
|
|
|
|
// FormatNames renders name and URL columns for `zli config --list`.
|
|
func (f *ZliConfigFile) FormatNames() (string, error) {
|
|
var builder strings.Builder
|
|
|
|
writer := tabwriter.NewWriter(&builder, 0, 8, 1, '\t', tabwriter.AlignRight) //nolint:mnd
|
|
|
|
for _, c := range f.Configs {
|
|
fmt.Fprintf(writer, "%s\t%s\n", c.Name, c.URL)
|
|
}
|
|
|
|
if err := writer.Flush(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return builder.String(), nil
|
|
}
|
|
|
|
// ApplyDefaults sets omitted boolean fields to their CLI defaults (mutates receiver).
|
|
func (c *ZliConfig) ApplyDefaults() {
|
|
if c.ShowSpinner == nil {
|
|
c.ShowSpinner = true
|
|
}
|
|
|
|
if c.VerifyTLS == nil {
|
|
c.VerifyTLS = true
|
|
}
|
|
}
|
|
|
|
// GetVar returns a single setting as text (after defaults apply via Find).
|
|
func (c *ZliConfig) GetVar(key string) (string, error) {
|
|
switch key {
|
|
case nameKey:
|
|
return c.Name, nil
|
|
case URLFlag:
|
|
return c.URL, nil
|
|
case showspinnerConfig:
|
|
if c.ShowSpinner == nil {
|
|
return "", nil
|
|
}
|
|
|
|
return fmt.Sprintf("%v", c.ShowSpinner), nil
|
|
case verifyTLSConfig:
|
|
if c.VerifyTLS == nil {
|
|
return "", nil
|
|
}
|
|
|
|
return fmt.Sprintf("%v", c.VerifyTLS), nil
|
|
default:
|
|
return "", zerr.ErrIllegalConfigKey
|
|
}
|
|
}
|
|
|
|
// SetVar updates url / showspinner / verify-tls (does not persist).
|
|
func (c *ZliConfig) SetVar(key, value string) error {
|
|
if key == nameKey {
|
|
return zerr.ErrIllegalConfigKey
|
|
}
|
|
|
|
if key != URLFlag && key != showspinnerConfig && key != verifyTLSConfig {
|
|
return zerr.ErrIllegalConfigKey
|
|
}
|
|
|
|
boolVal, parseErr := strconv.ParseBool(value)
|
|
out := any(value)
|
|
if parseErr == nil {
|
|
out = boolVal
|
|
}
|
|
|
|
switch key {
|
|
case URLFlag:
|
|
if err := validateURL(value); err != nil {
|
|
return err
|
|
}
|
|
|
|
c.URL = value
|
|
case showspinnerConfig:
|
|
c.ShowSpinner = out
|
|
case verifyTLSConfig:
|
|
c.VerifyTLS = out
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResetVar clears optional booleans (does not persist).
|
|
func (c *ZliConfig) ResetVar(key string) error {
|
|
switch key {
|
|
case URLFlag, nameKey:
|
|
return zerr.ErrCannotResetConfigKey
|
|
case showspinnerConfig:
|
|
c.ShowSpinner = nil
|
|
case verifyTLSConfig:
|
|
c.VerifyTLS = nil
|
|
default:
|
|
return zerr.ErrIllegalConfigKey
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// FormatListedVars renders lines for `zli config <name> --list`.
|
|
func (c *ZliConfig) FormatListedVars() string {
|
|
var builder strings.Builder
|
|
|
|
fmt.Fprintf(&builder, "%s = %v\n", URLFlag, c.URL)
|
|
fmt.Fprintf(&builder, "%s = %v\n", showspinnerConfig, c.ShowSpinner)
|
|
fmt.Fprintf(&builder, "%s = %v\n", verifyTLSConfig, c.VerifyTLS)
|
|
|
|
return builder.String()
|
|
}
|