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)) }