// SPDX-License-Identifier: GPL-3.0-or-later package model import ( "bytes" "context" "crypto/sha256" "errors" "fmt" "os" "strings" "git.labunix.xyz/krino/internal/cond" "git.labunix.xyz/krino/internal/config" "git.labunix.xyz/krino/internal/engine" "git.labunix.xyz/krino/internal/xdg" ) // ErrChangedOnDisk is Save's refusal when the file was edited elsewhere // since the editor read it: saving would throw that edit away, so the // caller is asked what to do (GUI design §5.3). var ErrChangedOnDisk = errors.New("the file changed on disk since it was opened") // Rules is one directory's own configuration file, open for editing. Only // dirs/NAME.conf is edited; krino.conf is not, in version 1 (GUI design §5). type Rules struct { Name string // the directory File string // its file, absolute Text string // what the editor holds, saved or not Diags []*config.Diag e *engine.Engine saved string // the text as last read from or written to disk stamp [32]byte // what was on disk then, to catch another editor } // RuleFiles is every included directory that has a file of its own, in the // order krino.conf includes them. func RuleFiles(e *engine.Engine) []string { var out []string for _, d := range e.Dirs { if _, err := os.Stat(config.DirFile(e.MainFile, d.Name)); err == nil { out = append(out, d.Name) } } return out } // OpenRules reads the file of the included directory called name. func OpenRules(e *engine.Engine, name string) (*Rules, error) { found := false for _, d := range e.Dirs { if d.Name == name { found = true break } } if !found { return nil, fmt.Errorf("model: %s is not an included directory", name) } file := config.DirFile(e.MainFile, name) text, err := os.ReadFile(file) if err != nil { return nil, err } r := &Rules{Name: name, File: file, Text: string(text), e: e, saved: string(text), stamp: sha256.Sum256(text)} return r, nil } // SetText replaces what the editor holds. Nothing is written until Save. func (r *Rules) SetText(s string) { r.Text = s } // Modified reports whether the text differs from what is on disk. func (r *Rules) Modified() bool { return r.Text != r.saved } // Revert throws the unsaved text away. func (r *Rules) Revert() { r.Text = r.saved } // Reload re-reads the file, dropping unsaved text - what to do when // something else has edited it. func (r *Rules) Reload() error { text, err := os.ReadFile(r.File) if err != nil { return err } r.Text, r.saved, r.stamp = string(text), string(text), sha256.Sum256(text) return nil } // Check loads the whole configuration with this file's unsaved text in // place of what is on disk, and reports every problem it finds - in this // file or in another, since one file's text can break another's include. // Nothing is written. func (r *Rules) Check() []*config.Diag { _, diags := r.load() r.Diags = diags return diags } // ErrorsHere is the subset of the last Check's diagnostics that belong to // this file, for marking lines in the editor. func (r *Rules) ErrorsHere() []*config.Diag { var out []*config.Diag for _, d := range r.Diags { if d.File == r.File { out = append(out, d) } } return out } // load builds an engine from the unsaved text. func (r *Rules) load() (*engine.Engine, []*config.Diag) { over := map[string][]byte{r.File: []byte(r.Text)} e, diags := engine.LoadWith(r.e.MainFile, over) if e != nil { e.CacheDir = r.e.CacheDir } return e, diags } // Explain answers what the unsaved rules would do to one file - "Test on // file" - as text. It can take seconds: content is extracted, so callers // run it off the main loop. Nothing is written, and the file is not // touched (GUI design §5.3). func (r *Rules) Explain(ctx context.Context, path string) (string, error) { e, diags := r.load() if len(diags) > 0 { return "", fmt.Errorf("%s", diags[0]) } x, err := e.ExplainWithChain(ctx, xdg.Expand(path)) if err != nil { return "", err } return explainText(x), nil } // Save writes the text, keeping what was there as NAME.conf.bak. It // refuses a file that will not load - a window must not leave krino unable // to run - and refuses to overwrite an edit made elsewhere since the file // was opened (GUI design §5.3). func (r *Rules) Save() error { if diags := r.Check(); len(diags) > 0 { return fmt.Errorf("%s", diags[0]) } on, err := os.ReadFile(r.File) if err != nil { return err } if sha256.Sum256(on) != r.stamp { return ErrChangedOnDisk } // The backup is the text being replaced, under the file's own // permissions: a rules file can hold real tax numbers, and a backup // readable by everyone would leak them. mode := os.FileMode(0o600) if fi, err := os.Stat(r.File); err == nil { mode = fi.Mode().Perm() } if err := os.WriteFile(r.File+".bak", on, mode); err != nil { return err } text := []byte(r.Text) if err := config.Replace(r.File, text); err != nil { return err } r.saved, r.stamp = r.Text, sha256.Sum256(text) return nil } // SaveOverwriting saves over an edit made elsewhere - the Overwrite the // caller offers after ErrChangedOnDisk. The other edit is not lost: it // becomes NAME.conf.bak, as any replaced text does. func (r *Rules) SaveOverwriting() error { on, err := os.ReadFile(r.File) if err != nil { return err } r.stamp = sha256.Sum256(on) return r.Save() } // explainText renders an explanation for the Test on file pane: every // exclude and rule with each test's answer, the captures a matching rule // took, and the chain the file alone would get. It is the GUI's own // rendering - krino explain(1) prints the same traces but no captures or // chain. func explainText(x *engine.Explanation) string { var b strings.Builder fmt.Fprintf(&b, "%s (directory %s)\n", xdg.Abbrev(x.File.Path), x.Dir.Name) if x.Skip != "" { fmt.Fprintf(&b, "krino would not look at this file: %s\n", x.Skip) } if x.Excluded != "" { fmt.Fprintf(&b, "krino would set this file aside: %s\n", x.Excluded) } b.WriteString("\n") for _, xt := range x.Excludes { status := "no" if xt.Match { status = "MATCH" } fmt.Fprintf(&b, "%s: %s\n", xt.Text, status) writeTrace(&b, xt.Trace) } for _, rt := range x.Rules { if rt.Stopped != "" { fmt.Fprintf(&b, "rule %s: not evaluated, %s\n", rt.Rule.Name, rt.Stopped) continue } status := "no" switch { case rt.Match: status = "MATCH" case rt.Trace != nil && rt.Trace.Unknown: status = "undecided" } fmt.Fprintf(&b, "rule %s: %s\n", rt.Rule.Name, status) writeTrace(&b, rt.Trace) writeCaptures(&b, rt.Captures) } if x.NoDelete != "" { fmt.Fprintf(&b, "\nkrino would skip deleting this file: %s\n", x.NoDelete) } if len(x.Chain) > 0 { b.WriteString("\nthis file alone would get:\n") for _, s := range x.Chain { switch { case s.Skip != "": fmt.Fprintf(&b, " %s skipped: %s\n", s.Kind, s.Skip) case s.Dst == "": fmt.Fprintf(&b, " %s\n", s.Kind) default: fmt.Fprintf(&b, " %s -> %s\n", s.Kind, xdg.Abbrev(s.Dst)) } } } return b.String() } // writeTrace writes a condition's tree, indented two spaces. func writeTrace(b *strings.Builder, t *cond.Trace) { if t == nil { return } var buf bytes.Buffer t.Format(&buf) for _, line := range strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") { b.WriteString(" " + line + "\n") } } // writeCaptures lists what a matching rule's patterns captured, numbered as // its actions' {1}, {2} ... see them. func writeCaptures(b *strings.Builder, captures []string) { for i, c := range captures { fmt.Fprintf(b, " {%d} = %s\n", i+1, c) } }