Files
mesh-host/internal/apply/archive_journal.go
T
jschoubben 5dc3a8e17f
mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a
Keep what an archive unpacked in the engine's own state directory, never beside the archive, and stop no slower than the host waits (issue 1585, review round 1)
The journal of an unpack sat at <target>.unpacked.json, in a directory
the module's account may write: a forged one would make the root engine
remove or adopt any path it named. It now lives under
<statedir>/unpacked/<sha256 of the target>.json, mode 0600, names its
target, and is refused whole when an entry is not filepath.IsLocal or a
parent is not above the target. It goes when the archive is taken off,
whether its state record still said what it unpacked or not.

The launcher's stop grace for a host on trial rises from 20 s to 75 s and
the unit's TimeoutStopSec is 120 s (openrc the same), past the host's own
60 s wait for the apply in flight, so neither cuts a save off again.

Tests folded as rows into existing ones (the repository is over its unit
budget): the journal's rows, a forged entry and parent refused, the
journal gone with a record that lost its Unpacked; the test that passed
on main dropped; the stop test waits on its own line, not a sleep, and no
longer consumes the save it waits for.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-04 10:52:42 +01:00

134 lines
5.3 KiB
Go

package apply
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/novox/mesh-host/internal/store"
)
// What an archive unpacked is written down the moment it is unpacked (novox/hq issue 1585).
//
// The node's state is saved at the end of a module's apply, so an engine that stopped, was replaced or lost its
// state between the unpack and that save held files on the machine it had no record of. The next apply then found
// its own files and refused them as ones "the mesh did not put there" (issue 162), and a module could not move to
// its new build until a person removed them. So, at once and atomically, the host keeps the digest it unpacked and
// the files and directories it placed; an apply that holds no record of the archive reads this instead of treating
// everything there as somebody's. The record in the state stays the authority: the journal is read only when there
// is none.
//
// **In the node-engine's own state directory, readable and writable by root alone, never beside the archive.** The
// archive's directory may be writable by the module's account (a home, a data directory): a journal there could be
// forged, and a root engine would then remove, or take as its own, any path the forger named. And an entry is read
// only when it names a path inside the archive's directory (filepath.IsLocal), and a parent only when it is above it.
// JournalDirName is the directory under the node's state directory that keeps the journals.
const JournalDirName = "unpacked"
type unpackJournal struct {
Target string `json:"target"`
Wrote string `json:"wrote"`
Unpacked *store.Unpacked `json:"unpacked"`
}
// journalDir is where the journals of this apply are kept: under the state directory every applier on the machine
// shares (the one its windows are kept in). Empty for a caller with no state directory (a test), which keeps none.
func journalDir(w *Windows) string {
if w == nil || w.dir == "" {
return ""
}
return filepath.Join(filepath.Dir(w.dir), JournalDirName)
}
// journalPath names the journal of the archive at target: by the digest of its path, so no path names a file
// outside the journals' directory.
func journalPath(dir, target string) string {
sum := sha256.Sum256([]byte(filepath.Clean(target)))
return filepath.Join(dir, hex.EncodeToString(sum[:])+".json")
}
// keepJournal writes down what an archive unpacked at path.
func keepJournal(dir, path, wrote string, u *store.Unpacked) error {
if dir == "" {
return nil
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.Marshal(unpackJournal{Target: filepath.Clean(path), Wrote: wrote, Unpacked: u})
if err != nil {
return err
}
return writeAtomicallyByPath(journalPath(dir, path), raw, 0o600)
}
// journalOf is what the journal says of path: false when there is none, and an error when there is one that cannot
// be read or names a path outside the archive's directory, which is never believed.
func journalOf(dir, path string) (unpackJournal, bool, error) {
if dir == "" {
return unpackJournal{}, false, nil
}
raw, err := os.ReadFile(journalPath(dir, path))
if err != nil {
return unpackJournal{}, false, nil
}
var j unpackJournal
if err := json.Unmarshal(raw, &j); err != nil {
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s is unreadable: %w", path, err)
}
if j.Wrote == "" || j.Unpacked == nil {
return unpackJournal{}, false, fmt.Errorf("the journal of what was unpacked at %s names no digest or files", path)
}
if err := checkJournal(j, filepath.Clean(path)); err != nil {
return unpackJournal{}, false, err
}
return j, true, nil
}
// checkJournal refuses a journal of another target, an entry that is not a path inside the archive's directory, and
// a parent that is not above it.
func checkJournal(j unpackJournal, target string) error {
if j.Target != target {
return fmt.Errorf("the journal kept for %s names %q", target, j.Target)
}
for _, rel := range append(append([]string(nil), j.Unpacked.Files...), j.Unpacked.Dirs...) {
if !filepath.IsLocal(filepath.FromSlash(rel)) {
return fmt.Errorf("the journal of what was unpacked at %s names %q, which is not inside it: not believed", target, rel)
}
}
for _, p := range j.Unpacked.Parents {
if p = filepath.Clean(p); !filepath.IsAbs(p) || p == target || !strings.HasPrefix(target, p+string(filepath.Separator)) {
return fmt.Errorf("the journal of what was unpacked at %s names the parent %q, which is not above it: not believed", target, p)
}
}
return nil
}
// withJournal is previous, or, when the state holds no record of the archive, what the journal says the host
// unpacked at path. A journal not believed is an error: the apply stops and says so rather than guess.
func withJournal(dir string, previous store.Applied, path string) (store.Applied, error) {
if previous.Wrote != "" {
return previous, nil
}
j, ok, err := journalOf(dir, path)
if err != nil || !ok {
return previous, err
}
previous.Wrote, previous.Target, previous.Unpacked = j.Wrote, path, j.Unpacked
return previous, nil
}
// dropJournal removes the journal of the archive at target, once the archive is taken off; whether the state's
// record of it still said what it unpacked or not.
func dropJournal(dir, target string) {
if dir == "" || target == "" {
return
}
_ = os.Remove(journalPath(dir, target))
}