// SPDX-License-Identifier: GPL-3.0-or-later // Package apply is the executor: it carries out one file's plan.Chain, // actually moving, copying, renaming, trashing or permanently deleting real // files. See docs/design.md §7.2 for the mechanism each action follows and // §7.4's last paragraph for the execution-time conflict re-check. package apply import ( "context" "errors" "fmt" "os" "path/filepath" "time" "git.labunix.xyz/krino/internal/plan" "git.labunix.xyz/krino/internal/scan" "git.labunix.xyz/krino/internal/trash" ) // StepResult is what happened to one step, in the order the executor ran // them. type StepResult struct { Step plan.Step Status string // "ok" | "failed" | "skipped" Detail string // the failure, or why it was skipped Dst string // where the file actually ended up (conflict names can change at execution time) Size int64 // of the file at Dst afterwards ModTime time.Time // of the file at Dst afterwards Entry string // trash entry name, for Trash steps; "" otherwise // DisplacedEntry is the trash entry name of the file this step // displaced; "" when none. Entry and DisplacedEntry describe two // different files: the one being acted on (Entry, only for a Trash-kind // step), and the one that was in this step's way and had to be trashed // first (DisplacedEntry, only when Displaces was set). Spec §7.4's // overwrite policy and §9's "displace" action both depend on this name // being recoverable — it is chosen inside trash.Put, so nothing // downstream of Chain could otherwise re-derive it for undo. DisplacedEntry string Made []string // directories this step created, outermost first } // Chain runs one file's steps in order and stops at the first failure, // marking the rest skipped. It never touches a file whose size or mtime no // longer matches what the plan recorded. It is ChainLogged with no done. func Chain(c plan.Chain) []StepResult { results, _ := ChainLogged(context.Background(), c, nil, nil) return results } // ChainLogged is Chain, calling done with each step's result as soon as // that step has run or been skipped, before the next one starts - so a // caller that logs from done never has a completed step missing from the // log when the process dies mid-chain. An error from done stops the chain // at once and is returned with the results so far. // // A move or rename that had to take a free name at apply time, because its // planned destination was taken since planning, stops the chain too: every // later step was planned against the name the file did not get, and must // not act on whatever is at that path. // // Once ctx is cancelled (an interrupt), the step already under way finishes // and every later step is skipped as "interrupted": an interrupt stops after // the current step, not after the file's whole chain (spec §11). func ChainLogged(ctx context.Context, c plan.Chain, done func(i int, sr StepResult) error, displaced func(i int, step plan.Step, entry string) error) ([]StepResult, error) { results := make([]StepResult, len(c.Steps)) stopWhy := "" for i, step := range c.Steps { if stopWhy == "" && ctx.Err() != nil { stopWhy = "interrupted" } switch { case stopWhy != "": results[i] = StepResult{Step: step, Status: "skipped", Detail: stopWhy} case step.Skip != "": // Planning already decided this step will not run; it must not // be attempted, so no pre-step check, no directory creation, no // touching the file (spec: a step already marked Skip is // reported, not attempted). results[i] = StepResult{Step: step, Status: "skipped", Detail: step.Skip} default: if err := checkUnchanged(step.Src, c.File); err != nil { results[i] = StepResult{Step: step, Status: "failed", Detail: err.Error()} stopWhy = "an earlier step in this chain failed" break } res := runStep(step, func(entry string) error { if displaced == nil { return nil } return displaced(i, step, entry) }) results[i] = res switch { case res.Status == "failed": stopWhy = "an earlier step in this chain failed" case (step.Kind == plan.Move || step.Kind == plan.Rename) && res.Dst != step.Dst: stopWhy = fmt.Sprintf("an earlier step put the file at %s, not the planned %s", res.Dst, step.Dst) } } if done != nil { if err := done(i, results[i]); err != nil { return results[:i+1], err } } } return results, nil } // checkUnchanged is the guard that matters most: before every step, the // source is Lstat'd and compared against what the plan recorded for the // whole file. A file rewritten or replaced between planning and applying // must never be acted on (spec §15.1): its size and mtime must match, it // must still be a regular file - not a symlink put in its place - and, at // its planned path, it must be the same inode. The inode is not compared // once an earlier step has moved the file: a move across filesystems // copies it to a new inode, and the chain is still following its own file. func checkUnchanged(src string, f scan.File) error { fi, err := os.Lstat(src) if err != nil { return fmt.Errorf("changed since plan: %w", err) } if !fi.Mode().IsRegular() { return errors.New("changed since plan: no longer a regular file") } if fi.Size() != f.Size || !fi.ModTime().Equal(f.ModTime) { return errors.New("changed since plan") } if src == f.Path && f.Ino != 0 { if now := scan.NewFile(src, f.Rel, fi); now.Dev != f.Dev || now.Ino != f.Ino { return errors.New("changed since plan: another file is in its place") } } return nil } // runStep dispatches one already-checked, non-skipped step to the code that // actually carries it out. reportDisplace is called the moment a displaced // file has reached the Trash, before the step that needed its name begins; // a step whose displace cannot be reported does not go on to use the name. func runStep(step plan.Step, reportDisplace func(entry string) error) StepResult { switch step.Kind { case plan.Copy, plan.Move, plan.Rename: return runFileStep(step, reportDisplace) case plan.Trash: return runTrashStep(step) case plan.DeletePermanent: return runDeleteStep(step) } panic(fmt.Sprintf("apply: unknown plan.Kind %d", int(step.Kind))) } // runFileStep carries out copy, move and rename. It re-checks the planned // destination against the filesystem as it is now (spec §7.4): if something // with Displaces set claims the file to trash first, that happens before // anything else, and if the displace fails nothing further is attempted for // this file. Otherwise, if the planned Dst now exists, the step moves to the // next free stem_N.ext and records the real name in Dst rather than // overwriting a file the plan never accounted for. Missing destination // directories are created and recorded in Made, outermost first, whether or // not the step that needed them goes on to succeed. // // The displace is reported through reportDisplace as soon as trash.Put // returns, not when this step finishes: the user's file is in the Trash // from that moment, durably, and for a copy or a cross-device move the rest // of the step is the whole data transfer. A process killed in that window // used to leave the file in the Trash with nothing in the log to say so. func runFileStep(step plan.Step, reportDisplace func(entry string) error) StepResult { dst := step.Dst var displacedEntry string if step.Displaces != "" { // Re-checked at apply time: only a regular file may be trashed to // make room, never a directory or link put there since. if fi, err := os.Lstat(step.Displaces); err != nil || !fi.Mode().IsRegular() { return StepResult{Step: step, Status: "failed", Detail: "the file to replace is gone or no longer a regular file"} } // overwrite policy: the file already at dst must be trashed before // this step's own destination name is used, so no free-name search // applies here — the whole point of displacing was to clear this // exact name. The entry name is captured regardless of what happens // next in this step: spec §9 logs "displace" as its own action with // its own line, independent of whether the move/copy/rename that // needed the name then goes on to succeed, so every return below // (failure included) carries it once trashing has succeeded. entry, err := trash.Put(step.Displaces) if err != nil { return StepResult{Step: step, Status: "failed", Detail: "displacing the existing file: " + err.Error()} } displacedEntry = entry if reportDisplace != nil { if err := reportDisplace(entry); err != nil { // The file is already in the Trash and cannot be recorded. // Using the name now would compound an unlogged destructive // act with a second one. return StepResult{Step: step, Status: "failed", DisplacedEntry: entry, Detail: "the file it replaces went to the Trash but could not be logged, so undo cannot see it: " + err.Error()} } } } else if _, err := os.Lstat(dst); err == nil { free, err := nextFreeName(dst) if err != nil { return StepResult{Step: step, Status: "failed", Detail: err.Error()} } dst = free } else if !os.IsNotExist(err) { return StepResult{Step: step, Status: "failed", Detail: err.Error()} } made, err := MkdirAllTracked(filepath.Dir(dst)) if err != nil { return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry} } switch step.Kind { case plan.Copy: err = copyFile(step.Src, dst) case plan.Move: err = moveFile(step.Src, dst) case plan.Rename: err = renameFile(step.Src, dst) } if err != nil { return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry} } fi, err := os.Stat(dst) if err != nil { return StepResult{Step: step, Status: "failed", Detail: err.Error(), Made: made, DisplacedEntry: displacedEntry} } return StepResult{Step: step, Status: "ok", Dst: dst, Size: fi.Size(), ModTime: fi.ModTime(), Made: made, DisplacedEntry: displacedEntry} } // runTrashStep carries out (delete): the file goes to the freedesktop.org // Trash via trash.Put. A file on a different filesystem from the Trash is // not trashed at all; the spec requires the failure to name the two ways // forward, since otherwise the user has no path out of it. // // Dst, Size and ModTime describe the file at trash.Dir()/files/, // even though plan.Step.Dst is always "" for a delete (there is nothing to // compute or conflict-check at plan time). That is a deliberate reading of // §9 rather than an oversight forced by the empty plan.Step.Dst: those // journal columns describe the file at Dst after the step, and after a // trash step the file genuinely lives there, so recording it is more // useful than an empty column and stays greppable. It also cannot confuse // undo: the refusal condition for reversing a trash step is "the entry is // gone, or Src now exists" — it reads Entry and Src, never Dst. func runTrashStep(step plan.Step) StepResult { entry, err := trash.Put(step.Src) if err != nil { detail := err.Error() if errors.Is(err, trash.ErrOtherFilesystem) { detail += "; use (delete permanent) or a move instead" } return StepResult{Step: step, Status: "failed", Detail: detail} } dst := filepath.Join(trash.Dir(), "files", entry) var size int64 var modTime time.Time if fi, err := os.Stat(dst); err == nil { size, modTime = fi.Size(), fi.ModTime() } return StepResult{Step: step, Status: "ok", Dst: dst, Size: size, ModTime: modTime, Entry: entry} } // runDeleteStep carries out (delete permanent): a plain unlink, with no // Trash and no way back. func runDeleteStep(step plan.Step) StepResult { if err := os.Remove(step.Src); err != nil { return StepResult{Step: step, Status: "failed", Detail: err.Error()} } return StepResult{Step: step, Status: "ok"} }