// SPDX-License-Identifier: GPL-3.0-or-later package config import ( "bytes" _ "embed" "errors" "fmt" "io/fs" "os" "path/filepath" "unicode" "unicode/utf8" "krino/internal/sexp" "krino/internal/xdg" ) //go:embed skel/krino.conf var skelMain []byte //go:embed skel/template.conf var skelTemplate []byte // Reserved are subcommand names; a directory with one of them could not be // run by name. var Reserved = map[string]bool{"init": true, "new": true, "check": true, "explain": true, "log": true, "undo": true} // Init creates the directory holding mainFile with a commented krino.conf and // template.conf, and a dirs/ directory. It never overwrites krino.conf, and // keeps an existing template.conf. It returns the files it created. func Init(mainFile string) ([]string, error) { dir := filepath.Dir(mainFile) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, err } if err := writeNew(mainFile, skelMain); errors.Is(err, fs.ErrExist) { return nil, fmt.Errorf("%s already exists; krino init leaves it alone", mainFile) } else if err != nil { return nil, err } created := []string{mainFile} if err := os.MkdirAll(filepath.Join(dir, "dirs"), 0o755); err != nil { return created, err } tmpl := filepath.Join(dir, "template.conf") switch err := writeNew(tmpl, skelTemplate); { case err == nil: created = append(created, tmpl) case !errors.Is(err, fs.ErrExist): return created, err } return created, nil } // NewDir creates dirs/.conf from template.conf with the path filled // in, and adds name to include in mainFile without changing anything else // there. It returns the new file's path. func NewDir(mainFile, name, path string) (string, error) { if !nameRE.MatchString(name) { return "", fmt.Errorf("bad directory name %q: use letters, digits, '.', '_' and '-'", name) } if Reserved[name] { return "", fmt.Errorf("%q is a krino command; choose another name", name) } abs, err := filepath.Abs(xdg.Expand(path)) if err != nil { return "", err } // The path is written into the new file, and config files are UTF-8 // text (the reader refuses anything else). if !utf8.ValidString(abs) { return "", fmt.Errorf("%q is not valid UTF-8; krino's config is UTF-8 text, so rename the directory first", abs) } // Nor control or bidirectional characters: a directory unpacked from a // download could name itself with escape codes (re-review cli F1). for _, r := range abs { if unicode.IsControl(r) || unicode.Is(unicode.Bidi_Control, r) || unicode.In(r, unicode.Zl, unicode.Zp) { return "", fmt.Errorf("%q holds control or bidirectional characters; rename the directory first", abs) } } if fi, err := os.Stat(abs); err != nil || !fi.IsDir() { return "", fmt.Errorf("%s is not a directory", abs) } src, err := os.ReadFile(mainFile) if errors.Is(err, fs.ErrNotExist) { return "", fmt.Errorf("%s not found; create it with: krino init", mainFile) } else if err != nil { return "", err } m, errs := ParseMain(mainFile, src) if len(errs) > 0 { return "", fmt.Errorf("fix %s first: %v", mainFile, errs[0]) } if _, ok := m.IncludePos[name]; ok { return "", fmt.Errorf("%q is already included", name) } tmplPath := filepath.Join(filepath.Dir(mainFile), "template.conf") tmpl, err := os.ReadFile(tmplPath) if errors.Is(err, fs.ErrNotExist) { tmpl, err = skelTemplate, nil } if err != nil { return "", err } file := DirFile(mainFile, name) body := bytes.ReplaceAll(tmpl, []byte(`"@PATH@"`), []byte(sexp.Quote(xdg.Abbrev(abs)))) newDir, derrs := ParseDir(name, file, body) if len(derrs) > 0 { return "", fmt.Errorf("template.conf is broken: %v", derrs[0]) } if newDir.Path != abs { return "", fmt.Errorf("%s must contain (path \"@PATH@\")", tmplPath) } newMain := addInclude(src, m, name) if _, errs := ParseMain(mainFile, newMain); len(errs) > 0 { return "", fmt.Errorf("could not add %q to include: %v", name, errs[0]) } if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { return "", err } if err := writeNew(file, body); errors.Is(err, fs.ErrExist) { return "", fmt.Errorf("%s already exists", file) } else if err != nil { return "", err } if err := replaceFile(mainFile, newMain); err != nil { os.Remove(file) return "", err } return file, nil } // addInclude inserts name after the last element of the (include ...) form, // or appends an include form when there is none. func addInclude(src []byte, m *Main, name string) []byte { ins := " " + sexp.Quote(name) if m.IncludeNode == nil { out := bytes.Clone(src) if len(out) > 0 && out[len(out)-1] != '\n' { out = append(out, '\n') } return append(out, "(include"+ins+")\n"...) } kids := m.IncludeNode.Children at := kids[len(kids)-1].End.Offset out := make([]byte, 0, len(src)+len(ins)) out = append(out, src[:at]...) out = append(out, ins...) return append(out, src[at:]...) } // writeNew creates path holding data, failing if it already exists. func writeNew(path string, data []byte) error { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return err } if _, err := f.Write(data); err != nil { f.Close() os.Remove(path) return err } return f.Close() } // replaceFile atomically replaces the file at path, keeping its permissions. // A symlink is followed and its target replaced, so dotfile links survive. func replaceFile(path string, data []byte) error { real, err := filepath.EvalSymlinks(path) if err != nil { return err } fi, err := os.Stat(real) if err != nil { return err } tmp, err := os.CreateTemp(filepath.Dir(real), ".krino-*") if err != nil { return err } defer os.Remove(tmp.Name()) if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Chmod(fi.Mode().Perm()); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), real) }