Files
Felis/internal/fileedit/unzip.go
T

557 lines
19 KiB
Go

package fileedit
import (
"archive/zip"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"math"
"os"
"path"
"sort"
"strings"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
// The codes an unzip refuses an archive with. Each names what is wrong with the
// ARCHIVE, so the panel can say "this zip is broken" rather than "your path is
// wrong"; Result.Entry names the entry.
const (
// CodeArchiveInvalid is a file that is not a zip, a damaged one, one whose
// entry bytes disagree with their header (size or CRC), or one with nothing in it.
CodeArchiveInvalid = "archive_invalid"
// CodeArchiveUnsafe is an entry naming a path outside the folder it is
// extracted into ("../", "/etc/x", "C:\x"), or a device, pipe or socket.
CodeArchiveUnsafe = "archive_unsafe"
// CodeArchiveSymlink is an entry that is a symbolic link. A link extracted into
// the world could point anywhere, and every later op would have to reason about
// it, so an archive carrying one is refused whole.
CodeArchiveSymlink = "archive_symlink"
// CodeTypeConflict is an archive with a file where the server has a folder, a
// folder where it has a file, or anything where it has a link. Overwrite never
// resolves it: replacing a folder with a file would delete the folder.
CodeTypeConflict = "type_conflict"
)
// MaxConflicts bounds Result.Conflicts, keeping the result line bounded when an
// archive would replace a whole world; ConflictCount still says how many there
// are. maxConflictBytes bounds the names listed as well: felis-api reads an
// unzip's result from the last opLogLines lines of its Pod's log, and the
// container runtime splits a line longer than 16 KiB into several, so 200 long
// paths would push the result marker out of that tail and the op would read as
// ended without a result. A file that exists has a path under PATH_MAX (4 KiB),
// so the first conflict always fits.
const (
MaxConflicts = 200
maxConflictBytes = 8 << 10
)
// unzipEntryOverhead is what the space check adds per entry for the inode and
// directory block it takes beyond its bytes.
const unzipEntryOverhead = 4096
// unzipTempPrefix names the working folder an unzip extracts into. It sits in
// the destination folder, so every move out of it is a rename on one volume.
const unzipTempPrefix = ".felis-unzip-"
// unzip extracts the .zip at name into the folder holding it.
//
// It is all or nothing. Every check that can refuse the archive runs before a
// byte is written: entry names, entry types, what is already on the server, and
// the room on the volume. The entries are then extracted into a working folder
// beside the destination, and only once every one of them has been written and
// verified are they renamed into place. A failure at any point before that
// leaves the destination exactly as it was. The renames themselves are journaled
// and undone in reverse if one fails, so a replaced file comes back. Only a Job
// killed in the middle of the renames — a few milliseconds for thousands of
// files — can leave the archive half applied.
//
// An existing file the archive would replace is a conflict: without overwrite the
// unzip lists them (CodeExists, Conflicts) and changes nothing; with it they are
// replaced. Folders merge. A file where the server has a folder, or the reverse,
// is CodeTypeConflict whatever overwrite says.
//
// The only size bound is the volume. Each entry's declared size is summed and
// checked against the free space up front, and archive/zip itself refuses an
// entry whose bytes run past its declared size or fail its CRC, so an archive
// that lies about its sizes (a zip bomb) stops at the first lying entry and
// nothing it wrote survives.
func unzip(r *os.Root, rootPath, name string, overwrite bool, progress func(done, total int64)) Result {
name = path.Clean(name)
if !strings.EqualFold(path.Ext(name), ".zip") {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a .zip archive", name)}
}
f, err := r.Open(name)
if err != nil {
return failure(err, name)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return failure(err, name)
}
if !info.Mode().IsRegular() {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a file", name)}
}
// ErrInsecurePath comes back WITH a usable reader, and only under
// GODEBUG=zipinsecurepath=0; planUnzip does that check itself, for every
// entry, whatever the setting.
zr, err := zip.NewReader(f, info.Size())
if err != nil && !errors.Is(err, zip.ErrInsecurePath) {
return Result{Code: CodeArchiveInvalid, Error: fmt.Sprintf("%s is not a readable zip archive: %v", name, err)}
}
p, res := planUnzip(zr.File)
if res.Code != "" {
return res
}
dest := path.Dir(name)
present, replaced, res := checkTargets(r, dest, p)
if res.Code != "" {
return res
}
if len(replaced) > 0 && !overwrite {
list := make([]string, 0, len(replaced))
for n := range replaced {
list = append(list, path.Join(dest, n))
}
sort.Strings(list)
count, n, size := len(list), 0, 0
for n < count && n < MaxConflicts && size+len(list[n]) <= maxConflictBytes {
size += len(list[n])
n++
}
list = list[:n]
return Result{Code: CodeExists, Conflicts: list, ConflictCount: count, Error: fmt.Sprintf(
"%d files in the archive already exist on the server; extract again with overwrite to replace them", count)}
}
// A working folder left by an unzip that was killed holds only a copy, and
// clearing it first gives its room back to the check below.
sweepUnzipTemps(r, dest)
need := p.bytes + int64(len(p.files)+len(p.dirs))*unzipEntryOverhead
if avail, _, err := statfs(rootPath); err == nil && uint64(need) > avail {
free := int64(math.MaxInt64)
if avail < math.MaxInt64 {
free = int64(avail)
}
return Result{Code: CodeNoSpace, Need: need, Avail: free, Error: fmt.Sprintf(
"extracting %s needs %d bytes and the server's volume has %d free; nothing was changed", name, need, free)}
}
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}
}
tmp := path.Join(dest, unzipTempPrefix+hex.EncodeToString(suffix[:]))
staged, old := path.Join(tmp, "new"), path.Join(tmp, "old")
for _, d := range []string{tmp, staged, old} {
if err := r.Mkdir(d, 0o700); err != nil {
_ = r.RemoveAll(tmp)
return unzipWriteFailure(err, dest)
}
}
// After a success tmp holds only the files the archive replaced; after a
// failure, everything the archive wrote. Either way it goes.
defer func() { _ = r.RemoveAll(tmp) }()
if res := extractAll(r, staged, p, progress); res.Code != "" {
return res
}
if res := placeAll(r, dest, staged, old, p, present, replaced); res.Code != "" {
return res
}
return Result{Files: len(p.files), Bytes: p.bytes}
}
// unzipPlan is an archive's entries once every one has passed planUnzip. Names
// are cleaned, slash-separated and relative to the destination folder.
type unzipPlan struct {
files []zipFile
// dirs is every folder the archive makes, named in it or implied by a file
// inside it, sorted so a folder comes before everything in it.
dirs []string
isDir map[string]bool
bytes int64 // the declared size of every file, summed
}
type zipFile struct {
f *zip.File
name string
mode fs.FileMode
}
// planUnzip checks every entry's name and type and works out what the archive
// makes. Nothing about the server is consulted yet.
func planUnzip(entries []*zip.File) (unzipPlan, Result) {
p := unzipPlan{isDir: map[string]bool{}}
for _, f := range entries {
raw := entryName(f)
name, ok := cleanEntry(raw)
if !ok {
return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf(
"%s leads outside the folder it would be extracted into", raw)}
}
// macOS's Finder adds __MACOSX/ to every zip it makes: resource forks that
// mean nothing on the server.
if name == "__MACOSX" || strings.HasPrefix(name, "__MACOSX/") {
continue
}
mode := f.Mode()
isDir := mode.IsDir() || strings.HasSuffix(raw, "/")
switch {
case mode&fs.ModeSymlink != 0:
return p, Result{Code: CodeArchiveSymlink, Entry: raw, Error: fmt.Sprintf(
"%s is a symbolic link; an archive containing links is not extracted", raw)}
case isDir:
if name != "." {
p.isDir[name] = true
}
continue
case !mode.IsRegular():
return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf(
"%s is not a regular file", raw)}
case name == ".":
return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf(
"%s names the destination folder itself", raw)}
case f.UncompressedSize64 > uint64(math.MaxInt64-p.bytes):
return p, Result{Code: CodeArchiveInvalid, Entry: raw, Error: fmt.Sprintf(
"%s declares an impossible size", raw)}
}
p.bytes += int64(f.UncompressedSize64)
// Anything the archive marks executable (a start.sh) stays executable;
// every other permission is the server's usual.
perm := fs.FileMode(0o644)
if mode.Perm()&0o111 != 0 {
perm = 0o755
}
p.files = append(p.files, zipFile{f: f, name: name, mode: perm})
}
// Sorted, a name the archive holds twice sits next to itself; a set of names
// would cost as much again as the entries for an archive of many small files.
sort.Slice(p.files, func(i, j int) bool { return p.files[i].name < p.files[j].name })
for i := 1; i < len(p.files); i++ {
if p.files[i].name == p.files[i-1].name {
return p, Result{Code: CodeArchiveInvalid, Entry: p.files[i].name, Error: fmt.Sprintf(
"%s appears in the archive twice", p.files[i].name)}
}
}
for _, zf := range p.files {
// cleanEntry already refused a rooted name; stopping at "/" as well keeps
// this loop finite should that check ever move.
for d := path.Dir(zf.name); d != "." && d != "/"; d = path.Dir(d) {
p.isDir[d] = true
}
}
for _, zf := range p.files {
if p.isDir[zf.name] {
return p, Result{Code: CodeArchiveInvalid, Entry: zf.name, Error: fmt.Sprintf(
"%s is both a file and a folder in the archive", zf.name)}
}
}
if len(p.files) == 0 && len(p.isDir) == 0 {
return p, Result{Code: CodeArchiveInvalid, Error: "the archive has nothing to extract"}
}
for d := range p.isDir {
p.dirs = append(p.dirs, d)
}
// A folder's name is a prefix of everything in it, and a prefix sorts first.
sort.Strings(p.dirs)
return p, Result{}
}
// entryName is an entry's name as its maker meant it. A zip made on Chinese
// Windows stores names in the system code page (GBK) without the UTF-8 flag; a
// name that is not valid UTF-8 is decoded as GB18030, GBK's superset. The flag
// alone is no guide: archive/zip reports NonUTF8 for every name made without it,
// which includes plain ASCII and macOS's UTF-8. Decoding comes before the
// backslash below because a GBK trail byte can be 0x5C.
func entryName(f *zip.File) string {
name := f.Name
if !utf8.ValidString(name) {
if s, err := simplifiedchinese.GB18030.NewDecoder().String(name); err == nil {
name = s
}
}
return strings.ReplaceAll(name, `\`, "/")
}
// cleanEntry cleans an entry name and reports false for one that must not be
// extracted: a NUL, an absolute path, a drive letter, or a climb out of the
// destination. It judges the name as TEXT, which is sound here because nothing
// is resolved through it until checkTargets and extraction, and those go through
// os.Root, which would refuse an escape anyway.
func cleanEntry(raw string) (string, bool) {
if raw == "" || strings.ContainsRune(raw, 0) || strings.HasPrefix(raw, "/") {
return "", false
}
if len(raw) >= 2 && raw[1] == ':' && (raw[0]|0x20) >= 'a' && (raw[0]|0x20) <= 'z' {
return "", false
}
name := path.Clean(raw)
if name == ".." || strings.HasPrefix(name, "../") {
return "", false
}
return name, true
}
// checkTargets looks at what the server already has where the archive lands.
// present is the archive's folders that exist on the server (they merge);
// replaced is its files that do (conflicts). A folder the server lacks cannot
// hold anything, so its contents are not looked up.
func checkTargets(r *os.Root, dest string, p unzipPlan) (present, replaced map[string]bool, res Result) {
present, replaced = map[string]bool{}, map[string]bool{}
absent := func(name string) bool {
parent := path.Dir(name)
return parent != "." && !present[parent]
}
for _, d := range p.dirs {
if absent(d) {
continue
}
at := path.Join(dest, d)
info, err := r.Lstat(at)
switch {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return nil, nil, failure(err, at)
case info.Mode()&fs.ModeSymlink != 0:
return nil, nil, typeConflict(at, "is a link on the server, and the archive has a folder there")
case info.IsDir():
present[d] = true
default:
return nil, nil, typeConflict(at, "is a file on the server, and the archive has a folder there")
}
}
for _, zf := range p.files {
if absent(zf.name) {
continue
}
at := path.Join(dest, zf.name)
info, err := r.Lstat(at)
switch {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return nil, nil, failure(err, at)
case info.Mode().IsRegular():
replaced[zf.name] = true
case info.IsDir():
return nil, nil, typeConflict(at, "is a folder on the server, and the archive has a file there")
default:
return nil, nil, typeConflict(at, "is a link or special file on the server, and the archive has a file there")
}
}
return present, replaced, Result{}
}
func typeConflict(at, why string) Result {
return Result{Code: CodeTypeConflict, Entry: at, Error: fmt.Sprintf("%s %s; nothing was changed", at, why)}
}
// extractAll writes every folder and file of p under staged, handed to the game
// uid, and syncs each file.
func extractAll(r *os.Root, staged string, p unzipPlan, progress func(done, total int64)) Result {
for _, d := range p.dirs {
at := path.Join(staged, d)
if err := r.Mkdir(at, 0o755); err != nil {
return unzipWriteFailure(err, d)
}
_ = ownWritten(r, at)
}
var done int64
count := func(n int) {
done += int64(n)
if progress != nil {
progress(done, p.bytes)
}
}
for _, zf := range p.files {
if res := extractOne(r, path.Join(staged, zf.name), zf, count); res.Code != "" {
return res
}
}
for _, d := range p.dirs {
syncDir(r, path.Join(staged, d))
}
syncDir(r, staged)
return Result{}
}
func extractOne(r *os.Root, at string, zf zipFile, count func(int)) Result {
invalid := func(err error) Result {
return Result{Code: CodeArchiveInvalid, Entry: zf.name, Error: fmt.Sprintf(
"%s in the archive is damaged: %v; nothing was changed", zf.name, err)}
}
src, err := zf.f.Open()
if err != nil {
return invalid(err)
}
defer src.Close()
w, err := r.OpenFile(at, os.O_WRONLY|os.O_CREATE|os.O_EXCL, zf.mode)
if err != nil {
return unzipWriteFailure(err, zf.name)
}
if err := w.Chmod(zf.mode); err != nil {
w.Close()
return unzipWriteFailure(err, zf.name)
}
if _, err := io.Copy(countingWriter{w, count}, archiveReader{src}); err != nil {
w.Close()
var ae *archiveError
if errors.As(err, &ae) {
return invalid(ae.err)
}
return unzipWriteFailure(err, zf.name)
}
if err := syncWritten(w); err != nil {
w.Close()
return unzipWriteFailure(err, zf.name)
}
if err := w.Close(); err != nil {
return unzipWriteFailure(err, zf.name)
}
_ = ownWritten(r, at)
return Result{}
}
// placeAll renames the extracted tree into dest. A folder the server lacks moves
// whole; one it has is descended into. A file it has is first moved aside into
// old, so undoing the journal puts it back.
func placeAll(r *os.Root, dest, staged, old string, p unzipPlan, present, replaced map[string]bool) Result {
// Only the destination and the folders the server has are descended into;
// everything else moves with the folder it is in, so its name is not kept.
kids := map[string][]string{}
add := func(name string) {
if parent := path.Dir(name); parent == "." || present[parent] {
kids[parent] = append(kids[parent], name)
}
}
for _, d := range p.dirs {
add(d)
}
for _, zf := range p.files {
add(zf.name)
}
type move struct{ from, to string }
var journal []move
mv := func(from, to string) error {
if err := renameEntry(r, from, to); err != nil {
return err
}
journal = append(journal, move{from, to})
return nil
}
var place func(dir string) error
place = func(dir string) error {
for _, c := range kids[dir] {
src, dst := path.Join(staged, c), path.Join(dest, c)
switch {
case p.isDir[c] && present[c]:
if err := place(c); err != nil {
return err
}
case replaced[c]:
aside := path.Join(old, c)
if err := r.MkdirAll(path.Dir(aside), 0o700); err != nil {
return err
}
if err := mv(dst, aside); err != nil {
return err
}
if err := mv(src, dst); err != nil {
return err
}
default:
if err := mv(src, dst); err != nil {
return err
}
}
}
return nil
}
if err := place("."); err != nil {
for i := len(journal) - 1; i >= 0; i-- {
_ = r.Rename(journal[i].to, journal[i].from)
}
return unzipWriteFailure(err, dest)
}
syncDir(r, dest)
for d := range present {
syncDir(r, path.Join(dest, d))
}
return Result{}
}
// renameEntry is the rename placeAll moves with. A var so a test can fail one
// part-way through and watch the journal undo the rest.
var renameEntry = func(r *os.Root, from, to string) error { return r.Rename(from, to) }
// sweepUnzipTemps removes working folders a killed unzip left in dir.
func sweepUnzipTemps(r *os.Root, dir string) {
d, err := r.Open(dir)
if err != nil {
return
}
names, _ := d.Readdirnames(-1)
d.Close()
for _, n := range names {
if isUnzipTemp(n) {
_ = r.RemoveAll(path.Join(dir, n))
}
}
}
func isUnzipTemp(name string) bool {
suffix, ok := strings.CutPrefix(name, unzipTempPrefix)
if !ok || len(suffix) != 12 {
return false
}
_, err := hex.DecodeString(suffix)
return err == nil
}
// unzipWriteFailure is writeFailure worded for an unzip, which by then has
// changed nothing whatever the step that failed.
func unzipWriteFailure(err error, name string) Result {
res := writeFailure(err, name)
if res.Code == CodeNoSpace {
res.Error = "the server's volume filled up while extracting; nothing was changed"
}
return res
}
// archiveError marks a failure reading an entry's bytes out of the archive, so
// extractOne can tell a damaged archive from the volume failing underneath.
type archiveError struct{ err error }
func (e *archiveError) Error() string { return "read archive: " + e.err.Error() }
func (e *archiveError) Unwrap() error { return e.err }
type archiveReader struct{ r io.Reader }
func (a archiveReader) Read(p []byte) (int, error) {
n, err := a.r.Read(p)
if err != nil && err != io.EOF {
err = &archiveError{err}
}
return n, err
}
// countingWriter reports each write's length to add.
type countingWriter struct {
w io.Writer
add func(int)
}
func (c countingWriter) Write(p []byte) (int, error) {
n, err := c.w.Write(p)
c.add(n)
return n, err
}