A file or archive placed under a fresh account's home with an owner left the parents it created, such as ~/.config or ~/.local/share, owned by root, so the person's own programs could not write there. Parents that already existed, and any outside the owner's home, are left as before.
434 lines
17 KiB
Go
434 lines
17 KiB
Go
package apply
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/novox/mesh-host/internal/declaration"
|
|
"github.com/novox/mesh-host/internal/store"
|
|
)
|
|
|
|
// A file written into a marked block, never over (novox/hq issue 128, ADR 0102).
|
|
//
|
|
// **The file is the machine's; the mesh owns lines in it.** The machine's hosts file is the case
|
|
// that needed it. The mesh wrote it whole — its own header, localhost, the machine's name and every
|
|
// name in the mesh — and on a workstation that file is shared: the distribution's lines, a local
|
|
// development tool's own marked blocks rewritten whenever its projects change, the operator's
|
|
// hand-added names. Written whole, all of those went at the next change to the mesh's names, with
|
|
// no failure anywhere: the tool believed it had written its block, and the mesh believed it owned
|
|
// the file. It is ADR 0102's failure exactly, in a file ADR 0102's JSON verb cannot speak.
|
|
//
|
|
// So the host finds the lines between `# BEGIN mesh <id>` and `# END mesh <id>`, rewrites those and
|
|
// nothing else, and records what they held before. Every line outside the markers is kept byte for
|
|
// byte — including another tool's `# BEGIN …` blocks, which are that tool's. Undeclared, the region
|
|
// is given back what it held, or taken out with its markers when it held nothing, and a file the
|
|
// mesh created goes only if nothing but whitespace is left.
|
|
|
|
// applyBlock writes a file's declared lines into its region of the file already at its path.
|
|
//
|
|
// **A link stays a link.** Where the path is a symbolic link — a hosts file some distributions keep
|
|
// elsewhere and link into /etc — the file read, written and renamed over is the one it points to,
|
|
// so the link and whatever manages it are left as they were. A file written whole, or into JSON,
|
|
// still replaces a link with a file; that is unchanged here.
|
|
func applyBlock(r *declaration.File, previous store.Applied) (Outcome, error) {
|
|
out := begin(r)
|
|
opening, closing := declaration.BlockMarkers(r.ID)
|
|
want := blockBody(r.Content)
|
|
|
|
real, err := realPath(r.Path)
|
|
if err != nil {
|
|
return out, err
|
|
}
|
|
raw, err := os.ReadFile(real)
|
|
existed := err == nil
|
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return out, err
|
|
}
|
|
// What the file is, taken once with what it holds: its mode and owner are the machine's and
|
|
// go back onto what is written. A file read and then not there to stat is a failure, never a
|
|
// file with no owner.
|
|
var info os.FileInfo
|
|
if existed {
|
|
if info, err = os.Stat(real); err != nil {
|
|
return out, fmt.Errorf("read %s and cannot see it: %w", r.Path, err)
|
|
}
|
|
}
|
|
existing := string(raw)
|
|
|
|
rec := store.Into{Format: declaration.IntoBlock}
|
|
var note string
|
|
rebuilt := false
|
|
|
|
// **A file the mesh once wrote whole** (novox/hq issue 128). The resource keeps its id when its
|
|
// module moves from writing the file whole to writing into it, and the file on the machine is
|
|
// then the mesh's own old write — its header, its loopback lines, its names. Adding the region
|
|
// after that would leave the old names above the new ones, and a resolver takes the first
|
|
// line that answers: the region would be shadowed by what it replaced. So the file is rebuilt:
|
|
// the original the mesh kept before its first write, with the region in it; or, where the mesh
|
|
// made the file itself, the loopback lines every machine needs, kept as the machine's, with the
|
|
// region beside them. Changed since the mesh wrote it, the file is somebody's again and is
|
|
// written into as it stands, and the outcome says so.
|
|
if existed && previous.Into == nil && previous.Wrote != "" {
|
|
if digestOf(existing) == previous.Wrote {
|
|
if previous.Kept != "" {
|
|
original, err := os.ReadFile(previous.Kept)
|
|
if err != nil {
|
|
return out, fmt.Errorf("%s was written whole by the mesh over an original kept at %s, "+
|
|
"which cannot be read to give it back: %w; it was left as it is", r.Path, previous.Kept, err)
|
|
}
|
|
existing = string(original)
|
|
note = "the mesh's old whole file replaced by the original kept at " + previous.Kept + ", with the region in it"
|
|
} else {
|
|
existing = loopbackOf(existing)
|
|
rec.Created = true
|
|
note = "the mesh's old whole file replaced by its loopback lines and the region"
|
|
}
|
|
// Not what was read: the whole of it was the mesh's, and the file is written afresh.
|
|
rebuilt = true
|
|
} else {
|
|
note = "a file the mesh once wrote whole, changed since; its old lines were kept"
|
|
}
|
|
}
|
|
|
|
lines := linesOf(existing)
|
|
at, found, err := regionIn(lines, opening, closing)
|
|
if err != nil {
|
|
// Refused, never guessed at: markers the host cannot pair are markers it cannot write
|
|
// between without risking lines that are not the mesh's.
|
|
return out, fmt.Errorf("%s: %w; it was left as it is", r.Path, err)
|
|
}
|
|
|
|
// A record of a block is carried; anything else — no record, a file once written whole, one
|
|
// once written into as JSON — is a file the host is seeing for the first time as a block.
|
|
recorded := previous.Into != nil && previous.Into.Format == declaration.IntoBlock
|
|
if recorded && existed {
|
|
rec.Created = previous.Into.Created
|
|
rec.Region = previous.Into.Region
|
|
rec.Separated = previous.Into.Separated
|
|
rec.At = previous.Into.At
|
|
rec.Ended = previous.Into.Ended
|
|
} else if !rebuilt {
|
|
// A file gone since the last apply is made again, and made by the mesh: what it held
|
|
// before went with it, so there is nothing to give back but the file's absence.
|
|
rec.Created = !existed
|
|
if found {
|
|
// **What the host may have written itself is not the machine's** — the same reasoning
|
|
// as a key in a JSON file (novox/hq ADR 0102). With no record, a region already holding
|
|
// exactly the declared lines cannot be told from one this host wrote a moment ago and
|
|
// died before saving; remembered as the machine's, it would be put back on undeclare
|
|
// for ever. So it is the mesh's, and undeclaring takes it out.
|
|
if held := at.body(lines); held != want {
|
|
rec.Region = &held
|
|
}
|
|
}
|
|
}
|
|
|
|
// Drift: the machine no longer holds, between the mesh's markers, what this host last put
|
|
// there. Judged only against a record of a block: a digest of a whole file says nothing about
|
|
// a region of it.
|
|
drifted := recorded && previous.Wrote != "" && existed &&
|
|
(!found || digestOf(at.body(lines)) != previous.Wrote)
|
|
|
|
var next string
|
|
switch {
|
|
case !existed:
|
|
next = regionOf(opening, closing, want)
|
|
case found:
|
|
// Where it is, whatever At says: the region is never moved, because moving it moves the
|
|
// machine's lines around it.
|
|
next = strings.Join(lines[:at.begin+1], "") + want + strings.Join(lines[at.end:], "")
|
|
case r.At == declaration.AtStart:
|
|
// Above everything, and one blank line between the region and the machine's first line
|
|
// unless there is one already — a line in some files means what the lines above it say.
|
|
rec.At, rec.Separated, rec.Ended = declaration.AtStart, false, false
|
|
next = regionOf(opening, closing, want)
|
|
if existing != "" && !strings.HasPrefix(existing, "\n") {
|
|
next += "\n"
|
|
rec.Separated = true
|
|
}
|
|
next += existing
|
|
default:
|
|
// At the end, apart from whatever is there: the file's last line is ended if it was not,
|
|
// and one blank line separates the region from the machine's lines unless there is one.
|
|
rec.At, rec.Separated, rec.Ended = "", false, false
|
|
next = existing
|
|
if next != "" && !strings.HasSuffix(next, "\n") {
|
|
next += "\n"
|
|
rec.Ended = true
|
|
}
|
|
if next != "" && next != "\n" && !strings.HasSuffix(next, "\n\n") {
|
|
next += "\n"
|
|
rec.Separated = true
|
|
}
|
|
next += regionOf(opening, closing, want)
|
|
}
|
|
// What was not the mesh's is what it was. By construction — and checked, because a slip in
|
|
// splicing lines is exactly the fault this mode exists to prevent, and it must never be written.
|
|
if found {
|
|
after := linesOf(next)
|
|
if where, ok, err := regionIn(after, opening, closing); err != nil || !ok || outside(after, where) != outside(lines, at) {
|
|
return out, fmt.Errorf("%s: writing the region would change lines outside it; it was left as it is", r.Path)
|
|
}
|
|
}
|
|
|
|
same := existed && next == string(raw)
|
|
if !same {
|
|
mode := os.FileMode(0o644)
|
|
if info != nil {
|
|
mode = info.Mode().Perm() // the machine's file keeps the machine's mode
|
|
} else if mode, err = modeOf(r.Mode, mode); err != nil {
|
|
return out, err
|
|
}
|
|
if err := makeDirs(filepath.Dir(real), 0o755, r.Owner); err != nil {
|
|
return out, err
|
|
}
|
|
if err := writeAtomically(real, []byte(next), mode); err != nil {
|
|
return out, err
|
|
}
|
|
if info != nil {
|
|
// The write is a new file renamed over the old, so it belongs to whoever wrote it. The
|
|
// machine's file keeps the machine's owner, as it keeps its mode.
|
|
if err := keepOwner(real, info); err != nil {
|
|
return out, err
|
|
}
|
|
} else if err := own(real, r.Owner); err != nil {
|
|
return out, err
|
|
}
|
|
}
|
|
|
|
// Read back: the region holds what was declared. Only the region — another tool writing its
|
|
// own lines in the moment after the rename is not a failed write. What remains is the moment
|
|
// between reading the file and renaming over it: a line another tool writes there is lost, and
|
|
// found again at its next write. Nothing short of a lock every writer honours closes that, and
|
|
// the other writers of a hosts file honour none.
|
|
written, err := os.ReadFile(real)
|
|
if err != nil {
|
|
return out, fmt.Errorf("wrote into %s and cannot read it back: %w", r.Path, err)
|
|
}
|
|
back := linesOf(string(written))
|
|
if where, ok, err := regionIn(back, opening, closing); err != nil || !ok || where.body(back) != want {
|
|
return out, fmt.Errorf("%s does not hold the mesh's region after writing into it", r.Path)
|
|
}
|
|
|
|
out.into = &rec
|
|
out.wrote = digestOf(want)
|
|
switch {
|
|
case note != "" && !same:
|
|
out.Action = "updated"
|
|
out.Detail = note
|
|
case !existed:
|
|
out.Action = "created"
|
|
out.Detail = "written into; the file was not there"
|
|
case same:
|
|
out.Action = "unchanged"
|
|
case drifted:
|
|
out.Action = "corrected"
|
|
out.Detail = "the mesh's region had been changed on the machine; every line outside it was kept"
|
|
case !found:
|
|
out.Action = "updated"
|
|
where := "end"
|
|
if rec.At == declaration.AtStart {
|
|
where = "start"
|
|
}
|
|
out.Detail = "the mesh's region added at the " + where + "; every other line kept as it was"
|
|
default:
|
|
out.Action = "updated"
|
|
out.Detail = "the mesh's region rewritten; every line outside it kept as it was"
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// removeBlock gives back what a file written into a block held before the mesh's region.
|
|
func removeBlock(a store.Applied) (string, string, error) {
|
|
real, err := realPath(a.Target)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
raw, err := os.ReadFile(real)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return "forgotten", "no longer there", nil
|
|
}
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
info, err := os.Stat(real)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("read %s and cannot see it: %w", a.Target, err)
|
|
}
|
|
opening, closing := declaration.BlockMarkers(a.ID)
|
|
lines := linesOf(string(raw))
|
|
at, found, err := regionIn(lines, opening, closing)
|
|
if err != nil {
|
|
return "kept", err.Error() + ", so nothing was taken out of it; remove the mesh's region by hand", nil
|
|
}
|
|
|
|
next, action, detail := string(raw), "forgotten", "the mesh's region was no longer in it"
|
|
switch {
|
|
case found && a.Into.Region != nil:
|
|
next = strings.Join(lines[:at.begin+1], "") + *a.Into.Region + strings.Join(lines[at.end:], "")
|
|
action, detail = "restored", "no longer declared; the region was given back what it held"
|
|
case found:
|
|
from, to := at.begin, at.end+1
|
|
// The blank line the host added beside the region, when a blank line still stands there.
|
|
// Whether it is the same one the host added cannot be known from the file; a blank line
|
|
// is the one line whose going changes nothing any program reads, so it is taken. A line
|
|
// that is not blank is never taken, whoever put it there.
|
|
if a.Into.Separated {
|
|
if a.Into.At == declaration.AtStart {
|
|
if to < len(lines) && lines[to] == "\n" {
|
|
to++
|
|
}
|
|
} else if from > 0 && lines[from-1] == "\n" {
|
|
from--
|
|
}
|
|
}
|
|
next = strings.Join(lines[:from], "") + strings.Join(lines[to:], "")
|
|
// And the line end the host gave the machine's last line, if that line is still last.
|
|
if a.Into.Ended && strings.Join(lines[to:], "") == "" {
|
|
next = strings.TrimSuffix(next, "\n")
|
|
}
|
|
action, detail = "restored", "no longer declared; the mesh's region was taken out and every other line kept"
|
|
}
|
|
|
|
if a.Into.Created && strings.TrimSpace(next) == "" && real == a.Target {
|
|
if err := os.Remove(real); err != nil {
|
|
return "", "", err
|
|
}
|
|
return "removed", "no longer declared; the mesh had created it and nothing else was in it", nil
|
|
}
|
|
if next == string(raw) {
|
|
return action, detail, nil
|
|
}
|
|
if err := writeAtomically(real, []byte(next), info.Mode().Perm()); err != nil {
|
|
return "", "", err
|
|
}
|
|
if err := keepOwner(real, info); err != nil {
|
|
return "", "", err
|
|
}
|
|
return action, detail, nil
|
|
}
|
|
|
|
// realPath is the file a path names, through any links; a path that is not there yet is itself.
|
|
// A link to nothing is refused: writing through it would replace the link with a file.
|
|
func realPath(path string) (string, error) {
|
|
real, err := filepath.EvalSymlinks(path)
|
|
if err == nil {
|
|
return real, nil
|
|
}
|
|
if _, lerr := os.Lstat(path); errors.Is(lerr, os.ErrNotExist) {
|
|
return path, nil
|
|
}
|
|
return "", fmt.Errorf("%s is a link the host cannot follow to a file: %w; it was left as it is", path, err)
|
|
}
|
|
|
|
// loopbackOf is the lines of a file that answer for the machine itself — localhost, its own name on
|
|
// 127.0.1.1, ::1 — and nothing else: what the mesh's old whole hosts file carried that the machine
|
|
// needs, without the mesh's header or its names.
|
|
func loopbackOf(text string) string {
|
|
var b strings.Builder
|
|
for _, line := range linesOf(text) {
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
continue
|
|
}
|
|
if ip := net.ParseIP(fields[0]); ip != nil && ip.IsLoopback() {
|
|
b.WriteString(strings.TrimSuffix(line, "\n") + "\n")
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// outside is every line of a file but the mesh's region, markers included, as one string.
|
|
func outside(lines []string, at region) string {
|
|
end := at.end + 1
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
return strings.Join(lines[:at.begin], "") + "\x00" + strings.Join(lines[end:], "")
|
|
}
|
|
|
|
// blockBody is the declared lines as they stand in the region: ending in exactly one line end, or
|
|
// nothing at all when there are no lines.
|
|
func blockBody(content string) string {
|
|
trimmed := strings.TrimRight(content, "\n")
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
return trimmed + "\n"
|
|
}
|
|
|
|
func regionOf(begin, end, body string) string {
|
|
return begin + "\n" + body + end + "\n"
|
|
}
|
|
|
|
// linesOf splits text into lines that keep their line ends, so joining them again gives back
|
|
// exactly the bytes that were read — a last line without one included.
|
|
func linesOf(text string) []string {
|
|
return strings.SplitAfter(text, "\n")
|
|
}
|
|
|
|
// region is where the mesh's markers stand, as indices into the lines of a file.
|
|
type region struct{ begin, end int }
|
|
|
|
// body is what stands between the markers.
|
|
func (r region) body(lines []string) string {
|
|
return strings.Join(lines[r.begin+1:r.end], "")
|
|
}
|
|
|
|
// regionIn finds the mesh's markers for one resource. A line is a marker only if it is exactly the
|
|
// marker, so another tool's block and another resource's region are never it. Markers that do not
|
|
// form one pair — a begin with no end, an end before its begin, either twice — are an error rather
|
|
// than a best guess, because a guess is how the host would rewrite lines that are not its own.
|
|
func regionIn(lines []string, begin, end string) (region, bool, error) {
|
|
at := region{begin: -1, end: -1}
|
|
for i, line := range lines {
|
|
switch strings.TrimSuffix(line, "\n") {
|
|
case begin:
|
|
if at.begin >= 0 {
|
|
return at, false, fmt.Errorf("%q is in it more than once", begin)
|
|
}
|
|
at.begin = i
|
|
case end:
|
|
if at.end >= 0 {
|
|
return at, false, fmt.Errorf("%q is in it more than once", end)
|
|
}
|
|
at.end = i
|
|
}
|
|
}
|
|
switch {
|
|
case at.begin < 0 && at.end < 0:
|
|
return at, false, nil
|
|
case at.begin < 0:
|
|
return at, false, fmt.Errorf("%q is in it with no %q before it", end, begin)
|
|
case at.end < 0:
|
|
return at, false, fmt.Errorf("%q is in it with no %q after it", begin, end)
|
|
case at.end < at.begin:
|
|
return at, false, fmt.Errorf("%q stands before %q", end, begin)
|
|
}
|
|
return at, true, nil
|
|
}
|
|
|
|
// keepOwner gives a file rewritten through a new one back to whoever owned what it replaced.
|
|
// Changed only where it differs, so a host that is not root can still write a file it owns.
|
|
func keepOwner(path string, was os.FileInfo) error {
|
|
uid, gid, ok := ownerOf(was)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
now, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u, g, ok := ownerOf(now); ok && u == uid && g == gid {
|
|
return nil
|
|
}
|
|
if err := os.Chown(path, uid, gid); err != nil {
|
|
return fmt.Errorf("cannot give %s back to its owner %d:%d: %w", path, uid, gid, err)
|
|
}
|
|
return nil
|
|
}
|