fix(files): 模组包上传也逐段核对 SHA-256、长文件名能写、卡住的导出按时停掉、世界被占用时文件页说明原因并等它结束
This commit is contained in:
59 files changed
+2538
-338
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user