fix(files): 模组包上传也逐段核对 SHA-256、长文件名能写、卡住的导出按时停掉、世界被占用时文件页说明原因并等它结束

This commit is contained in:
Lemon-miaow committed 2026-09-29 01:26:24 +08:00
1 parent 1d1549cea2
commit cb3065da1d
59 files changed
+2538 -338

No files matched your search

+4 -3
View File
@@ -221,8 +221,8 @@ type Listing struct {
Entries []Entry
// Truncated reports that the directory holds more than MaxEntries.
Truncated bool
// Free is the bytes free on the server's volume, 0 when the Job could not
// tell.
// Free is the bytes free on the server's volume, negative when the Job could
// not tell.
Free int64
}
@@ -394,7 +394,8 @@ type OpState struct {
Done, Total int64
// Result is what the Job printed once it finished. It is nil while the Job
// runs, and for a Job that ended without printing one (killed at its
// deadline, out of memory, its bytes unfetchable), whose Reason says why.
// deadline, out of memory, its bytes unfetchable), whose Reason says why
// (ReasonOOMKilled for memory).
Result *Result
Reason string
}
+24 -8
View File
@@ -188,15 +188,16 @@ type Result struct {
SHA256 string `json:"sha256,omitempty"`
// Conflicts lists, relative to the root and sorted, the existing files an
// unzip would replace: the first MaxConflicts of them. ConflictCount is how
// many there are in all.
// unzip would replace: the first of them, up to MaxConflicts and 8 KiB of
// names (see maxConflictBytes). ConflictCount is how many there are in all.
Conflicts []string `json:"conflicts,omitempty"`
ConflictCount int `json:"conflict_count,omitempty"`
// Entry names what an unzip refused: the archive entry, or the path on the
// server it collides with.
Entry string `json:"entry,omitempty"`
// Need and Avail are, on a no_space an upload or unzip saw coming, the bytes
// it needs and the bytes the volume has free. A listing sets Avail too.
// it needs and the bytes the volume has free. A listing sets Avail too, to
// -1 when it could not read it.
Need int64 `json:"need,omitempty"`
Avail int64 `json:"avail,omitempty"`
// Files and Bytes are what a successful unzip extracted.
@@ -239,6 +240,10 @@ type Upload struct {
// Open starts the transfer. It runs only once the target has passed every
// check, so a refused upload never pulls the bytes.
Open func() (io.ReadCloser, error)
// Landed, when set, runs once the bytes are in place, so felis-api can let
// go of the copy it staged (Stage.Landed). Until then felis-api keeps it,
// and a failed landing is started again without the bytes being sent again.
Landed func()
}
// Execute performs one operation inside root and returns the Result to print.
@@ -346,7 +351,9 @@ func list(r *os.Root, rootPath, path string) Result {
}
entries = append(entries, e)
}
res := Result{Entries: entries, Truncated: truncated}
// A full volume is Avail 0, which the JSON leaves out; -1 is a volume whose
// free space could not be read, so the two stay apart.
res := Result{Entries: entries, Truncated: truncated, Avail: -1}
if avail, _, err := statfs(rootPath); err == nil {
res.Avail = int64(min(avail, math.MaxInt64))
}
@@ -557,6 +564,10 @@ type transferError struct{ err error }
func (e *transferError) Error() string { return "fetch upload: " + e.err.Error() }
func (e *transferError) Unwrap() error { return e.err }
// NameMax is the longest name a folder entry can have on the volumes a world
// lives on (NAME_MAX).
const NameMax = 255
// land atomically puts the bytes fill writes at target, the path landingTarget
// returned for name. Write and upload both land through it; unzip lands a whole
// tree at once and has its own path (unzip.go).
@@ -567,8 +578,9 @@ func (e *transferError) Unwrap() error { return e.err }
// server.properties an in-place truncate would, which is a server that no longer
// boots. The sibling gets mode and is handed to the game uid before the rename, so
// the file the server finds is never root's. On failure it is removed; only a kill
// between create and rename leaves one behind, named ".<file>.felis-edit-<hex>" so
// no loader mistakes it for a plugin jar or a config.
// between create and rename leaves one behind, named ".felis-edit-<hex>" so no
// loader mistakes it for a plugin jar or a config. The name is its own rather than
// the target's with a suffix, so a target named up to NameMax bytes can be written.
//
// A *transferError from fill comes back as the error; every other failure is a
// Result.
@@ -577,7 +589,7 @@ func land(r *os.Root, name, target string, mode fs.FileMode, fill func(io.Writer
if _, err := rand.Read(suffix[:]); err != nil {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}, nil
}
tmp := path.Join(path.Dir(target), "."+path.Base(target)+".felis-edit-"+hex.EncodeToString(suffix[:]))
tmp := path.Join(path.Dir(target), ".felis-edit-"+hex.EncodeToString(suffix[:]))
f, err := r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return writeFailure(err, name), nil
@@ -652,7 +664,7 @@ func upload(r *os.Root, rootPath, name string, u Upload, overwrite bool, progres
return Result{Code: CodeNoSpace, Need: u.Size, Avail: int64(min(avail, math.MaxInt64)), Error: fmt.Sprintf(
"%s is %d bytes and the server's volume has %d free; nothing was changed", name, u.Size, avail)}, nil
}
return land(r, name, target, mode, func(w io.Writer) error {
res, err := land(r, name, target, mode, func(w io.Writer) error {
body, err := u.Open()
if err != nil {
return &transferError{err}
@@ -677,6 +689,10 @@ func upload(r *os.Root, rootPath, name string, u Upload, overwrite bool, progres
}
return nil
})
if err == nil && res.Code == "" && u.Landed != nil {
u.Landed()
}
return res, err
}
// sourceReader tags the source's read errors as transfer errors, so land can tell
+20 -3
View File
@@ -198,10 +198,15 @@ func TestExecuteHappyPath(t *testing.T) {
t.Fatalf("avail = %d, want it clamped to %d", res.Avail, int64(math.MaxInt64))
}
statfs = func(string) (uint64, uint64, error) { return 0, 99999, nil }
if res, _ := run(root, OpList, "config", nil, ""); res.Avail != 0 {
t.Fatalf("avail = %d on a full volume, want 0", res.Avail)
}
statfs = func(string) (uint64, uint64, error) { return 1, 1, errors.New("no statfs") }
res, err = run(root, OpList, "config", nil, "")
if err != nil || res.Code != "" || len(res.Entries) != 1 || res.Avail != 0 {
t.Fatalf("a volume that cannot be measured still lists, with no room reported: %v %+v", err, res)
if err != nil || res.Code != "" || len(res.Entries) != 1 || res.Avail != -1 {
t.Fatalf("a volume that cannot be measured still lists, with its room -1 (unknown): %v %+v", err, res)
}
})
@@ -239,7 +244,7 @@ func TestExecuteHappyPath(t *testing.T) {
if res, err := run(root, OpWrite, "ops.json", []byte("[]"), ""); err != nil || res.Code != "" {
t.Fatalf("creating a new file should succeed: %v / %+v", err, res)
}
if len(owned) != 1 || !strings.HasPrefix(owned[0], ".ops.json.felis-edit-") {
if len(owned) != 1 || !strings.HasPrefix(owned[0], ".felis-edit-") {
t.Errorf("files handed to the game uid = %v, want the one temporary sibling of ops.json", owned)
}
res, err := run(root, OpWrite, "nope/deep.txt", []byte("x"), "")
@@ -537,6 +542,18 @@ func TestWriteIsAtomic(t *testing.T) {
assertNoTemporaries(t, root)
})
t.Run("a name as long as a folder allows is written", func(t *testing.T) {
long := strings.Repeat("n", NameMax-4) + ".yml"
res, err := Execute(root, Request{Op: OpWrite, Path: "config/" + long, Content: []byte("a: 1\n"), CreateOnly: true})
if err != nil || res.Code != "" {
t.Fatalf("write a %d-byte name: %v / %+v", len(long), err, res)
}
if b, _ := os.ReadFile(filepath.Join(root, "config", long)); string(b) != "a: 1\n" {
t.Fatalf("content = %q", b)
}
assertNoTemporaries(t, filepath.Join(root, "config"))
})
t.Run("a link inside the root is written through, not replaced", func(t *testing.T) {
if err := os.Symlink("config/paper.yml", filepath.Join(root, "paper-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
+30 -6
View File
@@ -246,10 +246,11 @@ func (k *K8sRunner) Ops(ctx context.Context, namespace, server string) ([]OpStat
out := make([]OpState, 0, len(items))
for i := range items {
log := ""
if pod := podOf[items[i].Labels[LabelOpID]]; pod != nil && pod.Status.Phase != corev1.PodPending {
pod := podOf[items[i].Labels[LabelOpID]]
if pod != nil && pod.Status.Phase != corev1.PodPending {
log, _ = k.logTail(ctx, namespace, pod.Name)
}
out = append(out, opState(&items[i], log))
out = append(out, opState(&items[i], pod, log))
}
return out, nil
}
@@ -272,9 +273,11 @@ func (k *K8sRunner) logTail(ctx context.Context, namespace, pod string) (string,
// OpState. The printed result decides the outcome whatever the Job's condition
// says: a Job killed at its deadline just after printing did finish its work.
// A Job that ended without one failed, and the condition's reason says how
// (DeadlineExceeded, BackoffLimitExceeded); a Job that completed but whose log
// could not be read has an outcome no one can tell, ResultUnavailable.
func opState(job *batchv1.Job, log string) OpState {
// (DeadlineExceeded, BackoffLimitExceeded), unless its Pod says the kernel
// killed it for memory (ReasonOOMKilled), which the condition does not tell; a
// Job that completed but whose log could not be read has an outcome no one can
// tell, ResultUnavailable.
func opState(job *batchv1.Job, pod *corev1.Pod, log string) OpState {
st := OpState{
ID: job.Labels[LabelOpID], Op: job.Labels[LabelMode], Path: job.Annotations[AnnotationPath],
State: OpRunning, Started: job.CreationTimestamp.Time,
@@ -312,9 +315,30 @@ func opState(job *batchv1.Job, log string) OpState {
st.State = OpFailed
default:
st.State, st.Reason = OpFailed, reason
if st.Reason == "" {
if killedForMemory(pod) {
st.Reason = ReasonOOMKilled
} else if st.Reason == "" {
st.Reason = "Failed"
}
}
return st
}
// ReasonOOMKilled is an op's Reason when the kernel killed its Job for going over
// the Job's memory limit: an archive of more entries than the Job can hold the
// list of.
const ReasonOOMKilled = "OOMKilled"
// killedForMemory reports whether pod's container was killed for going over its
// memory limit.
func killedForMemory(pod *corev1.Pod) bool {
if pod == nil {
return false
}
for _, cs := range pod.Status.ContainerStatuses {
if t := cs.State.Terminated; cs.Name == containerName && t != nil && t.Reason == ReasonOOMKilled {
return true
}
}
return false
}
+42 -1
View File
@@ -45,10 +45,17 @@ func TestOpState(t *testing.T) {
ok := ResultPrefix + `{"files":3,"bytes":40}` + "\n"
conflict := ResultPrefix + `{"code":"exists","conflicts":["a.txt"],"conflict_count":1}` + "\n"
complete := cond(batchv1.JobComplete, corev1.ConditionTrue, "")
backoff := cond(batchv1.JobFailed, corev1.ConditionTrue, "BackoffLimitExceeded")
killed := func(container, reason string) *corev1.Pod {
return &corev1.Pod{Status: corev1.PodStatus{Phase: corev1.PodFailed, ContainerStatuses: []corev1.ContainerStatus{{
Name: container, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 137, Reason: reason}},
}}}}
}
cases := []struct {
name string
conds []batchv1.JobCondition
pod *corev1.Pod
log string
state string
reason string
@@ -90,10 +97,22 @@ func TestOpState(t *testing.T) {
{name: "complete with a result that does not parse",
conds: []batchv1.JobCondition{complete}, log: ResultPrefix + "{\n",
state: OpFailed, reason: "ResultUnavailable", finished: true},
{name: "killed for memory without a result",
conds: []batchv1.JobCondition{backoff}, pod: killed(containerName, "OOMKilled"), log: progress,
state: OpFailed, reason: "OOMKilled", done: 40, finished: true},
{name: "killed for memory after printing a clean result",
conds: []batchv1.JobCondition{backoff}, pod: killed(containerName, "OOMKilled"), log: ok,
state: OpSucceeded, files: 3, finished: true, wantResult: true},
{name: "killed another way",
conds: []batchv1.JobCondition{backoff}, pod: killed(containerName, "Error"),
state: OpFailed, reason: "BackoffLimitExceeded", finished: true},
{name: "another container killed for memory",
conds: []batchv1.JobCondition{backoff}, pod: killed("sidecar", "OOMKilled"),
state: OpFailed, reason: "BackoffLimitExceeded", finished: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st := opState(asyncJob(tc.conds...), tc.log)
st := opState(asyncJob(tc.conds...), tc.pod, tc.log)
if st.ID != "0a" || st.Op != OpUnzip || st.Path != "maps/world.zip" || !st.Started.Equal(opCreated) {
t.Fatalf("identity = %q %q %q %v", st.ID, st.Op, st.Path, st.Started)
}
@@ -186,6 +205,28 @@ func TestK8sRunnerOps(t *testing.T) {
}
}
// TestK8sRunnerOpsReadsAMemoryKill checks Ops hands each Job's Pod to opState: a
// Pod the kernel killed for memory is what names an op's reason OOMKilled.
func TestK8sRunnerOpsReadsAMemoryKill(t *testing.T) {
p := testParams(OpUnzip)
p.Server, p.OpID, p.Path, p.Async = "survival", "oom", "maps/tiles.zip", true
j, err := FilesJob(p)
if err != nil {
t.Fatal(err)
}
j.Status.Conditions = []batchv1.JobCondition{cond(batchv1.JobFailed, corev1.ConditionTrue, "BackoffLimitExceeded")}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{Name: j.Name + "-x", Namespace: "minecraft", Labels: j.Spec.Template.Labels},
Status: corev1.PodStatus{Phase: corev1.PodFailed, ContainerStatuses: []corev1.ContainerStatus{{
Name: containerName, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 137, Reason: "OOMKilled"}},
}}},
}
ops, err := NewK8sRunner(fake.NewSimpleClientset(j, pod)).Ops(context.Background(), "minecraft", "survival")
if err != nil || len(ops) != 1 || ops[0].State != OpFailed || ops[0].Reason != "OOMKilled" {
t.Fatalf("Ops = %+v, %v; want the one op failed for OOMKilled", ops, err)
}
}
// TestK8sRunnerOpsFailsLoudly checks a listing the cluster refused is an error,
// never an empty list that would read as nothing running.
func TestK8sRunnerOpsFailsLoudly(t *testing.T) {
+26 -13
View File
@@ -397,16 +397,18 @@ func TestRename(t *testing.T) {
})
}
// fakeSource is an upload's bytes as the Job would fetch them.
// fakeSource is an upload's bytes as the Job would fetch them. landed counts
// the reports that they are in place.
type fakeSource struct {
body string
opened int
landed int
err error // returned by Open
readErr error // returned by the body once it runs out
}
func (s *fakeSource) upload(size int64, sum string) *Upload {
return &Upload{Size: size, SHA256: sum, Open: func() (io.ReadCloser, error) {
return &Upload{Size: size, SHA256: sum, Landed: func() { s.landed++ }, Open: func() (io.ReadCloser, error) {
s.opened++
if s.err != nil {
return nil, s.err
@@ -434,9 +436,17 @@ func TestUpload(t *testing.T) {
t.Run("lands the bytes as a new file", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "config/Geyser.jar", whole(src), false)
if err != nil || res.Code != "" {
t.Fatalf("result = %+v, %v", res, err)
u := whole(src)
// Reported once the file is in place, never before.
var there string
u.Landed = func() {
src.landed++
b, _ := os.ReadFile(filepath.Join(root, "config", "Geyser.jar"))
there = string(b)
}
res, err := send(t, root, "config/Geyser.jar", u, false)
if err != nil || res.Code != "" || src.landed != 1 || there != jar {
t.Fatalf("result = %+v, %v; landed %d with %q in place", res, err, src.landed, there)
}
if got := mustRead(t, filepath.Join(root, "config", "Geyser.jar")); got != jar {
t.Fatalf("content = %q", got)
@@ -454,8 +464,8 @@ func TestUpload(t *testing.T) {
for _, name := range []string{"server.properties", "dangling.jar"} {
src := &fakeSource{body: jar}
res, err := send(t, root, name, whole(src), false)
if err != nil || res.Code != CodeExists || src.opened != 0 {
t.Fatalf("%s: result = %+v, %v, opened %d; want exists and no fetch", name, res, err, src.opened)
if err != nil || res.Code != CodeExists || src.opened != 0 || src.landed != 0 {
t.Fatalf("%s: result = %+v, %v, opened %d, landed %d; want exists and no fetch", name, res, err, src.opened, src.landed)
}
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
@@ -541,10 +551,11 @@ func TestUpload(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
root, _ := worldRoot(t)
res, err := send(t, root, "server.properties", u(&fakeSource{body: jar}), true)
src := &fakeSource{body: jar}
res, err := send(t, root, "server.properties", u(src), true)
var te *transferError
if !errors.As(err, &te) || res.Code != "" {
t.Fatalf("result = %+v, err = %v; want a transfer error", res, err)
if !errors.As(err, &te) || res.Code != "" || src.landed != 0 {
t.Fatalf("result = %+v, err = %v, landed %d; want a transfer error and no report", res, err, src.landed)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
@@ -559,9 +570,11 @@ func TestUpload(t *testing.T) {
prev := syncWritten
syncWritten = func(*os.File) error { return syscall.ENOSPC }
defer func() { syncWritten = prev }()
res, err := send(t, root, "server.properties", whole(&fakeSource{body: jar}), true)
if err != nil || res.Code != CodeNoSpace {
t.Fatalf("result = %+v, %v; want no_space", res, err)
src := &fakeSource{body: jar}
res, err := send(t, root, "server.properties", whole(src), true)
// The bytes were fetched but never landed, so felis-api keeps them.
if err != nil || res.Code != CodeNoSpace || src.opened != 1 || src.landed != 0 {
t.Fatalf("result = %+v, %v, opened %d, landed %d; want no_space after a fetch and no report", res, err, src.opened, src.landed)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
+64 -20
View File
@@ -4,11 +4,13 @@ import (
"crypto/sha256"
"crypto/subtle"
"encoding"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"slices"
"time"
)
@@ -23,11 +25,18 @@ import (
// Every call names the user and the server the session was begun for, and a
// session answers no one else: an id that is someone else's reads as unknown.
//
// A part that fails midway (the connection dropped, the edge cut it off) is
// rolled back to where it started, so the session's length is always the resume
// point. A sealed session stays until it has been served whole once (Served), so
// a Job that failed before it had every byte can be started again without the
// file being sent again; one left idle for SessionIdle is dropped (Expire).
// Each part carries the SHA-256 the client computed over it, and a part whose
// bytes hash to anything else was changed on the way and is refused. A part that
// fails midway (the connection dropped, the edge cut it off, the digest did not
// match) is rolled back to where it started, so the session's length is always
// the resume point. The session keeps each part's size and digest, so a client
// resuming with a file from disk can check the file still holds the bytes
// already sent before it sends the rest.
//
// A sealed session stays until the Job that fetched it says its file has landed
// (Landed), so a Job that failed at any point before that (a broken fetch, a
// full volume, a file in the way) can be started again without the file being
// sent again; one left idle for SessionIdle is dropped (Expire).
// PartBytes is the largest part Append takes, matching the modpack upload's
// parts (submit.DefaultPartMaxBytes).
@@ -71,6 +80,15 @@ type Session struct {
Path string
Size int64
Received int64
// Parts are the parts that make up Received, in order.
Parts []Part
}
// Part is one part a session took: its length and the SHA-256 (lowercase hex)
// it arrived with and matched.
type Part struct {
Size int64
SHA256 string
}
type session struct {
@@ -78,11 +96,13 @@ type session struct {
file string
size, received int64
h hash.Hash
parts []Part
busy bool
touched time.Time
// armed is set by Seal with the digest of the token it minted and cleared by
// the Open that spends it.
// the Open that spends it. tokenHash stays until the next Seal, so the Job
// holding that token can still report its file landed (Landed).
armed bool
tokenHash [sha256.Size]byte
}
@@ -150,7 +170,7 @@ func (s *Stage) lookup(user, server, id string) (*session, error) {
}
func (ss *session) view(id string) Session {
return Session{ID: id, Path: ss.path, Size: ss.size, Received: ss.received}
return Session{ID: id, Path: ss.path, Size: ss.size, Received: ss.received, Parts: slices.Clone(ss.parts)}
}
// Status reports where the caller's session stands.
@@ -166,12 +186,16 @@ func (s *Stage) Status(user, server, id string) (Session, error) {
// Append adds the n bytes of body at offset, which must be where the session
// ends. body must end right after them (an HTTP body of that Content-Length
// does). On any failure the session is left as it was before the call.
func (s *Stage) Append(user, server, id string, offset int64, body io.Reader, n int64) (Session, error) {
// does), and they must hash to want, the SHA-256 the client computed over them
// (ErrDigestMismatch otherwise). On any failure the session is left as it was
// before the call.
func (s *Stage) Append(user, server, id string, offset int64, body io.Reader, n int64, want []byte) (Session, error) {
s.mu.Lock()
ss, err := s.lookup(user, server, id)
switch {
case err != nil:
case len(want) != sha256.Size:
err = ErrNoDigest
case ss.busy:
err = ErrUploadBusy
case offset != ss.received:
@@ -195,7 +219,11 @@ func (s *Stage) Append(user, server, id string, offset int64, body io.Reader, n
// touched without the lock. The hash's state is kept to undo a failed part.
before, err := ss.h.(encoding.BinaryMarshaler).MarshalBinary()
if err == nil {
err = appendPart(ss.file, offset, body, n, ss.h)
part := sha256.New()
err = appendPart(ss.file, offset, body, n, io.MultiWriter(ss.h, part))
if err == nil {
err = checkDigest(part.Sum(nil), want)
}
if err != nil {
_ = os.Truncate(ss.file, offset)
_ = ss.h.(encoding.BinaryUnmarshaler).UnmarshalBinary(before)
@@ -211,12 +239,13 @@ func (s *Stage) Append(user, server, id string, offset int64, body io.Reader, n
}
ss.received += n
s.reserved -= n
ss.parts = append(ss.parts, Part{Size: n, SHA256: hex.EncodeToString(want)})
return ss.view(id), nil
}
// appendPart writes exactly n bytes of body at offset in the file named file,
// feeding them to h as well.
func appendPart(file string, offset int64, body io.Reader, n int64, h hash.Hash) error {
func appendPart(file string, offset int64, body io.Reader, n int64, h io.Writer) error {
f, err := os.OpenFile(file, os.O_WRONLY, 0)
if err != nil {
return fmt.Errorf("fileedit: open the staged upload: %w", err)
@@ -269,19 +298,34 @@ func (s *Stage) openSession(id string, sum [sha256.Size]byte) (path string, size
return ss.file, ss.size, true, nil
}
// Served tells the stage the session id was sent whole to the Job that opened
// it, and deletes it: its bytes are on the Job's side now. An id that names no
// session (an upload staged by Put, which its own release deletes) is ignored.
func (s *Stage) Served(id string) {
// Landed tells the stage the Job holding token has put session id's file in
// place, and deletes the session: nothing will fetch it again. Only the token
// the latest Seal minted says so, spent or not, so a Job an earlier commit
// started, or anyone else, changes nothing (ErrNotStaged). An upload staged by
// Put answers to its own token too and is left to the release that deletes it.
func (s *Stage) Landed(id, token string) error {
sum := sha256.Sum256([]byte(token))
s.mu.Lock()
ss, ok := s.sessions[id]
if ok {
delete(s.sessions, id)
if !ok {
it, found := s.items[id]
s.mu.Unlock()
if !found || subtle.ConstantTimeCompare(sum[:], it.tokenHash[:]) != 1 {
return ErrNotStaged
}
return nil
}
// Before the first Seal tokenHash is zero, which no token hashes to. A sealed
// session has every byte, so a part arriving now could only be an empty one
// and leaves nothing to account for: no busy check, unlike Drop.
if subtle.ConstantTimeCompare(sum[:], ss.tokenHash[:]) != 1 {
s.mu.Unlock()
return ErrNotStaged
}
s.dropLocked(id, ss)
s.mu.Unlock()
if ok {
os.Remove(ss.file)
}
os.Remove(ss.file)
return nil
}
// Drop cancels the caller's session and deletes what it holds.
+101 -26
View File
@@ -5,6 +5,7 @@ import (
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -31,7 +32,7 @@ func diskStage(t *testing.T, free, total uint64, minFree float64) *Stage {
}
func appendString(s *Stage, user, server, id string, offset int64, part string) (Session, error) {
return s.Append(user, server, id, offset, strings.NewReader(part), int64(len(part)))
return s.Append(user, server, id, offset, strings.NewReader(part), int64(len(part)), sumOf(part))
}
func readStaged(t *testing.T, s *Stage, id, token string) string {
@@ -51,8 +52,9 @@ func readStaged(t *testing.T, s *Stage, id, token string) string {
return string(b)
}
// TestSessionArrivesInParts: parts land in order, Seal hands the Job a token for
// exactly those bytes, and Served deletes them.
// TestSessionArrivesInParts: parts land in order and are listed with their
// digests, Seal hands the Job a token for exactly those bytes, and they stay
// until that Job reports them landed.
func TestSessionArrivesInParts(t *testing.T) {
s := roomyStage(t)
const whole = "PK\x03\x04 first part, second part"
@@ -60,19 +62,24 @@ func TestSessionArrivesInParts(t *testing.T) {
if err != nil {
t.Fatalf("Begin: %v", err)
}
if !hexID.MatchString(sess.ID) || sess != (Session{ID: sess.ID, Path: "plugins/big.jar", Size: int64(len(whole))}) {
if !hexID.MatchString(sess.ID) || !reflect.DeepEqual(sess, Session{ID: sess.ID, Path: "plugins/big.jar", Size: int64(len(whole))}) {
t.Fatalf("Begin = %+v", sess)
}
got, err := appendString(s, "u1", "survival", sess.ID, 0, whole[:16])
if err != nil || got.Received != 16 || got.Size != int64(len(whole)) {
t.Fatalf("first part: %+v, %v", got, err)
}
if at, err := s.Status("u1", "survival", sess.ID); err != nil || at.Received != 16 || at.Path != "plugins/big.jar" {
first := Part{Size: 16, SHA256: digest([]byte(whole[:16]))}
if at, err := s.Status("u1", "survival", sess.ID); err != nil || at.Received != 16 || at.Path != "plugins/big.jar" ||
!reflect.DeepEqual(at.Parts, []Part{first}) {
t.Fatalf("Status = %+v, %v", at, err)
}
if got, err = appendString(s, "u1", "survival", sess.ID, 16, whole[16:]); err != nil || got.Received != int64(len(whole)) {
t.Fatalf("second part: %+v, %v", got, err)
}
if want := []Part{first, {Size: int64(len(whole) - 16), SHA256: digest([]byte(whole[16:]))}}; !reflect.DeepEqual(got.Parts, want) {
t.Fatalf("parts = %+v, want %+v", got.Parts, want)
}
st, err := s.Seal("u1", "survival", sess.ID)
if err != nil {
@@ -88,12 +95,28 @@ func TestSessionArrivesInParts(t *testing.T) {
t.Fatalf("second Open with the same token: err = %v, want ErrNotStaged", err)
}
s.Served(st.ID)
// Served whole, and still here: the Job has yet to check the bytes and put
// them in place, and a Job that fails at either is started again on them.
if at, err := s.Status("u1", "survival", sess.ID); err != nil || at.Received != int64(len(whole)) {
t.Fatalf("Status once served: %+v, %v", at, err)
}
if err := s.Landed(st.ID, strings.Repeat("0", 64)); !errors.Is(err, ErrNotStaged) {
t.Fatalf("Landed with a wrong token: err = %v, want ErrNotStaged", err)
}
if names := stagedNames(t, s); len(names) != 1 {
t.Fatalf("after a wrong token: %v", names)
}
if err := s.Landed(st.ID, st.Token); err != nil {
t.Fatalf("Landed: %v", err)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("after Served: %v", names)
t.Fatalf("after Landed: %v", names)
}
if _, err := s.Status("u1", "survival", sess.ID); !errors.Is(err, ErrNotStaged) {
t.Fatalf("Status after Served: err = %v, want ErrNotStaged", err)
t.Fatalf("Status after Landed: err = %v, want ErrNotStaged", err)
}
if err := s.Landed(st.ID, st.Token); !errors.Is(err, ErrNotStaged) {
t.Fatalf("Landed twice: err = %v, want ErrNotStaged", err)
}
}
@@ -149,7 +172,7 @@ func TestSessionRefusesAPartThatDoesNotFit(t *testing.T) {
if _, err := appendString(s, "u1", "survival", sess.ID, 3, "defg"); !errors.Is(err, ErrPartTooLarge) {
t.Fatalf("past the declared size: err = %v, want ErrPartTooLarge", err)
}
if _, err := s.Append("u1", "survival", sess.ID, 3, strings.NewReader(""), -1); !errors.Is(err, ErrPartTooLarge) {
if _, err := s.Append("u1", "survival", sess.ID, 3, strings.NewReader(""), -1, sumOf("")); !errors.Is(err, ErrPartTooLarge) {
t.Fatalf("negative length: err = %v, want ErrPartTooLarge", err)
}
if at, err := appendString(s, "u1", "survival", sess.ID, 3, "def"); err != nil || at.Received != 6 {
@@ -160,21 +183,24 @@ func TestSessionRefusesAPartThatDoesNotFit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err := s.Append("u1", "survival", big.ID, 0, strings.NewReader(""), PartBytes+1); !errors.Is(err, ErrPartTooLarge) {
if _, err := s.Append("u1", "survival", big.ID, 0, strings.NewReader(""), PartBytes+1, sumOf("")); !errors.Is(err, ErrPartTooLarge) {
t.Fatalf("a part over PartBytes: err = %v, want ErrPartTooLarge", err)
}
}
// A part that breaks or runs long leaves the session as it was: the file is cut
// back and the digest forgets it, so the resent part makes the right file.
// A part that breaks, runs long or does not match its digest leaves the session
// as it was: the file is cut back and the digest forgets it, so the resent part
// makes the right file.
func TestSessionRollsBackAFailedPart(t *testing.T) {
for name, tc := range map[string]struct {
body io.Reader
short bool
body io.Reader
want error // nil: any other failure
}{
"breaks": {io.MultiReader(strings.NewReader("XY"), errReader{io.ErrUnexpectedEOF}), true},
"ends": {strings.NewReader("XY"), true},
"runs long": {strings.NewReader("XYZWV"), false},
"breaks": {io.MultiReader(strings.NewReader("XY"), errReader{io.ErrUnexpectedEOF}), ErrShortUpload},
"ends": {strings.NewReader("XY"), ErrShortUpload},
"runs long": {strings.NewReader("XYZWV"), nil},
// Four bytes, as declared, that are not the four the digest was made of.
"changed on the way": {strings.NewReader("dXfg"), ErrDigestMismatch},
} {
t.Run(name, func(t *testing.T) {
s := roomyStage(t)
@@ -185,9 +211,13 @@ func TestSessionRollsBackAFailedPart(t *testing.T) {
if _, err := appendString(s, "u1", "survival", sess.ID, 0, "abc"); err != nil {
t.Fatal(err)
}
at, err := s.Append("u1", "survival", sess.ID, 3, tc.body, 4)
if err == nil || errors.Is(err, ErrShortUpload) != tc.short || at.Received != 3 {
t.Fatalf("%+v, err = %v; want a failure at 3 (short = %v)", at, err, tc.short)
at, err := s.Append("u1", "survival", sess.ID, 3, tc.body, 4, sumOf("defg"))
kind := tc.want
if kind == nil {
kind = ErrShortUpload // must not be it
}
if err == nil || errors.Is(err, kind) != (tc.want != nil) || at.Received != 3 || len(at.Parts) != 1 {
t.Fatalf("%+v, err = %v; want a failure at 3 (%v)", at, err, tc.want)
}
info, err := os.Stat(filepath.Join(s.Dir, stagedNames(t, s)[0]))
if err != nil || info.Size() != 3 {
@@ -220,7 +250,7 @@ func TestSessionIsBusyWhileAPartArrives(t *testing.T) {
pr, pw := io.Pipe()
done := make(chan error, 1)
go func() {
_, err := s.Append("u1", "survival", sess.ID, 0, pr, 4)
_, err := s.Append("u1", "survival", sess.ID, 0, pr, 4, sumOf("abcd"))
done <- err
}()
// The write returns once Append is copying, which is after it marked busy.
@@ -309,16 +339,61 @@ func TestSessionSealArmsOneFetch(t *testing.T) {
}
}
// Served deletes only sessions: an upload staged by Put belongs to the release
// func Put returned.
func TestServedLeavesPutAlone(t *testing.T) {
// Only the token the latest Seal minted reports a session landed: a Job an
// earlier commit started changes nothing, and neither does anyone before the
// first Seal.
func TestLandedTakesTheLatestToken(t *testing.T) {
s := roomyStage(t)
st, release, err := s.Put(strings.NewReader("abc"), 3)
sess, err := s.Begin("u1", "survival", "a.zip", 4)
if err != nil {
t.Fatal(err)
}
if _, err := appendString(s, "u1", "survival", sess.ID, 0, "abcd"); err != nil {
t.Fatal(err)
}
if err := s.Landed(sess.ID, ""); !errors.Is(err, ErrNotStaged) {
t.Fatalf("Landed before any Seal: err = %v, want ErrNotStaged", err)
}
first, err := s.Seal("u1", "survival", sess.ID)
if err != nil {
t.Fatal(err)
}
readStaged(t, s, sess.ID, first.Token)
second, err := s.Seal("u1", "survival", sess.ID)
if err != nil {
t.Fatal(err)
}
if err := s.Landed(sess.ID, first.Token); !errors.Is(err, ErrNotStaged) {
t.Fatalf("the replaced token: err = %v, want ErrNotStaged", err)
}
if _, err := s.Status("u1", "survival", sess.ID); err != nil {
t.Fatalf("after the replaced token: %v", err)
}
// Not yet fetched with it, and it still says so: the Job holds the token
// whatever became of its fetch.
if err := s.Landed(sess.ID, second.Token); err != nil {
t.Fatalf("the latest token: %v", err)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("after Landed: %v", names)
}
}
// An upload staged by Put answers Landed to its own token and is left to the
// release func Put returned.
func TestLandedLeavesPutAlone(t *testing.T) {
s := roomyStage(t)
st, release, err := s.Put(strings.NewReader("abc"), 3, sumOf("abc"))
if err != nil {
t.Fatal(err)
}
defer release()
s.Served(st.ID)
if err := s.Landed(st.ID, strings.Repeat("0", 64)); !errors.Is(err, ErrNotStaged) {
t.Fatalf("a wrong token: err = %v, want ErrNotStaged", err)
}
if err := s.Landed(st.ID, st.Token); err != nil {
t.Fatalf("Landed: %v", err)
}
if body := readStaged(t, s, st.ID, st.Token); body != "abc" {
t.Fatalf("served %q", body)
}
+27 -2
View File
@@ -79,8 +79,22 @@ var (
// ErrNotStaged is an Open with an unknown id, a wrong token, or a spent one.
// They are one error on purpose: the internal face answers all three the same.
ErrNotStaged = errors.New("fileedit: no such staged upload")
// ErrNoDigest is bytes sent without the SHA-256 the client computed over
// them, so what arrived cannot be told apart from what was sent.
ErrNoDigest = errors.New("fileedit: the upload carries no SHA-256 digest")
// ErrDigestMismatch is bytes that do not hash to the digest they were sent
// with: they were changed on the way.
ErrDigestMismatch = errors.New("fileedit: the bytes that arrived do not match the digest they were sent with")
)
// checkDigest compares the digest of what arrived with the one it was sent with.
func checkDigest(got, want []byte) error {
if subtle.ConstantTimeCompare(got, want) != 1 {
return fmt.Errorf("%w: they hash to %x, sent as %x", ErrDigestMismatch, got, want)
}
return nil
}
// statfs reports a filesystem's available and total bytes. A var so a test can
// stage against a disk of a chosen size.
var statfs = func(dir string) (avail, total uint64, err error) {
@@ -104,10 +118,17 @@ func (s *Stage) Sweep() error {
// it by, plus the func that deletes it. body must end right after size bytes (an
// HTTP body with that Content-Length does): Put reads to its end, which is also
// what tells the server the body is done.
func (s *Stage) Put(body io.Reader, size int64) (Staged, func(), error) {
//
// want is the SHA-256 the client computed over the bytes it sent (the request's
// Content-Digest). Bytes that hash to anything else were changed on the way and
// are refused with ErrDigestMismatch; nothing is staged without one.
func (s *Stage) Put(body io.Reader, size int64, want []byte) (Staged, func(), error) {
if size < 0 {
return Staged{}, nil, fmt.Errorf("fileedit: an upload of %d bytes", size)
}
if len(want) != sha256.Size {
return Staged{}, nil, ErrNoDigest
}
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
return Staged{}, nil, fmt.Errorf("fileedit: create the upload stage: %w", err)
}
@@ -125,7 +146,11 @@ func (s *Stage) Put(body io.Reader, size int64) (Staged, func(), error) {
// One byte past size, so the read that finds the end happens here.
n, copyErr := io.Copy(io.MultiWriter(f, h), io.LimitReader(src, size+1))
closeErr := f.Close()
if err := stageFailure(src.err, copyErr, closeErr, n, size); err != nil {
err = stageFailure(src.err, copyErr, closeErr, n, size)
if err == nil {
err = checkDigest(h.Sum(nil), want)
}
if err != nil {
os.Remove(f.Name())
return Staged{}, nil, err
}
+51 -12
View File
@@ -1,6 +1,7 @@
package fileedit
import (
"crypto/sha256"
"errors"
"io"
"os"
@@ -39,6 +40,12 @@ func stagedNames(t *testing.T, s *Stage) []string {
return names
}
// sumOf is the SHA-256 a client sends with s.
func sumOf(s string) []byte {
sum := sha256.Sum256([]byte(s))
return sum[:]
}
var hexID = regexp.MustCompile(`^[0-9a-f]{32}$`)
var hexToken = regexp.MustCompile(`^[0-9a-f]{64}$`)
@@ -47,7 +54,7 @@ var hexToken = regexp.MustCompile(`^[0-9a-f]{64}$`)
func TestStageOpensOnce(t *testing.T) {
s := roomyStage(t)
const body = "PK\x03\x04 staged"
st, release, err := s.Put(strings.NewReader(body), int64(len(body)))
st, release, err := s.Put(strings.NewReader(body), int64(len(body)), sumOf(body))
if err != nil {
t.Fatalf("Put: %v", err)
}
@@ -55,7 +62,7 @@ func TestStageOpensOnce(t *testing.T) {
st.Size != int64(len(body)) || st.SHA256 != digest([]byte(body)) {
t.Fatalf("staged = %+v", st)
}
other, releaseOther, err := s.Put(strings.NewReader(body), int64(len(body)))
other, releaseOther, err := s.Put(strings.NewReader(body), int64(len(body)), sumOf(body))
if err != nil {
t.Fatalf("Put: %v", err)
}
@@ -104,7 +111,7 @@ func TestStageOpensOnce(t *testing.T) {
// is readable by anyone but felis-api's own uid.
func TestStageIsPrivate(t *testing.T) {
s := roomyStage(t)
_, release, err := s.Put(strings.NewReader("x"), 1)
_, release, err := s.Put(strings.NewReader("x"), 1, sumOf("x"))
if err != nil {
t.Fatalf("Put: %v", err)
}
@@ -120,13 +127,14 @@ func TestStageIsPrivate(t *testing.T) {
}
}
// eofReader serves body and records whether it was read to its end.
// eofReader serves r and records whether it was read at all, and to its end.
type eofReader struct {
r io.Reader
hitEOF bool
r io.Reader
read, hitEOF bool
}
func (e *eofReader) Read(p []byte) (int, error) {
e.read = true
n, err := e.r.Read(p)
if err == io.EOF {
e.hitEOF = true
@@ -140,7 +148,7 @@ func (e *eofReader) Read(p []byte) (int, error) {
func TestStagePutReadsToTheEnd(t *testing.T) {
s := roomyStage(t)
body := &eofReader{r: strings.NewReader("abc")}
_, release, err := s.Put(body, 3)
_, release, err := s.Put(body, 3, sumOf("abc"))
if err != nil {
t.Fatalf("Put: %v", err)
}
@@ -150,6 +158,37 @@ func TestStagePutReadsToTheEnd(t *testing.T) {
}
}
// Bytes that do not hash to the digest they came with were changed on the way:
// nothing is staged and their room is given back. Without a digest the body is
// not read at all.
func TestStageChecksTheDigest(t *testing.T) {
stubStatfs(t, 1000, 1200) // floor 600 at MinFree 0.5: room for 400
s := &Stage{Dir: t.TempDir(), MinFree: 0.5}
body := strings.Repeat("x", 400)
_, _, err := s.Put(strings.NewReader(body), 400, sumOf(strings.Repeat("y", 400)))
if !errors.Is(err, ErrDigestMismatch) {
t.Fatalf("a wrong digest: err = %v, want ErrDigestMismatch", err)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("left behind: %v", names)
}
st, release, err := s.Put(strings.NewReader(body), 400, sumOf(body))
if err != nil {
t.Fatalf("the same bytes with their digest, in the room the refused ones held: %v", err)
}
defer release()
if st.SHA256 != digest([]byte(body)) {
t.Fatalf("staged %+v", st)
}
for name, want := range map[string][]byte{"none": nil, "too short": sumOf("x")[:31]} {
unread := &eofReader{r: strings.NewReader("abc")}
if _, _, err := roomyStage(t).Put(unread, 3, want); !errors.Is(err, ErrNoDigest) || unread.read {
t.Errorf("%s: err = %v, body read = %v; want ErrNoDigest before any read", name, err, unread.read)
}
}
}
func TestStageRefusesABodyOfTheWrongLength(t *testing.T) {
for name, tc := range map[string]struct {
body io.Reader
@@ -164,7 +203,7 @@ func TestStageRefusesABodyOfTheWrongLength(t *testing.T) {
} {
t.Run(name, func(t *testing.T) {
s := roomyStage(t)
_, release, err := s.Put(tc.body, tc.size)
_, release, err := s.Put(tc.body, tc.size, sumOf(""))
if err == nil {
release()
t.Fatal("Put accepted it")
@@ -177,7 +216,7 @@ func TestStageRefusesABodyOfTheWrongLength(t *testing.T) {
}
})
}
if _, _, err := roomyStage(t).Put(strings.NewReader(""), -1); err == nil {
if _, _, err := roomyStage(t).Put(strings.NewReader(""), -1, sumOf("")); err == nil {
t.Fatal("a negative size was accepted")
}
}
@@ -186,7 +225,7 @@ func TestStageRefusesABodyOfTheWrongLength(t *testing.T) {
// MinFree of the disk free, counting uploads still arriving.
func TestStageKeepsItsFloor(t *testing.T) {
put := func(s *Stage, size int64) error {
_, release, err := s.Put(strings.NewReader(strings.Repeat("x", int(size))), size)
_, release, err := s.Put(strings.NewReader(strings.Repeat("x", int(size))), size, sumOf(strings.Repeat("x", int(size))))
if err == nil {
release()
}
@@ -231,7 +270,7 @@ func TestStageKeepsItsFloor(t *testing.T) {
pr, pw := io.Pipe()
done := make(chan error, 1)
go func() {
_, release, err := s.Put(pr, 300)
_, release, err := s.Put(pr, 300, sumOf(strings.Repeat("x", 300)))
if err == nil {
release()
}
@@ -271,7 +310,7 @@ func TestStageSweep(t *testing.T) {
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("after Sweep: %v", names)
}
if _, release, err := s.Put(strings.NewReader("x"), 1); err != nil {
if _, release, err := s.Put(strings.NewReader("x"), 1, sumOf("x")); err != nil {
t.Fatalf("Put after Sweep: %v", err)
} else {
release()
+33 -13
View File
@@ -40,8 +40,16 @@ const (
// MaxConflicts bounds Result.Conflicts, keeping the result line bounded when an
// archive would replace a whole world; ConflictCount still says how many there
// are.
const MaxConflicts = 200
// 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.
@@ -113,10 +121,12 @@ func unzip(r *os.Root, rootPath, name string, overwrite bool, progress func(done
list = append(list, path.Join(dest, n))
}
sort.Strings(list)
count := len(list)
if count > MaxConflicts {
list = list[:MaxConflicts]
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)}
}
@@ -180,7 +190,6 @@ type zipFile struct {
// makes. Nothing about the server is consulted yet.
func planUnzip(entries []*zip.File) (unzipPlan, Result) {
p := unzipPlan{isDir: map[string]bool{}}
byName := map[string]bool{}
for _, f := range entries {
raw := entryName(f)
name, ok := cleanEntry(raw)
@@ -210,14 +219,10 @@ func planUnzip(entries []*zip.File) (unzipPlan, Result) {
case name == ".":
return p, Result{Code: CodeArchiveUnsafe, Entry: raw, Error: fmt.Sprintf(
"%s names the destination folder itself", raw)}
case byName[name]:
return p, Result{Code: CodeArchiveInvalid, Entry: raw, Error: fmt.Sprintf(
"%s appears in the archive twice", 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)}
}
byName[name] = true
p.bytes += int64(f.UncompressedSize64)
// Anything the archive marks executable (a start.sh) stays executable;
// every other permission is the server's usual.
@@ -227,6 +232,15 @@ func planUnzip(entries []*zip.File) (unzipPlan, Result) {
}
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.
@@ -248,7 +262,6 @@ func planUnzip(entries []*zip.File) (unzipPlan, Result) {
}
// A folder's name is a prefix of everything in it, and a prefix sorts first.
sort.Strings(p.dirs)
sort.Slice(p.files, func(i, j int) bool { return p.files[i].name < p.files[j].name })
return p, Result{}
}
@@ -410,12 +423,19 @@ func extractOne(r *os.Root, at string, zf zipFile, count func(int)) Result {
// 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 {
kids[path.Dir(d)] = append(kids[path.Dir(d)], d)
add(d)
}
for _, zf := range p.files {
kids[path.Dir(zf.name)] = append(kids[path.Dir(zf.name)], zf.name)
add(zf.name)
}
type move struct{ from, to string }
+23
View File
@@ -3,6 +3,7 @@ package fileedit
import (
"archive/zip"
"bytes"
"encoding/json"
"errors"
"fmt"
"hash/crc32"
@@ -369,6 +370,28 @@ func TestUnzip(t *testing.T) {
}
})
// The result comes back through the tail of the Pod's log, where a line past
// 16 KiB is split and its head can fall out of the tail.
t.Run("the list stops at 8 KiB of names too, keeping the result line whole", func(t *testing.T) {
root, _ := worldRoot(t)
var entries []zent
for i := range 40 {
name := fmt.Sprintf("%02d", i) + strings.Repeat("n", 248) // 250 bytes
if err := os.WriteFile(filepath.Join(root, name), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
entries = append(entries, file(name, "new"))
}
writeZip(t, filepath.Join(root, "long.zip"), entries...)
res := unzipAt(t, root, "long.zip", false)
line, _ := json.Marshal(res)
// 32 names are 8000 bytes; a 33rd would pass 8192.
if res.Code != CodeExists || res.ConflictCount != 40 || len(res.Conflicts) != 32 ||
!strings.HasPrefix(res.Conflicts[31], "31n") || len(line) >= 16<<10 {
t.Fatalf("code %q, count %d, %d listed, result line %d bytes", res.Code, res.ConflictCount, len(res.Conflicts), len(line))
}
})
t.Run("overwrite replaces files, merges folders and keeps everything else", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.WriteFile(filepath.Join(root, "config", "keep.yml"), []byte("keep"), 0o644); err != nil {