// Frontend: Format changes // SPDX-License-Identifier: GPL-3.0-or-later // Copyright (C) 2021-2024 Simon Ruderich package frontend import ( "fmt" "io/fs" "strings" "ruderich.org/simon/safcm" "ruderich.org/simon/safcm/cmd/safcm/config" ) // NOTE: Be careful when implementing new format* functions. All input from // the remote helper is untrusted and must be either escaped with %q or by // calling EscapeControlCharacters(). type Changes struct { DryRun bool Quiet bool IsTTY bool } func (c *Changes) FormatChanges(resp safcm.MsgSyncResp) string { var changes []string if len(resp.FileChanges) > 0 { changes = append(changes, c.FormatFileChanges(resp.FileChanges)) } if len(resp.PackageChanges) > 0 { changes = append(changes, c.FormatPackageChanges(resp.PackageChanges)) } if len(resp.ServiceChanges) > 0 { changes = append(changes, c.FormatServiceChanges(resp.ServiceChanges)) } if len(resp.CommandChanges) > 0 { changes = append(changes, c.FormatCommandChanges(resp.CommandChanges)) } if len(changes) == 0 { // Notify user that the host was synced successfully return "no changes" } x := strings.Join(changes, "\n") // If quiet is used and only commands without output were executed // then don't prepend a newline so that the whole change output of a // host fits in a single line. This makes the output much more // readable with multiple hosts. if strings.Count(x, "\n") == 1 { return x } return "\n" + x } func (c *Changes) FormatFileChanges(changes []safcm.FileChange) string { var buf strings.Builder if c.DryRun { fmt.Fprintf(&buf, "will change %d file(s): (dry-run)\n", len(changes)) } else { fmt.Fprintf(&buf, "changed %d file(s):\n", len(changes)) } for _, x := range changes { fmt.Fprintf(&buf, "%s:", c.FormatTarget(x.Path)) var info []string if x.Created { info = append(info, ColorString(c.IsTTY, ColorGreen, "created"), FormatFileType(x.New), FormatFileUserGroup(x.New), FormatFilePerm(x.New), ) } else { if x.Old.Mode.Type() != x.New.Mode.Type() { info = append(info, fmt.Sprintf("%s -> %s", FormatFileType(x.Old), FormatFileType(x.New), )) } if x.Old.User != x.New.User || x.Old.Uid != x.New.Uid || x.Old.Group != x.New.Group || x.Old.Gid != x.New.Gid { info = append(info, fmt.Sprintf("%s -> %s", FormatFileUserGroup(x.Old), FormatFileUserGroup(x.New), )) } if config.FileModeToFullPerm(x.Old.Mode) != config.FileModeToFullPerm(x.New.Mode) { info = append(info, fmt.Sprintf("%s -> %s", FormatFilePerm(x.Old), FormatFilePerm(x.New), )) } } if len(info) > 0 { fmt.Fprint(&buf, " ") fmt.Fprint(&buf, strings.Join(info, ", ")) } if x.DataDiff != "" { fmt.Fprintf(&buf, "\n%s", c.FormatDiff(x.DataDiff)) } fmt.Fprintf(&buf, "\n") } return buf.String() } func FormatFileType(info safcm.FileChangeInfo) string { switch info.Mode.Type() { case 0: // regular file return "file" case fs.ModeSymlink: return "symlink" case fs.ModeDir: return "dir" default: return fmt.Sprintf("invalid type %v", info.Mode.Type()) } } func FormatFileUserGroup(info safcm.FileChangeInfo) string { return fmt.Sprintf("%s(%d) %s(%d)", EscapeControlCharacters(false, info.User), info.Uid, EscapeControlCharacters(false, info.Group), info.Gid) } func FormatFilePerm(info safcm.FileChangeInfo) string { return fmt.Sprintf("%#o", config.FileModeToFullPerm(info.Mode)) } func (c *Changes) FormatPackageChanges(changes []safcm.PackageChange) string { var buf strings.Builder if c.DryRun { fmt.Fprintf(&buf, "will install %d package(s): (dry-run)\n", len(changes)) } else { fmt.Fprintf(&buf, "installed %d package(s):\n", len(changes)) } for _, x := range changes { // TODO: indicate if installation failed fmt.Fprintf(&buf, "%s\n", c.FormatTarget(x.Name)) } return buf.String() } func (c *Changes) FormatServiceChanges(changes []safcm.ServiceChange) string { var buf strings.Builder if c.DryRun { fmt.Fprintf(&buf, "will modify %d service(s): (dry-run)\n", len(changes)) } else { fmt.Fprintf(&buf, "modified %d service(s):\n", len(changes)) } for _, x := range changes { var info []string if x.Started { info = append(info, "started") } if x.Enabled { info = append(info, "enabled") } fmt.Fprintf(&buf, "%s: %s\n", c.FormatTarget(x.Name), strings.Join(info, ", ")) } return buf.String() } func (c *Changes) FormatCommandChanges(changes []safcm.CommandChange) string { const indent = " > " // Quiet hides all successful, non-trigger commands which produce no // output. This is useful as many commands will be used to enforce a // certain state (e.g. file not-present, `ainsl`, etc.) and are run on // each sync. Displaying them provides not much useful information. // Instead, quiet shows them only when they produce output (e.g. // `ainsl`, `rm -v`) and thus modify the host's state. var noOutput int if c.Quiet { for _, x := range changes { if x.Trigger == "" && x.Error == "" && x.Output == "" { noOutput++ } } } var buf strings.Builder if c.DryRun { fmt.Fprintf(&buf, "will execute %d command(s)", len(changes)) } else { fmt.Fprintf(&buf, "executed %d command(s)", len(changes)) } if noOutput > 0 && !c.DryRun { fmt.Fprintf(&buf, ", %d with no output (hidden)", noOutput) } if noOutput != len(changes) { fmt.Fprintf(&buf, ":") } if c.DryRun { fmt.Fprintf(&buf, " (dry-run)") } fmt.Fprintf(&buf, "\n") for _, x := range changes { if noOutput > 0 && x.Trigger == "" && x.Error == "" && x.Output == "" { continue } fmt.Fprintf(&buf, "%s", c.FormatTarget(x.Command)) if x.Trigger != "" { fmt.Fprintf(&buf, ", trigger for %q", x.Trigger) } if x.Error != "" { fmt.Fprintf(&buf, ", failed: %q", x.Error) } if x.Output != "" { // TODO: truncate very large outputs? x := indentBlock(x.Output, indent) fmt.Fprintf(&buf, ":\n%s", EscapeControlCharacters(c.IsTTY, x)) } fmt.Fprintf(&buf, "\n") } return buf.String() } func (c *Changes) FormatTarget(x string) string { x = fmt.Sprintf("%q", x) // escape! return ColorString(c.IsTTY, ColorCyan, x) } func (c *Changes) FormatDiff(diff string) string { const indent = " " diff = indentBlock(diff, indent) // Never color diff content as we want to color the whole diff diff = EscapeControlCharacters(false, diff) if !c.IsTTY { return diff } var res []string for _, x := range strings.Split(diff, "\n") { if strings.HasPrefix(x, indent+"+") { x = ColorString(c.IsTTY, ColorGreen, x) } else if strings.HasPrefix(x, indent+"-") { x = ColorString(c.IsTTY, ColorRed, x) } res = append(res, x) } return strings.Join(res, "\n") } func indentBlock(x string, sep string) string { lines := strings.Split(x, "\n") if lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } else { lines = append(lines, "\\ No newline at end of file") } return sep + strings.Join(lines, "\n"+sep) }