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.
237 lines
7.8 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|