internal/textconfig/textconfig.go (161 lines of code) (raw):

// Copyright 2024 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Package textconfig provides a way to read and write plain text configuration // files (such as sshd.conf, pam.conf, etc). package textconfig import ( "fmt" "os" "strings" ) // Position is the position of a block in the file. type Position int const ( // DefaultSpacer is the default spacer used to separate key & value when // writing the file. DefaultSpacer = " " // Top is the position identifier for the top of the file. Top Position = iota // Bottom is the position identifier for the bottom of the file. Bottom ) // Entry is the configuration entry, it indicates the key and value of the // entry. type Entry struct { // key is the key of the entry. key string // value is the value of the entry. value string } // NewEntry creates a new entry. func NewEntry(key, value string) *Entry { return &Entry{key: key, value: value} } // format returns the formatted entry string (applying the provided spacer // between the key and value). func (en *Entry) format(spacer string) string { return strings.TrimSpace(fmt.Sprintf("%s%s%s", en.key, spacer, en.value)) } // Block is a group of entries that are in the same position in the file. type Block struct { // entries is the list of entries in the block. entries []*Entry // position is the position of the block in the file (top or bottom). position Position } // NewBlock creates a new block for the given position. func NewBlock(pos Position) *Block { return &Block{position: pos} } // Append appends an entry to the block. func (bl *Block) Append(key, value string) { bl.entries = append(bl.entries, &Entry{key, value}) } // lines returns the formatted lines of the block. func (bl *Block) lines(delimiter *Delimiter, spacer string) []string { var lines []string // Block without entries, no need to write anything - avoid writing empty // blocks (with delimiters only). if len(bl.entries) == 0 { return nil } lines = append(lines, delimiter.Start) for _, entry := range bl.entries { lines = append(lines, entry.format(spacer)) } lines = append(lines, delimiter.End) return lines } // Delimiter is the delimiter for a block. It indicates the block start matching // pattern and the block end matching pattern. type Delimiter struct { // Start is the start matching pattern. Start string // End is the end matching pattern. End string } // Handle is represents the configuration file. type Handle struct { // file is the path to the configuration file. file string // mode is the mode of the file. mode os.FileMode // blocks is the list of blocks in the file. blocks []*Block // opts is the options for the configuration file. opts Options } // Options is the options for the configuration file. type Options struct { // Delimiters is the default delimiter for the file, it is used when writing // the file - it's also considered for reading and identifying blocks of // interest along with the known delimiters (ever supported delimiters). Delimiters *Delimiter // KnownDelimiters is the list of known delimiters for the file i.e. previously // used delimiters and still supported for backwards compatibility. KnownDelimiters []*Delimiter // AllDelimiters is the list of all delimiters for the file, it's ordered as: // - Delimiters // - KnownDelimiters AllDelimiters []*Delimiter // Spacer is the spacer used to separate key & value when writing the file. Spacer string // deprecatedEntries is the list of deprecated entries that should be removed // from the file. DeprecatedEntries []*Entry } // New creates a new configuration file handle. func New(file string, mode os.FileMode, opts Options) *Handle { var allDelimiters []*Delimiter allDelimiters = append(allDelimiters, opts.Delimiters) allDelimiters = append(allDelimiters, opts.KnownDelimiters...) for _, dd := range allDelimiters { if dd == nil { continue } opts.AllDelimiters = append(opts.AllDelimiters, dd) } if opts.Spacer == "" { opts.Spacer = DefaultSpacer } return &Handle{file: file, mode: mode, opts: opts} } // AddBlock adds a block to the configuration handle. func (h *Handle) AddBlock(block *Block) { h.blocks = append(h.blocks, block) } // Cleanup removes the blocks and deprecated entries managed by the Handle from // the file. func (h *Handle) Cleanup() error { data, err := os.ReadFile(h.file) if err != nil { return fmt.Errorf("failed to read file %q: %v", h.file, err) } lines := strings.Split(string(data), "\n") output := h.cleanup(lines) if err := os.WriteFile(h.file, []byte(strings.Join(output, "\n")), h.mode); err != nil { return fmt.Errorf("failed to write file %q: %v", h.file, err) } return nil } // cleanup removes our managed blocks. func (h *Handle) cleanup(lines []string) []string { var output []string inBlock := false for _, line := range lines { isMatch := h.matchLine(line) // Leave out the delimiters. if isMatch { inBlock = !inBlock continue } // Leave out any lines inside a block. if inBlock { continue } // Leave out any deprecated entries. foundDeprecatedEntry := false for _, entry := range h.opts.DeprecatedEntries { if strings.HasPrefix(strings.TrimSpace(line), entry.key) && strings.HasSuffix(strings.TrimSpace(line), entry.value) { foundDeprecatedEntry = true break } } if foundDeprecatedEntry { continue } output = append(output, line) } return output } // matchLine returns true if the line matches any of the delimiters. func (h *Handle) matchLine(line string) bool { for _, dd := range h.opts.AllDelimiters { if strings.TrimSpace(line) == dd.Start { return true } if strings.TrimSpace(line) == dd.End { return true } } return false } // Apply applies the changes to the file. func (h *Handle) Apply() error { if err := h.Cleanup(); err != nil { return fmt.Errorf("failed to cleanup file %q: %v", h.file, err) } var topBlocks []*Block var bottomBlocks []*Block for _, block := range h.blocks { switch block.position { case Top: topBlocks = append(topBlocks, block) case Bottom: bottomBlocks = append(bottomBlocks, block) } } data, err := os.ReadFile(h.file) if err != nil { return fmt.Errorf("failed to read file %q: %v", h.file, err) } lines := strings.Split(string(data), "\n") cleanedup := h.cleanup(lines) var output []string for _, block := range topBlocks { output = append(output, block.lines(h.opts.Delimiters, h.opts.Spacer)...) } output = append(output, cleanedup...) for _, block := range bottomBlocks { output = append(output, block.lines(h.opts.Delimiters, h.opts.Spacer)...) } if err := os.WriteFile(h.file, []byte(strings.Join(output, "\n")), h.mode); err != nil { return fmt.Errorf("failed to write file %q: %v", h.file, err) } return nil }