aboutsummaryrefslogtreecommitdiff
path: root/internal/apply/swap_test.go
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 13:53:01 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-17 13:53:01 +0200
commita80d470cbad7335fd5e534f32d935e5a49aadb24 (patch)
treec7d92ba026ebb90dfce4ffb8cb547a1db49812b2 /internal/apply/swap_test.go
parent78d8313791f05defc9e0a9f2bad8e9710f741a60 (diff)
downloadkrino-a80d470cbad7335fd5e534f32d935e5a49aadb24.tar.gz
krino-a80d470cbad7335fd5e534f32d935e5a49aadb24.zip
a file trashed to make room is logged the moment it is trashed
The displace was carried out first and logged only when the whole step finished - for a copy or a cross-device move, the entire data transfer later. A process killed in that window left the user's file in the Trash with nothing recording it: krino log said "nothing applied" and undo offered nothing. It is its own action with its own line (spec ยง9), so it is now written the moment trash.Put returns, through a hook ChainLogged calls before the step that needed the name begins. A displace that cannot be logged fails the step rather than compounding an unrecorded destructive act with a second one. Verified by killing krino -9 mid-copy with 600 MB in flight: before: krino log "nothing applied", 0 displace lines after: krino log "1 displaced", 1 displace line
Diffstat (limited to 'internal/apply/swap_test.go')
-rw-r--r--internal/apply/swap_test.go90
1 files changed, 88 insertions, 2 deletions
diff --git a/internal/apply/swap_test.go b/internal/apply/swap_test.go
index d16d3f9..297db72 100644
--- a/internal/apply/swap_test.go
+++ b/internal/apply/swap_test.go
@@ -4,6 +4,7 @@ package apply
import (
"context"
+ "errors"
"os"
"path/filepath"
"strings"
@@ -156,7 +157,7 @@ func TestChainLoggedReportsEachStepBeforeTheNext(t *testing.T) {
}
}
return nil
- })
+ }, nil)
if err != nil || len(calls) != 2 || res[1].Status != "ok" {
t.Fatalf("calls %v, err %v, results %+v", calls, err, res)
}
@@ -206,7 +207,7 @@ func TestChainLoggedStopsBetweenStepsWhenInterrupted(t *testing.T) {
cancel()
}
return nil
- })
+ }, nil)
if err != nil {
t.Fatal(err)
}
@@ -217,3 +218,88 @@ func TestChainLoggedStopsBetweenStepsWhenInterrupted(t *testing.T) {
t.Errorf("the finished step was undone or never ran: %v", err)
}
}
+
+// TestDisplaceIsReportedBeforeTheStepThatNeededIt: trashing the file in the
+// way is a destructive act of its own, and it is durable the moment
+// trash.Put returns. Reporting it only when the whole step finishes leaves
+// a window - the entire data transfer of a copy or a cross-device move -
+// in which the user's file is in the Trash with nothing recording that it
+// went there. Killed in that window, krino log said "nothing applied".
+func TestDisplaceIsReportedBeforeTheStepThatNeededIt(t *testing.T) {
+ root := t.TempDir()
+ // The Trash must be on the same filesystem as the file being trashed.
+ t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
+ src := filepath.Join(root, "a.pdf")
+ dst := filepath.Join(root, "W", "a.pdf")
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("the file already there"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := planned(t, root, "a.pdf", "incoming",
+ plan.Step{Kind: plan.Copy, Src: src, Dst: dst, Displaces: dst},
+ )
+
+ var order []string
+ res, err := ChainLogged(context.Background(), c,
+ func(i int, sr StepResult) error {
+ order = append(order, "step")
+ return nil
+ },
+ func(i int, step plan.Step, entry string) error {
+ order = append(order, "displace")
+ if entry == "" {
+ t.Error("the displace was reported with no trash entry")
+ }
+ // The file is in the Trash already; the copy has not begun.
+ if b, err := os.ReadFile(dst); err == nil && string(b) == "incoming" {
+ t.Error("the displace was reported only after the copy had run")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(order) != 2 || order[0] != "displace" || order[1] != "step" {
+ t.Errorf("order = %v; want the displace reported first", order)
+ }
+ if res[0].Status != "ok" {
+ t.Errorf("step = %+v", res[0])
+ }
+}
+
+// TestDisplaceThatCannotBeReportedFailsTheStep: if the displace cannot be
+// written to the log, the step must not go on to use the name - the user's
+// file is already in the Trash and nothing would record it.
+func TestDisplaceThatCannotBeReportedFailsTheStep(t *testing.T) {
+ root := t.TempDir()
+ t.Setenv("XDG_DATA_HOME", filepath.Join(root, "share"))
+ src := filepath.Join(root, "a.pdf")
+ dst := filepath.Join(root, "W", "a.pdf")
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("the file already there"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ c := planned(t, root, "a.pdf", "incoming",
+ plan.Step{Kind: plan.Copy, Src: src, Dst: dst, Displaces: dst},
+ )
+ res, err := ChainLogged(context.Background(), c, nil,
+ func(i int, step plan.Step, entry string) error {
+ return errors.New("log is closed")
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res[0].Status != "failed" || !strings.Contains(res[0].Detail, "could not be logged") {
+ t.Errorf("step = %+v; want a failure naming the unlogged displace", res[0])
+ }
+ if res[0].DisplacedEntry == "" {
+ t.Error("the failure does not carry the trash entry, so nothing can say where the file went")
+ }
+ if b, rerr := os.ReadFile(dst); rerr == nil && string(b) == "incoming" {
+ t.Error("the copy ran even though the displace could not be logged")
+ }
+}