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