mesh/delivery merged: source.main moved to novox/mesh-host@998d8f737008
mesh/check being carried to 5dc3a8e17f97 on 998d8f737008 | from fabfdf04f78a
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
134 lines
5.3 KiB
Go
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))
|
|
}
|