check / check (push) Successful in 12m48s
config set storage_url to an s3:// URL was refused, without saying why, when s3.access_key_id and s3.secret_access_key were not yet set. The refusal now names both keys and says to set them first. config set still refuses any change that leaves the config unable to load, and clearing a key gets the plain refusal. Model: opus-5-5
583 regels
19 KiB
Go
583 regels
19 KiB
Go
// Package config loads, validates, and provides the vaultik YAML
|
|
// configuration, including snapshot definitions, encryption recipients,
|
|
// and storage settings.
|
|
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"git.eeqj.de/sneak/smartconfig"
|
|
"github.com/adrg/xdg"
|
|
"go.uber.org/fx"
|
|
"gopkg.in/yaml.v3"
|
|
"sneak.berlin/go/vaultik/internal/chunker"
|
|
"sneak.berlin/go/vaultik/internal/log"
|
|
)
|
|
|
|
const appName = "vaultik"
|
|
|
|
// secretKeyPrefix marks an age secret (private) key. It is compared
|
|
// case-insensitively so a recipient entry that is actually a private key is
|
|
// caught and never passed to age or echoed back.
|
|
//
|
|
//nolint:gosec // G101: marker for detecting a pasted secret key, not a credential
|
|
const secretKeyPrefix = "AGE-SECRET-KEY-"
|
|
|
|
// Defaults and validation bounds for tunable settings. MB and GB are
|
|
// 1000-based, as the size parser reads them; the S3 part sizes are S3's own
|
|
// limits, which are 1024-based (MiB, GiB).
|
|
const (
|
|
defaultBlobSizeLimit = Size(10 * 1000 * 1000 * 1000) // 10GB
|
|
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
|
|
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
|
|
defaultCompressionLevel = 3
|
|
defaultPruneGracePeriod = 24 * time.Hour
|
|
minChunkSize = 1000 * 1000 // 1MB
|
|
minCompressionLevel = 1
|
|
maxCompressionLevel = 19
|
|
// S3 accepts a multipart upload part from 5MiB to 5GiB.
|
|
minS3PartSize = 5 * 1024 * 1024
|
|
maxS3PartSize = 5 * 1024 * 1024 * 1024
|
|
)
|
|
|
|
// Sentinel validation errors.
|
|
var (
|
|
errNoConfigPath = errors.New("config path not provided")
|
|
errRecipientIsSecretKey = errors.New(
|
|
"an age secret key was given where a public key (age1...) belongs")
|
|
errRecipientNotX25519 = errors.New(
|
|
"not a valid recipient; only X25519 age1... public keys are supported")
|
|
errNoSnapshots = errors.New(
|
|
"at least one snapshot must be configured (see configs/config.example.yml)")
|
|
errSnapshotNoPaths = errors.New("snapshot must have at least one path")
|
|
errChunkSizeTooSmall = errors.New("chunk_size must be at least 1MB")
|
|
errBlobSizeTooSmall = errors.New(
|
|
"blob_size_limit must be at least the largest chunk the chunker can " +
|
|
"emit (chunk_size times the FastCDC size spread)")
|
|
errBadCompression = errors.New("compression_level must be between 1 and 19")
|
|
errBadS3PartSize = errors.New("s3.part_size must be between 5MiB and 5GiB")
|
|
errBadStorageScheme = errors.New(
|
|
"storage_url must start with s3://, file://, or rclone://")
|
|
errFileURLNotAbsolute = errors.New(
|
|
"a file:// storage_url needs an absolute path, " +
|
|
"as in file:///path/to/backup")
|
|
errIndexPathNotAbsolute = errors.New(
|
|
"index_path and VAULTIK_INDEX_PATH need an absolute path, " +
|
|
"as in ~/.local/share/vaultik/index.sqlite")
|
|
errNegativePruneGracePeriod = errors.New(
|
|
"prune_grace_period must not be negative")
|
|
errStorageNotConfigured = errors.New(
|
|
"storage not configured; set storage_url or provide s3.endpoint + " +
|
|
"s3.bucket + credentials")
|
|
errS3BucketRequired = errors.New("s3.bucket is required (or set storage_url)")
|
|
)
|
|
|
|
// Validate returns these when S3 storage is configured without its
|
|
// credentials.
|
|
var (
|
|
ErrS3KeyIDRequired = errors.New("s3.access_key_id is required")
|
|
ErrS3SecretRequired = errors.New("s3.secret_access_key is required")
|
|
)
|
|
|
|
// expandTilde expands ~ at the start of a path to the user's home directory.
|
|
func expandTilde(path string) string {
|
|
if path == "~" {
|
|
home, _ := os.UserHomeDir()
|
|
|
|
return home
|
|
}
|
|
|
|
if strings.HasPrefix(path, "~/") {
|
|
home, _ := os.UserHomeDir()
|
|
|
|
return filepath.Join(home, path[2:])
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// expandTildeInURL expands ~ in file:// URLs.
|
|
func expandTildeInURL(url string) string {
|
|
if strings.HasPrefix(url, "file://~/") {
|
|
home, _ := os.UserHomeDir()
|
|
|
|
return "file://" + filepath.Join(home, url[9:])
|
|
}
|
|
|
|
return url
|
|
}
|
|
|
|
// SnapshotConfig represents configuration for a named snapshot.
|
|
// Each snapshot backs up one or more paths and can have its own exclude patterns
|
|
// in addition to the global excludes.
|
|
type SnapshotConfig struct {
|
|
Paths []string `yaml:"paths"`
|
|
Exclude []string `yaml:"exclude"` // Additional excludes for this snapshot
|
|
}
|
|
|
|
// GetExcludes returns the combined exclude patterns for a named snapshot.
|
|
// It merges global excludes with the snapshot-specific excludes.
|
|
func (c *Config) GetExcludes(snapshotName string) []string {
|
|
snap, ok := c.Snapshots[snapshotName]
|
|
if !ok {
|
|
return c.Exclude
|
|
}
|
|
|
|
if len(snap.Exclude) == 0 {
|
|
return c.Exclude
|
|
}
|
|
|
|
// Combine global and snapshot-specific excludes
|
|
combined := make([]string, 0, len(c.Exclude)+len(snap.Exclude))
|
|
combined = append(combined, c.Exclude...)
|
|
combined = append(combined, snap.Exclude...)
|
|
|
|
return combined
|
|
}
|
|
|
|
// SnapshotNames returns the names of all configured snapshots in sorted order.
|
|
func (c *Config) SnapshotNames() []string {
|
|
names := make([]string, 0, len(c.Snapshots))
|
|
for name := range c.Snapshots {
|
|
names = append(names, name)
|
|
}
|
|
// Sort for deterministic order
|
|
sort.Strings(names)
|
|
|
|
return names
|
|
}
|
|
|
|
// Names of the two places the age secret key can be configured, used by
|
|
// AgeSecretKeySourceName for error messages that must not echo the value.
|
|
//
|
|
//nolint:gosec // G101: these are the names of the config sources, not a key
|
|
const (
|
|
ageSecretKeySourceEnv = "VAULTIK_AGE_SECRET_KEY"
|
|
ageSecretKeySourceConfig = "age_secret_key"
|
|
)
|
|
|
|
// AgeSecretKeySourceName returns the human name of where AgeSecretKey was
|
|
// configured. A Config built directly (as in tests) has no recorded
|
|
// source, so it reports the config-file field name.
|
|
func (c *Config) AgeSecretKeySourceName() string {
|
|
if c.AgeSecretKeySource != "" {
|
|
return c.AgeSecretKeySource
|
|
}
|
|
|
|
return ageSecretKeySourceConfig
|
|
}
|
|
|
|
// Config represents the application configuration for Vaultik.
|
|
// It defines all settings for backup operations, including source directories,
|
|
// encryption recipients, storage configuration, and performance tuning parameters.
|
|
// Configuration is typically loaded from a YAML file.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established config-file format
|
|
type Config struct {
|
|
AgeRecipients []string `yaml:"age_recipients"`
|
|
AgeSecretKey string `yaml:"age_secret_key"`
|
|
// AgeSecretKeySource names where AgeSecretKey was configured
|
|
// ("VAULTIK_AGE_SECRET_KEY" or "age_secret_key") so a later parse
|
|
// failure can name the source without echoing the secret value. It is
|
|
// set by Load and never read from or written to the config file.
|
|
AgeSecretKeySource string `yaml:"-"`
|
|
BlobSizeLimit Size `yaml:"blob_size_limit"`
|
|
ChunkSize Size `yaml:"chunk_size"`
|
|
// Exclude holds global excludes applied to all snapshots.
|
|
Exclude []string `yaml:"exclude"`
|
|
Hostname string `yaml:"hostname"`
|
|
IndexPath string `yaml:"index_path"`
|
|
S3 S3Config `yaml:"s3"`
|
|
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
|
|
CompressionLevel int `yaml:"compression_level"`
|
|
|
|
// PruneGracePeriod is how old a blob no manifest lists must be before
|
|
// a prune deletes it, and how old every object in a directory under
|
|
// metadata/ with no manifest must be before a prune deletes that
|
|
// directory. A backup writes its manifest only once it has uploaded
|
|
// all its blobs and its db.zst.age, so a younger blob or directory may
|
|
// belong to a backup still running, on this host or another sharing
|
|
// the destination store.
|
|
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
|
|
|
|
// StorageURL specifies the storage backend using a URL format.
|
|
// Takes precedence over S3Config if set.
|
|
// Supported formats:
|
|
// - s3://bucket/prefix?endpoint=host®ion=us-east-1
|
|
// - file:///path/to/backup
|
|
// For S3 URLs, credentials are still read from s3.access_key_id
|
|
// and s3.secret_access_key.
|
|
StorageURL string `yaml:"storage_url"`
|
|
}
|
|
|
|
// S3Config represents S3 storage configuration for backup storage.
|
|
// It supports both AWS S3 and S3-compatible storage services.
|
|
// All fields except UseSSL and PartSize are required.
|
|
//
|
|
//nolint:tagliatelle // snake_case is the established config-file format
|
|
type S3Config struct {
|
|
Endpoint string `yaml:"endpoint"`
|
|
Bucket string `yaml:"bucket"`
|
|
Prefix string `yaml:"prefix"`
|
|
AccessKeyID string `yaml:"access_key_id"`
|
|
SecretAccessKey string `yaml:"secret_access_key"`
|
|
Region string `yaml:"region"`
|
|
// UseSSL selects HTTPS for a scheme-less endpoint. Omitted (nil) means
|
|
// the default, TLS; set it to false only to force plain HTTP.
|
|
UseSSL *bool `yaml:"use_ssl"`
|
|
PartSize Size `yaml:"part_size"`
|
|
}
|
|
|
|
// Path wraps the config file path for fx dependency injection.
|
|
// This type allows the config file path to be injected as a distinct type
|
|
// rather than a plain string, avoiding conflicts with other string dependencies.
|
|
type Path string
|
|
|
|
// New creates a new Config instance by loading from the specified path.
|
|
// This function is used by the fx dependency injection framework.
|
|
// Returns an error if the path is empty or if loading fails.
|
|
func New(path Path) (*Config, error) {
|
|
if path == "" {
|
|
return nil, errNoConfigPath
|
|
}
|
|
|
|
cfg, err := Load(string(path))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Load reads the configuration file at path, parses it with Parse, warns
|
|
// when others can read the file, and validates the configuration.
|
|
// Returns an error if the file cannot be read, parsed, or if validation
|
|
// fails.
|
|
func Load(path string) (*Config, error) {
|
|
data, err := os.ReadFile(path) //nolint:gosec // G304: config path is operator-supplied
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
|
}
|
|
|
|
cfg, err := Parse(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check config file permissions (warn if world or group readable)
|
|
//nolint:gosec // G703: config path is operator-supplied by design
|
|
info, statErr := os.Stat(path)
|
|
if statErr == nil {
|
|
mode := info.Mode().Perm()
|
|
if mode&0044 != 0 { // group or world readable
|
|
log.Warn(cfg.readableByOthersWarning(),
|
|
"path", path,
|
|
"mode", fmt.Sprintf("%04o", mode),
|
|
"recommendation", "chmod 600 "+path)
|
|
}
|
|
}
|
|
|
|
err = cfg.Validate()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid config: %w", err)
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Parse reads YAML config data as Load does, without validating it. It
|
|
// refuses a key that no setting has and a size setting whose value is not a
|
|
// size, naming the setting. It applies default values for optional
|
|
// fields, fills in ${...} references using smartconfig, and expands ~ in
|
|
// paths.
|
|
func Parse(data []byte) (*Config, error) {
|
|
// smartconfig drops line numbers, so keys are checked in the data as
|
|
// written.
|
|
var root yaml.Node
|
|
|
|
err := yaml.Unmarshal(data, &root)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
err = checkKeys(&root, reflect.TypeFor[Config](), "",
|
|
map[*yaml.Node]bool{}, map[nodeAndType]bool{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Load config using smartconfig for interpolation
|
|
sc, err := smartconfig.NewFromReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load config file: %w", err)
|
|
}
|
|
|
|
// A ${...} reference can bring in keys that are not in the file, and
|
|
// is the value of a size setting only once it is filled in.
|
|
err = checkFilledConfig(sc.Data())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cfg := &Config{
|
|
// Set defaults
|
|
BlobSizeLimit: defaultBlobSizeLimit,
|
|
ChunkSize: defaultChunkSize,
|
|
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
|
CompressionLevel: defaultCompressionLevel,
|
|
PruneGracePeriod: defaultPruneGracePeriod,
|
|
S3: S3Config{PartSize: defaultS3PartSize},
|
|
}
|
|
|
|
// Convert smartconfig data to YAML then unmarshal
|
|
configData := sc.Data()
|
|
|
|
yamlBytes, err := yaml.Marshal(configData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal config data: %w", err)
|
|
}
|
|
|
|
err = yaml.Unmarshal(yamlBytes, cfg)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse config: %w", err)
|
|
}
|
|
|
|
// Expand tilde in all path fields
|
|
cfg.IndexPath = expandTilde(cfg.IndexPath)
|
|
cfg.StorageURL = expandTildeInURL(cfg.StorageURL)
|
|
|
|
// Expand tildes in snapshot paths
|
|
for name, snap := range cfg.Snapshots {
|
|
for i, path := range snap.Paths {
|
|
snap.Paths[i] = expandTilde(path)
|
|
}
|
|
|
|
cfg.Snapshots[name] = snap
|
|
}
|
|
|
|
// Check for environment variable override for IndexPath
|
|
if envIndexPath := os.Getenv("VAULTIK_INDEX_PATH"); envIndexPath != "" {
|
|
cfg.IndexPath = expandTilde(envIndexPath)
|
|
}
|
|
|
|
cfg.setAgeSecretKey()
|
|
|
|
// Get hostname if not set
|
|
if cfg.Hostname == "" {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get hostname: %w", err)
|
|
}
|
|
|
|
cfg.Hostname = hostname
|
|
}
|
|
|
|
// Set default S3 settings
|
|
if cfg.S3.Region == "" {
|
|
cfg.S3.Region = "us-east-1"
|
|
}
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
// Validate checks if the configuration is valid and complete.
|
|
// It ensures all required fields are present and have valid values:
|
|
// - Every age recipient must parse as an X25519 age1... public key (so a
|
|
// bad entry fails at load, not mid-backup); errors name the position,
|
|
// never the value. An empty list is accepted, because only snapshot
|
|
// create needs a recipient and it checks for one itself
|
|
// - At least one snapshot must be configured with at least one path
|
|
// - Storage must be configured (either storage_url or s3.* fields)
|
|
// - The index path must be absolute: a relative one would resolve against
|
|
// the working directory, so each directory would get its own index
|
|
// - Chunk size must be at least 1MB
|
|
// - Blob size limit must be at least the largest chunk the chunker can emit
|
|
// (chunk_size times chunker.ChunkSizeSpread), so a single-chunk blob never
|
|
// exceeds the configured limit
|
|
// - Compression level must be between 1 and 19
|
|
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
|
|
// - Prune grace period must not be negative
|
|
//
|
|
// Returns an error describing the first validation failure encountered.
|
|
func (c *Config) Validate() error {
|
|
for i, recipient := range c.AgeRecipients {
|
|
err := validateAgeRecipient(recipient)
|
|
if err != nil {
|
|
return fmt.Errorf("age_recipients[%d]: %w", i, err)
|
|
}
|
|
}
|
|
|
|
if len(c.Snapshots) == 0 {
|
|
return errNoSnapshots
|
|
}
|
|
|
|
for name, snap := range c.Snapshots {
|
|
if len(snap.Paths) == 0 {
|
|
return fmt.Errorf("%w: %q", errSnapshotNoPaths, name)
|
|
}
|
|
}
|
|
|
|
// Validate storage configuration
|
|
err := c.validateStorage()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !filepath.IsAbs(c.IndexPath) {
|
|
return fmt.Errorf("%w: %q", errIndexPathNotAbsolute, c.IndexPath)
|
|
}
|
|
|
|
if c.ChunkSize.Int64() < minChunkSize {
|
|
return errChunkSizeTooSmall
|
|
}
|
|
|
|
// The chunker can emit chunks up to chunk_size * ChunkSizeSpread, and the
|
|
// packer places a single such chunk into an otherwise empty blob. A limit
|
|
// below that bound would let a blob exceed it, so reject it.
|
|
largestChunk := c.ChunkSize.Int64() * chunker.ChunkSizeSpread
|
|
if c.BlobSizeLimit.Int64() < largestChunk {
|
|
return fmt.Errorf("%w: need at least %d bytes",
|
|
errBlobSizeTooSmall, largestChunk)
|
|
}
|
|
|
|
if c.CompressionLevel < minCompressionLevel ||
|
|
c.CompressionLevel > maxCompressionLevel {
|
|
return errBadCompression
|
|
}
|
|
|
|
if c.S3.PartSize.Int64() < minS3PartSize ||
|
|
c.S3.PartSize.Int64() > maxS3PartSize {
|
|
return errBadS3PartSize
|
|
}
|
|
|
|
if c.PruneGracePeriod < 0 {
|
|
return errNegativePruneGracePeriod
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateAgeRecipient parses one age_recipients entry with the age library
|
|
// and returns a value-free error on failure. A recipient string can be
|
|
// sensitive (an operator may paste a secret key by mistake), so neither the
|
|
// entry nor age's own error (which quotes its input) is ever included.
|
|
func validateAgeRecipient(recipient string) error {
|
|
if strings.HasPrefix(strings.ToUpper(recipient), secretKeyPrefix) {
|
|
return errRecipientIsSecretKey
|
|
}
|
|
|
|
_, err := age.ParseX25519Recipient(recipient)
|
|
if err != nil {
|
|
return errRecipientNotX25519
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// setAgeSecretKey records the age secret key and where it came from. The
|
|
// value is stored raw and parsed only where decryption happens
|
|
// (internal/vaultik), so backup, list and prune keep working whatever the
|
|
// field holds. The environment variable overrides the config-file field.
|
|
func (c *Config) setAgeSecretKey() {
|
|
if c.AgeSecretKey != "" {
|
|
c.AgeSecretKeySource = ageSecretKeySourceConfig
|
|
}
|
|
|
|
if env := os.Getenv("VAULTIK_AGE_SECRET_KEY"); env != "" {
|
|
c.AgeSecretKey = env
|
|
c.AgeSecretKeySource = ageSecretKeySourceEnv
|
|
}
|
|
}
|
|
|
|
// readableByOthersWarning is the warning Load logs when others can read
|
|
// the config file. It says "may contain" because the S3 credentials are
|
|
// seen only after smartconfig has replaced any ${...} reference in the
|
|
// file with its value, so a set credential need not be in the file.
|
|
func (c *Config) readableByOthersWarning() string {
|
|
if c.S3.AccessKeyID != "" || c.S3.SecretAccessKey != "" {
|
|
return "Config file is readable by others and may contain S3 credentials"
|
|
}
|
|
|
|
return "Config file is readable by others"
|
|
}
|
|
|
|
// validateStorage validates storage configuration.
|
|
// If StorageURL is set, it takes precedence. S3 URLs require credentials.
|
|
// File URLs don't require any S3 configuration.
|
|
// If StorageURL is not set, legacy S3 configuration is required.
|
|
func (c *Config) validateStorage() error {
|
|
if c.StorageURL != "" {
|
|
return c.validateStorageURL()
|
|
}
|
|
|
|
// Legacy S3 configuration
|
|
if c.S3.Endpoint == "" {
|
|
return errStorageNotConfigured
|
|
}
|
|
|
|
if c.S3.Bucket == "" {
|
|
return errS3BucketRequired
|
|
}
|
|
|
|
if c.S3.AccessKeyID == "" {
|
|
return ErrS3KeyIDRequired
|
|
}
|
|
|
|
if c.S3.SecretAccessKey == "" {
|
|
return ErrS3SecretRequired
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateStorageURL validates URL-based storage configuration. File and
|
|
// rclone URLs need no credentials; S3 URLs require the legacy s3.*
|
|
// credential fields. A file URL must name an absolute path: a relative one
|
|
// would resolve against the working directory, so the destination would
|
|
// move with it.
|
|
func (c *Config) validateStorageURL() error {
|
|
switch {
|
|
case strings.HasPrefix(c.StorageURL, "file://"):
|
|
// storage.ParseStorageURL applies the same rule, but config
|
|
// cannot import storage, which imports config.
|
|
if !strings.HasPrefix(c.StorageURL, "file:///") {
|
|
return errFileURLNotAbsolute
|
|
}
|
|
|
|
return nil
|
|
case strings.HasPrefix(c.StorageURL, "rclone://"):
|
|
// Rclone storage uses rclone's own config
|
|
return nil
|
|
case strings.HasPrefix(c.StorageURL, "s3://"):
|
|
// S3 storage needs credentials
|
|
if c.S3.AccessKeyID == "" {
|
|
return fmt.Errorf("%w for s3:// URLs", ErrS3KeyIDRequired)
|
|
}
|
|
|
|
if c.S3.SecretAccessKey == "" {
|
|
return fmt.Errorf("%w for s3:// URLs", ErrS3SecretRequired)
|
|
}
|
|
|
|
return nil
|
|
default:
|
|
return errBadStorageScheme
|
|
}
|
|
}
|
|
|
|
// Module exports the config module for fx dependency injection.
|
|
// It provides the Config type to other modules in the application.
|
|
//
|
|
//nolint:gochecknoglobals // fx module definitions are package globals
|
|
var Module = fx.Module("config",
|
|
fx.Provide(New),
|
|
)
|