Files
mesh-host/internal/apply/archive.go
T
jochen 2a5f4c8270 Parents the host makes inside an owner's home are the owner's (hq ADR 0182, to-be 41)
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.
2026-10-04 04:07:40 +02:00

237 lines
7.8 KiB
Go

package apply
import (
"archive/tar"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/novox/mesh-host/internal/declaration"
"github.com/novox/mesh-host/internal/store"
)
// A set of files, fetched by digest and unpacked.
//
// For what inlining cannot serve: a theme, an icon set, a tree of configuration. Hundreds of
// files inlined would make every declaration enormous and rewrite all of them when one changed.
//
// **This is the one place the host reaches out on its own.** Everywhere else it holds a single
// outbound connection to the broker and fetches nothing; a container image is pulled by the
// runtime rather than by this process. So the discipline has to be explicit and it is the same
// one the bootstrap uses for images: **pinned by digest, and the digest is checked before
// anything is written.** What is fetched is bytes from a network the mesh does not control, and
// the only thing making them safe to unpack is that they hash to what was declared.
// maxArchive is how much will be read before giving up.
//
// Because a fetch with no limit is a machine somebody can fill up from the far end. Chosen large
// enough for a desktop theme and small enough to notice.
const maxArchive = 512 << 20
func applyArchive(ctx context.Context, r *declaration.Archive, previous store.Applied) (Outcome, error) {
out := begin(r)
out.Action = "unchanged"
body, err := fetch(ctx, r.Source)
if err != nil {
return out, err
}
sum := sha256.Sum256(body)
got := "sha256:" + hex.EncodeToString(sum[:])
if got != r.Digest {
// Refused before a single file is written. A digest that does not match means the thing
// at that address is not the thing that was declared, and unpacking it would be applying
// something nobody reviewed.
return out, fmt.Errorf(
"%s was declared as %s and what arrived is %s; nothing was unpacked",
r.Source, r.Digest, got)
}
out.wrote = got
// Already what it should be. The digest is the whole identity of an archive, so a matching
// record means the unpacked tree came from these exact bytes.
if previous.Wrote == got {
if _, err := os.Stat(r.Path); err == nil {
owned, err := ownedBy(r.Path, r.Owner)
if err == nil && owned {
return out, nil
}
}
}
written, err := replaceWith(body, r.Path, r.Owner)
if err != nil {
return out, err
}
out.Action = "updated"
if previous.Wrote == "" {
out.Action = "created"
}
out.Detail = fmt.Sprintf("%d file(s)", written)
return out, nil
}
// replaceWith makes the directory exactly the archive (novox/hq issue 220).
//
// **The tree on disk is the archive and nothing else.** The digest is the whole identity of what
// is unpacked here, so a file the previous archive had and this one does not must go. Unpacked over
// the old tree, it stayed: a bundle rebuilt as one file per entrypoint kept the package directory
// of the version before, which code could still import, and a fix that removed a file worked on a
// fresh machine only. So the archive is unpacked into a fresh directory beside the old one, owned,
// and swapped in by rename. A running process keeps the files it has open, and the old tree is
// removed only once the new one is in place. A failed unpack leaves the old tree untouched.
func replaceWith(body []byte, path, owner string) (int, error) {
parent := filepath.Dir(path)
if err := makeDirs(parent, 0o755, owner); err != nil {
return 0, err
}
fresh := path + ".unpacking"
replaced := path + ".replaced"
// What an interrupted earlier attempt left beside the directory.
for _, leftover := range []string{fresh, replaced} {
if err := os.RemoveAll(leftover); err != nil {
return 0, err
}
}
if err := os.Mkdir(fresh, 0o755); err != nil {
return 0, err
}
written, err := unpack(body, fresh)
if err == nil {
err = ownAll(fresh, owner)
}
if err != nil {
os.RemoveAll(fresh)
return written, err
}
hadOne := true
if err := os.Rename(path, replaced); err != nil {
if !os.IsNotExist(err) {
os.RemoveAll(fresh)
return written, err
}
hadOne = false
}
if err := os.Rename(fresh, path); err != nil {
if hadOne {
// Put the old tree back rather than leave nothing at the path.
os.Rename(replaced, path)
}
os.RemoveAll(fresh)
return written, err
}
if hadOne {
if err := os.RemoveAll(replaced); err != nil {
return written, fmt.Errorf("%s is in place, and the tree it replaced could not be removed: %w", path, err)
}
}
return written, nil
}
func fetch(ctx context.Context, source string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, source, nil)
if err != nil {
return nil, err
}
response, err := http.DefaultClient.Do(request)
if err != nil {
return nil, fmt.Errorf("cannot fetch %s: %w", source, err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", source, response.Status)
}
body, err := io.ReadAll(io.LimitReader(response.Body, maxArchive+1))
if err != nil {
return nil, err
}
if len(body) > maxArchive {
return nil, fmt.Errorf("%s is larger than %d bytes, which is not an archive this host "+
"will unpack", source, maxArchive)
}
return body, nil
}
// unpack writes a gzipped tar into a directory, refusing anything that would land outside it.
func unpack(body []byte, into string) (int, error) {
zipped, err := gzip.NewReader(strings.NewReader(string(body)))
if err != nil {
return 0, fmt.Errorf("this is not a gzipped tar: %w", err)
}
defer zipped.Close()
root, err := filepath.Abs(into)
if err != nil {
return 0, err
}
reader := tar.NewReader(zipped)
written := 0
for {
header, err := reader.Next()
if err == io.EOF {
return written, nil
}
if err != nil {
return written, err
}
// The oldest bug in unpacking: an entry named ../../etc/passwd writes outside the
// directory it was unpacked into.
//
// **Refused, not sanitised.** Rewriting the name so it lands inside would put a file
// somewhere nobody asked for and report success — the "looks configured and is not"
// failure this host exists to prevent. An archive that names a path outside itself is
// either hostile or broken, and both want the same answer.
cleaned := filepath.Clean(header.Name)
if filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(os.PathSeparator)) {
return written, fmt.Errorf(
"%s names a path outside the archive; nothing more was unpacked", header.Name)
}
// And the same question asked of the result, because a name can be made to resolve
// outside without saying so.
target := filepath.Join(root, cleaned)
if !strings.HasPrefix(target, root+string(os.PathSeparator)) && target != root {
return written, fmt.Errorf(
"%s would land outside %s; nothing more was unpacked", header.Name, into)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, os.FileMode(header.Mode)&os.ModePerm); err != nil {
return written, err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return written, err
}
file, err := os.OpenFile(target,
os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode)&os.ModePerm)
if err != nil {
return written, err
}
if _, err := io.Copy(file, io.LimitReader(reader, maxArchive)); err != nil {
file.Close()
return written, err
}
if err := file.Close(); err != nil {
return written, err
}
written++
default:
// Symlinks, devices, fifos. Refused rather than skipped: a theme that needed one
// would silently arrive incomplete, and a device node in an archive is not something
// to unpack quietly onto a machine.
return written, fmt.Errorf(
"%s is a %c, and this host unpacks only files and directories",
header.Name, header.Typeflag)
}
}
}