package systemdconfig import ( "fmt" "regexp" "strings" "slices" ) var defaultSectionHeader = "DEFAULT" type SystemdConfig struct { sections map[string]*Section // sections of the config file, mapping section headers sections orderedSectionHeaders []string // section headers, ordered as they appear in the file } // Creates new SystemdConfig from byte data func LoadFromBytes(data []byte) (*SystemdConfig, error) { lines := getLinesFromData(data) sectionsLines, orderedSectionHeaders := splitLinesBySection(lines) sections := map[string]*Section{} for sectionHeader, sectionLines := range sectionsLines { section, err := getSectionFromSectionLines(sectionLines) if err != nil { return nil, err } sections[sectionHeader] = section } return &SystemdConfig{sections, orderedSectionHeaders}, nil } // Splits bytes on newlines into a slice of (string) lines func getLinesFromData(bytes []byte) []string { return strings.Split(string(bytes), "\n") } // Splits lines into sections, mapping section header to string lines func splitLinesBySection(lines []string) (map[string][]string, []string) { currentSection := defaultSectionHeader sections := map[string][]string{} orderedSectionHeaders := []string{currentSection} for _, line := range lines { if sectionHeaderIsValid(line) { currentSection = line orderedSectionHeaders = append(orderedSectionHeaders, currentSection) } else { sections[currentSection] = append(sections[currentSection], line) } } // removing trailing whitespace allows for uniform spacing between sections sections = removeTrailingEmptyLinesFromSectionsLines(sections) return sections, orderedSectionHeaders } // Given a line, return if it is a section header, i.e. '[Section]' func sectionHeaderIsValid(sectionHeader string) bool { return regexp.MustCompile(`^\[(\w+\s)*\w+\]$`).MatchString(sectionHeader) // exactly match `[word(s)]` with single spaces between } // Removes trailing empty lines from section lines func removeTrailingEmptyLinesFromSectionsLines(sections map[string][]string) map[string][]string { for sectionHeader, section := range sections { for idx := len(section) - 1; idx >= 0; idx-- { if section[idx] == "" { section = section[:idx] } else { break } } sections[sectionHeader] = section } return sections } // Given a section's string lines, create a Section object func getSectionFromSectionLines(sectionLines []string) (*Section, error) { fields := map[string]*Field{} orderedFieldKeys := []string{} for idx, line := range sectionLines { field, err := getFieldFromLine(line) if err != nil { return nil, err } fieldKey := generateKeyForField(field.GetKey(), idx) fields[fieldKey] = field orderedFieldKeys = append(orderedFieldKeys, fieldKey) } return &Section{fields, orderedFieldKeys}, nil } // Given a line, create Field object func getFieldFromLine(line string) (*Field, error) { // check if line is empty trimmedLine := strings.TrimSpace(line) if trimmedLine == "" { return &Field{}, nil } // split on =, for 'key=value...' key, remainder, found := strings.Cut(line, "=") // if no key:value, there should be a comment if !found { if !isCommented(trimmedLine) { return nil, &SyntaxError{line, errSystemdConfigSyntax} } return &Field{comment: line}, nil } if !keyIsValid(key) { return nil, &SyntaxError{line, errKeySyntax} } value, comment, err := splitValueFromComment(remainder) if err != nil { return nil, err } key, value, comment = strings.TrimSpace(key), strings.TrimSpace(value), strings.TrimSpace(comment) return &Field{key, value, comment}, nil } // Split value from comment in string func splitValueFromComment(line string) (string, string, error) { // split into 'value #comment' value, comment, found := strings.Cut(line, "#") if found { comment = "#" + comment // add '#' to start of comment } // comments can also start with ';', so check if this occurs before '#' if !found || strings.Contains(value, ";") { value, comment, found = strings.Cut(line, ";") if found { comment = ";" + comment // add ';' to start of comment } } // multi-line values are not supported if strings.HasSuffix(line, "\\") { return "", "", &UnsupportedError{value, errMultiLineValuesUnsupported} } return value, comment, nil } // Returns if the field key commented-out key is valid func keyIsValid(key string) bool { key = strings.TrimSpace(key) if key == "" { return false } if isCommented(key) { if len(key) == 1 { return false } key = strings.TrimSpace(key[1:]) } return regexp.MustCompile(`^[A-Za-z0-9-]+$`).MatchString(key) } func isCommented(line string) bool { line = strings.TrimSpace(line) return strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") } // Returns if the field value is valid func valueIsValid(value string) bool { value = strings.TrimSpace(value) if value == "" { return true } return !strings.ContainsAny(value, "#;") } // Generates a unique key for a field in a section. If there is no key value, one is created from line idx. func generateKeyForField(key string, lineIndex int) string { if key == "" { // give blank or commented lines unique key for section with invalid char to avoid collisions return fmt.Sprintf("!%d", lineIndex) } if isCommented(key) { // remove comment symbol from key key = key[1:] } return key } // Creates a (deep) copy of the SystemConfig object func (systemdConfig *SystemdConfig) DeepCopy() *SystemdConfig { copiedSections := map[string]*Section{} for sectionHeader, section := range systemdConfig.sections { copiedFields := map[string]*Field{} for key, field := range section.fields { copiedField := Field{field.GetKey(), field.GetValue(), field.GetComment()} copiedFields[key] = &copiedField } copiedOrderedKeys := slices.Clone(section.orderedFieldKeys) section := Section{copiedFields, copiedOrderedKeys} copiedSections[sectionHeader] = §ion } copiedOrderedSectionHeaders := slices.Clone(systemdConfig.orderedSectionHeaders) copiedSystemdConfig := SystemdConfig{copiedSections, copiedOrderedSectionHeaders} return &copiedSystemdConfig } // Gets the section for a given section header func (systemdConfig *SystemdConfig) Section(targetSectionHeader string) *Section { return systemdConfig.sections[targetSectionHeader] } // Lists the section headers in the systemd config func (systemdConfig *SystemdConfig) ListSections() []string { sectionHeaders := []string{} for _, sectionHeader := range systemdConfig.orderedSectionHeaders { if sectionHeader != defaultSectionHeader { sectionHeaders = append(sectionHeaders, sectionHeader) } } return sectionHeaders } // Adds a given section to the systemd config, if it didn't already exist func (systemdConfig *SystemdConfig) AddSection(targetSectionHeader string) error { if !sectionHeaderIsValid(targetSectionHeader) { return &SyntaxError{targetSectionHeader, errSectionHeaderSyntax} } if _, ok := systemdConfig.sections[targetSectionHeader]; !ok { systemdConfig.sections[targetSectionHeader] = &Section{map[string]*Field{}, []string{}} systemdConfig.orderedSectionHeaders = append(systemdConfig.orderedSectionHeaders, targetSectionHeader) } return nil } // Removes a given section for the systemd config, if it exists func (systemdConfig *SystemdConfig) RemoveSection(targetSectionHeader string) error { if !sectionHeaderIsValid(targetSectionHeader) { return &SyntaxError{targetSectionHeader, errSectionHeaderSyntax} } if _, ok := systemdConfig.sections[targetSectionHeader]; ok { delete(systemdConfig.sections, targetSectionHeader) updatedSectionHeaders := []string{} for _, sectionHeader := range systemdConfig.orderedSectionHeaders { if sectionHeader != targetSectionHeader { updatedSectionHeaders = append(updatedSectionHeaders, sectionHeader) } } systemdConfig.orderedSectionHeaders = updatedSectionHeaders } return nil } // Returns if the systemd config has a given section func (systemdConfig *SystemdConfig) HasSection(targetSectionHeader string) bool { _, ok := systemdConfig.sections[targetSectionHeader] return ok } // Returns systemd config data as bytes func (systemdConfig *SystemdConfig) Bytes() []byte { return joinLinesToBytes(systemdConfig.Lines()) } // Returns lines of systemd config data func (systemdConfig *SystemdConfig) Lines() []string { lines := []string{} for _, sectionHeader := range systemdConfig.orderedSectionHeaders { if sectionHeader != defaultSectionHeader { lines = append(lines, "", sectionHeader) // add whitespace between sections } for _, key := range systemdConfig.Section(sectionHeader).orderedFieldKeys { lines = append(lines, systemdConfig.Section(sectionHeader).Field(key).ToFileLine()) } } return lines } // Joins (string) lines into bytes with newlines func joinLinesToBytes(lines []string) []byte { return []byte(strings.Join(lines, "\n")) }