// SPDX-License-Identifier: GPL-3.0-or-later package apply import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "syscall" ) // copyFile copies src to dst by streaming its content through a temporary // file created in dst's directory, syncing it, then renaming it into place. // dst must not already exist. Mode and modification time are preserved from // src. On any failure the temporary file is removed and neither src nor a // pre-existing dst is touched. // // This is not internal/config's replaceFile reused: that helper resolves a // destination symlink and overwrites a file that is already there, and it // takes the whole replacement as a []byte. copy's destination must not exist // beforehand, and a []byte cannot stand in for a file that may be many // gigabytes, so this is a separate, streaming equivalent kept local to // internal/apply rather than shared with internal/config. func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() fi, err := in.Stat() if err != nil { return err } dir := filepath.Dir(dst) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".krino-*") if err != nil { return err } tmpName := tmp.Name() done := false defer func() { if !done { os.Remove(tmpName) } }() if _, err := io.Copy(tmp, in); err != nil { tmp.Close() return err } if err := tmp.Chmod(fi.Mode().Perm()); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } if err := os.Chtimes(tmpName, fi.ModTime(), fi.ModTime()); err != nil { return err } // Deliberate, not redundant: the executor's own conflict re-check // (runFileStep, apply.go) already found a name free of anything on disk // before ever calling copyFile, so dst existing here means something // else claimed it in the meantime. Refusing is the only safe response — // silently overwriting it, via os.Rename below, would destroy whatever // just raced us. if _, err := os.Lstat(dst); err == nil { return fmt.Errorf("copy: destination already exists: %s", dst) } else if !os.IsNotExist(err) { return err } if err := os.Rename(tmpName, dst); err != nil { return err } done = true return nil } // moveFile moves src to dst. It tries os.Rename first, which is atomic when // src and dst are on the same filesystem. Only when that fails with EXDEV // (a different filesystem) does it fall back to copying src to dst through // copyFile — itself leaving no temporary file and touching neither src nor // dst on failure — and, only once that copy has landed at dst, removing src. // A failure at any point before the copy has landed leaves src exactly // where it was; a failure to remove src afterward leaves both a full copy // at dst and the original at src rather than risk deleting the only good // copy. // // The filesystem check is done by unwrapping the error for syscall.EXDEV, // never by comparing a Stat_t's device field: that field's type differs // across the platforms `make ci` vets (freebsd, openbsd), while syscall.EXDEV // itself is defined identically on all three. func moveFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err } // This guard must hold independently of runFileStep's own pre-check, // layered rather than moved: trusting that some caller already checked // is exactly what lets a silently replacing helper cause harm. Placed // immediately before the operation that would otherwise clobber dst, // the same way copyFile's own guard sits right before its rename into // place. if err := refuseIfExists(dst); err != nil { return err } err := os.Rename(src, dst) if err == nil { return nil } if !errors.Is(err, syscall.EXDEV) { return err } if err := copyFile(src, dst); err != nil { return err } return os.Remove(src) } // renameFile renames src to dst, refusing on its own when dst already // exists rather than trusting that a caller checked first (the same // reasoning as moveFile's guard above): a bare os.Rename silently replaces // an occupied destination, and runFileStep's own pre-check must not be the // only thing standing between a rename step and that. func renameFile(src, dst string) error { if err := refuseIfExists(dst); err != nil { return err } return os.Rename(src, dst) } // refuseIfExists reports an error naming dst if something is already there // (os.Lstat succeeds, following no symlink), and propagates any other stat // failure. A nil return means dst was confirmed absent at the moment of the // check. func refuseIfExists(dst string) error { if _, err := os.Lstat(dst); err == nil { return fmt.Errorf("destination already exists: %s", dst) } else if !os.IsNotExist(err) { return err } return nil } // maxSuffixAttempts bounds nextFreeName. internal/plan/conflict.go and // internal/trash/trash.go each have their own cap of the same size, for the // same reason given below: nextFreeName solves yet another, independent // collision problem and is not sharing code with either. const maxSuffixAttempts = 10000 // nextFreeName finds the first stem_N.ext, N starting at 1, that does not // currently exist on disk. It is the executor's own conflict re-check (spec // §7.4, last paragraph): planning already resolved every conflict once // against the filesystem as it was then, but something else can claim the // planned name before the executor gets to it, so the executor looks again, // right before acting, using only the real filesystem — it has no run-wide // claim set to consult, unlike planning's. // // internal/plan's own suffixed() and splitExt are unexported, so they are // not reachable from here; nextFreeName and splitExt below are a second, // small implementation, the same shape as internal/plan's and // internal/trash's for the same reason those two do not share code with // each other either — each resolves a distinct, independently changing set // of collisions (planned destinations; entries already in the Trash; names // that appeared on disk since this plan was made). func nextFreeName(dst string) (string, error) { dir, base := filepath.Split(dst) stem, ext := splitExt(base) for n := 1; n <= maxSuffixAttempts; n++ { candidate := filepath.Join(dir, fmt.Sprintf("%s_%d%s", stem, n, ext)) if _, err := os.Lstat(candidate); os.IsNotExist(err) { return candidate, nil } } return "", errors.New("too many conflicting names") } // splitExt splits name on its last dot, which does not count when it is the // first character: "a.tar.gz" -> "a.tar", ".gz"; ".bashrc" -> ".bashrc", "". // Duplicated from internal/plan/conflict.go and internal/trash/trash.go // (unexported in both) rather than shared; see nextFreeName's comment. func splitExt(name string) (stem, ext string) { i := strings.LastIndexByte(name, '.') if i <= 0 { return name, "" } return name[:i], name[i:] } // MkdirAllTracked creates dir and any missing ancestors (mode 0755), // returning every directory it actually created, outermost first, so undo // can later remove the empty ones again. A directory that already existed // is not included, and nothing is created or returned on error. func MkdirAllTracked(dir string) ([]string, error) { dir = filepath.Clean(dir) if fi, err := os.Stat(dir); err == nil { if !fi.IsDir() { return nil, fmt.Errorf("%s exists and is not a directory", dir) } return nil, nil } else if !os.IsNotExist(err) { return nil, err } parent := filepath.Dir(dir) var made []string if parent != dir { parentMade, err := MkdirAllTracked(parent) if err != nil { return nil, err } made = parentMade } if err := os.Mkdir(dir, 0o755); err != nil && !os.IsExist(err) { return made, err } return append(made, dir), nil }