blob: feb4fbee4617ad98eddc3956e7614a5675af41a2 [file] [log] [blame]
// Copyright 2018 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package system_updater
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"syscall"
"syscall/zx"
"syscall/zx/fdio"
zxio "syscall/zx/io"
appcontext "app/context"
fuchsiaio "fidl/fuchsia/io"
"fidl/fuchsia/mem"
"fidl/fuchsia/paver"
"fidl/fuchsia/pkg"
"syslog"
)
// When this suffix is found in the "images" file, it indicates a typed image
// that looks for all matches within the update package.
const ImageTypeSuffix = "[_type]"
func ConnectToPackageResolver(ctx *appcontext.Context) (*pkg.PackageResolverWithCtxInterface, error) {
req, pxy, err := pkg.NewPackageResolverWithCtxInterfaceRequest()
if err != nil {
syslog.Errorf("control interface could not be acquired: %s", err)
return nil, err
}
ctx.ConnectToEnvService(req)
return pxy, nil
}
func ConnectToPaver(ctx *appcontext.Context) (*paver.DataSinkWithCtxInterface, *paver.BootManagerWithCtxInterface, error) {
req, pxy, err := paver.NewPaverWithCtxInterfaceRequest()
if err != nil {
syslog.Errorf("control interface could not be acquired: %s", err)
return nil, nil, err
}
defer pxy.Close()
ctx.ConnectToEnvService(req)
dataSinkReq, dataSinkPxy, err := paver.NewDataSinkWithCtxInterfaceRequest()
if err != nil {
syslog.Errorf("data sink interface could not be acquired: %s", err)
return nil, nil, err
}
err = pxy.FindDataSink(context.Background(), dataSinkReq)
if err != nil {
syslog.Errorf("could not find data sink: %s", err)
return nil, nil, err
}
bootManagerReq, bootManagerPxy, err := paver.NewBootManagerWithCtxInterfaceRequest()
if err != nil {
syslog.Errorf("boot manager interface could not be acquired: %s", err)
return nil, nil, err
}
err = pxy.FindBootManager(context.Background(), bootManagerReq)
if err != nil {
syslog.Errorf("could not find boot manager: %s", err)
return nil, nil, err
}
return dataSinkPxy, bootManagerPxy, nil
}
// CacheUpdatePackage caches the requested, possibly merkle-pinned, update
// package URL and returns the pkgfs path to the package.
func CacheUpdatePackage(updateURL string, resolver *pkg.PackageResolverWithCtxInterface) (*UpdatePackage, error) {
dirPxy, err := resolvePackage(updateURL, resolver)
if err != nil {
return nil, err
}
pkg, err := NewUpdatePackage(dirPxy)
if err != nil {
return nil, err
}
merkle, err := pkg.Merkleroot()
if err != nil {
pkg.Close()
return nil, err
}
syslog.Infof("resolved %s as %s", updateURL, merkle)
return pkg, nil
}
// An image name and type string.
type Image struct {
// The base name of the image.
Name string
// A type string, default "".
Type string
}
// Returns an Image's filename in an update package.
//
// If a type is given, the filename in the package will be <name>_<type>, e.g.:
// name="foo", type="" -> "foo"
// name="foo", type="bar" -> "foo_bar"
func (i *Image) Filename() string {
if i.Type == "" {
return i.Name
}
return fmt.Sprintf("%s_%s", i.Name, i.Type)
}
func ParseRequirements(updatePkg *UpdatePackage) ([]string, []Image, error) {
// First, figure out which packages files we should parse
parseJson := true
pkgSrc, err := updatePkg.Open("packages.json")
// Fall back to line formatted packages file if packages.json not present
// Ideally, we'd fall back if specifically given the "file not found" error,
// though it's unclear which error that is (syscall.ENOENT did not work)
if err != nil {
syslog.Infof("parse_requirements: could not open packages.json, falling back to packages.")
parseJson = false
pkgSrc, err = updatePkg.Open("packages")
}
if err != nil {
return nil, nil, fmt.Errorf("error opening packages data file! %s", err)
}
defer pkgSrc.Close()
// Now that we know which packages file to parse, we can parse it.
pkgs := []string{}
if parseJson {
pkgs, err = ParsePackagesJson(pkgSrc)
} else {
pkgs, err = ParsePackagesLineFormatted(pkgSrc)
}
if err != nil {
return nil, nil, fmt.Errorf("failed to parse packages: %v", err)
}
// Finally, we parse images
imgSrc, err := os.Open(filepath.Join("/pkg", "data", "images"))
if err != nil {
return nil, nil, fmt.Errorf("error opening images data file! %s", err)
}
defer imgSrc.Close()
filenames, err := updatePkg.ListFiles()
if err != nil {
return nil, nil, fmt.Errorf("failed to list package files: %v", err)
}
imgs, err := ParseImages(imgSrc, filenames)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse images: %v", err)
}
return pkgs, imgs, nil
}
// Packages deserializes the packages.json file in the system update package.
// NOTE: Fields must be exported for json decoding.
type packages struct {
Version intOrStr `json:"version"`
// A list of fully qualified URIs.
URIs []string `json:"content"`
}
type intOrStr int
// Enables us to support version as either a string or int.
func (i *intOrStr) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err == nil {
b = []byte(s)
}
return json.Unmarshal(b, (*int)(i))
}
func ParsePackagesJson(pkgSrc io.ReadCloser) ([]string, error) {
bytes, err := ioutil.ReadAll(pkgSrc)
if err != nil {
return nil, fmt.Errorf("failed to read packages.json with error: %v", err)
}
var packages packages
if err := json.Unmarshal(bytes, &packages); err != nil {
return nil, fmt.Errorf("failed to unmarshal packages.json: %v", err)
}
if packages.Version != 1 {
return nil, fmt.Errorf("unsupported version of packages.json: %v", packages.Version)
}
return packages.URIs, nil
}
func ParsePackagesLineFormatted(pkgSrc io.ReadCloser) ([]string, error) {
pkgs := []string{}
rdr := bufio.NewReader(pkgSrc)
for {
l, err := rdr.ReadString('\n')
s := strings.TrimSpace(l)
if (err == nil || err == io.EOF) && len(s) > 0 {
entry := strings.Split(s, "=")
if len(entry) != 2 {
return nil, fmt.Errorf("parser: entry format %q", s)
} else {
pkgURI := fmt.Sprintf("fuchsia-pkg://fuchsia.com/%s?hash=%s", entry[0], entry[1])
pkgs = append(pkgs, pkgURI)
}
}
if err != nil {
if err != io.EOF {
return nil, fmt.Errorf("parser: got error reading packages file %s", err)
}
break
}
}
return pkgs, nil
}
// Finds all images that match |basename| in |filenames|.
//
// A match is one of:
// <basename>
// <basename>_<type>
func FindTypedImages(basename string, filenames []string) []Image {
var images []Image
for _, name := range filenames {
if strings.HasPrefix(name, basename) {
suffix := name[len(basename):]
if len(suffix) == 0 {
// The base name alone indicates default type (empty string).
images = append(images, Image{Name: basename, Type: ""})
} else if suffix[0] == '_' {
images = append(images, Image{Name: basename, Type: suffix[1:]})
}
}
}
return images
}
// Returns a list of images derived from the "images" file.
//
// Untyped images (those without the [_type] suffix) are included in the return
// slice no matter what.
//
// Typed images, on the other hand, will only include matches that exist in
// |filenames|.
func ParseImages(imgSrc io.ReadCloser, filenames []string) ([]Image, error) {
rdr := bufio.NewReader(imgSrc)
imgs := []Image{}
for {
l, err := rdr.ReadString('\n')
s := strings.TrimSpace(l)
if (err == nil || err == io.EOF) && len(s) > 0 {
if strings.HasSuffix(s, ImageTypeSuffix) {
// Typed image: look for all matching images in the package.
basename := strings.TrimSuffix(s, ImageTypeSuffix)
imgs = append(imgs, FindTypedImages(basename, filenames)...)
} else {
imgs = append(imgs, Image{Name: s, Type: ""})
}
}
if err != nil {
if err != io.EOF {
return nil, fmt.Errorf("parser: got error reading images file %s", err)
}
break
}
}
return imgs, nil
}
// Types to deserialize the update-mode file. NOTE: Fields must be exported for json decoding.
// Expected form for update-mode file is:
// {
// "version": "1",
// "content": {
// "mode": "normal" / "force-recovery",
// }
// }
type updateModeFileContent struct {
Mode string `json:"mode"`
}
type updateModeFile struct {
Version string `json:"version"`
Content updateModeFileContent `json:"content"`
}
// Type to describe the supported update modes.
// Note: exporting since this will be used in main (to be consistent with the rest of the code).
type UpdateMode string
const (
UpdateModeNormal UpdateMode = "normal"
UpdateModeForceRecovery = "force-recovery"
)
// We define custom error wrappers so we can test the proper error is being returned.
type updateModeNotSupportedError UpdateMode
func (e updateModeNotSupportedError) Error() string {
return fmt.Sprintf("unsupported update mode: %s", string(e))
}
type jsonUnmarshalError struct {
err error
}
func (e jsonUnmarshalError) Error() string {
return fmt.Sprintf("failed to unmarshal update-mode: %v", e.err)
}
// Note: exporting since this will be used in main (to be consistent with the rest of the code).
func ParseUpdateMode(updatePkg *UpdatePackage) (UpdateMode, error) {
// Fall back to normal if the update-mode file does not exist.
// Ideally, we'd fall back if specifically given the "file not found" error,
// though it's unclear which error that is (syscall.ENOENT did not work).
modeSrc, err := updatePkg.Open("update-mode")
if err != nil {
syslog.Infof("parse_update_mode: could not open update-mode file, assuming normal system update flow.")
return UpdateModeNormal, nil
}
defer modeSrc.Close()
// Read the raw bytes.
b, err := ioutil.ReadAll(modeSrc)
if err != nil {
return "", fmt.Errorf("failed to read mode file: %w", err)
}
// Convert to json.
var updateModeFile updateModeFile
if err := json.Unmarshal(b, &updateModeFile); err != nil {
return "", jsonUnmarshalError{err}
}
// Confirm we support this mode.
mode := UpdateMode(updateModeFile.Content.Mode)
if mode != UpdateModeNormal && mode != UpdateModeForceRecovery {
return "", updateModeNotSupportedError(mode)
}
return mode, nil
}
func FetchPackages(pkgs []string, resolver *pkg.PackageResolverWithCtxInterface) error {
var errCount int
var firstErr error
for _, pkgURI := range pkgs {
if err := fetchPackage(pkgURI, resolver); err != nil {
syslog.Errorf("fetch error: %s", err)
errCount++
if firstErr == nil {
firstErr = err
}
}
}
if errCount > 0 {
syslog.Errorf("system update failed, %d packages had errors", errCount)
return firstErr
}
return nil
}
func fetchPackage(pkgURI string, resolver *pkg.PackageResolverWithCtxInterface) error {
dirPxy, err := resolvePackage(pkgURI, resolver)
if dirPxy != nil {
dirPxy.Close(context.Background())
}
return err
}
func resolvePackage(pkgURI string, resolver *pkg.PackageResolverWithCtxInterface) (*fuchsiaio.DirectoryWithCtxInterface, error) {
selectors := []string{}
updatePolicy := pkg.UpdatePolicy{}
dirReq, dirPxy, err := fuchsiaio.NewDirectoryWithCtxInterfaceRequest()
if err != nil {
return nil, err
}
syslog.Infof("requesting %s from update system", pkgURI)
status, err := resolver.Resolve(context.Background(), pkgURI, selectors, updatePolicy, dirReq)
if err != nil {
dirPxy.Close(context.Background())
return nil, fmt.Errorf("fetch: Resolve error: %s", err)
}
statusErr := zx.Status(status)
if statusErr != zx.ErrOk {
dirPxy.Close(context.Background())
return nil, fmt.Errorf("fetch: Resolve status: %s", statusErr)
}
return dirPxy, nil
}
func ValidateUpdatePackage(updatePkg *UpdatePackage) error {
actual, err := updatePkg.ReadFile("board")
if err == nil {
expected, err := ioutil.ReadFile("/config/build-info/board")
if err != nil {
return err
}
if !bytes.Equal(actual, expected) {
return fmt.Errorf("parser: expected board name %s found %s", expected, actual)
}
} else if !os.IsNotExist(err) {
return err
}
return nil
}
func ValidateImgs(imgs []Image, updatePkg *UpdatePackage, updateMode UpdateMode) error {
found := false
for _, img := range []string{"zbi", "zbi.signed"} {
if _, err := updatePkg.Stat(img); err == nil {
found = true
break
}
}
// Update package with normal mode should have a `zbi` or `zbi.signed`.
if updateMode == UpdateModeNormal && !found {
return fmt.Errorf("parser: missing 'zbi' or 'zbi.signed', this is required in normal update mode")
}
// Update package with force-recovery mode should NOT have a `zbi` nor `zbi.signed`.
if updateMode == UpdateModeForceRecovery && found {
return fmt.Errorf("parser: contains 'zbi' or 'zbi.signed', this is not allowed in force-recovery update mode")
}
return nil
}
func WriteImgs(dataSink *paver.DataSinkWithCtxInterface, bootManager *paver.BootManagerWithCtxInterface, imgs []Image, updatePkg *UpdatePackage, updateMode UpdateMode, skipRecovery bool) error {
if updateMode == UpdateModeForceRecovery && skipRecovery == true {
return fmt.Errorf("can't force recovery when skipping recovery image installation")
}
syslog.Infof("Writing images %+v from update package", imgs)
activeConfig, err := queryActiveConfig(bootManager)
if err != nil {
return fmt.Errorf("querying target config: %v", err)
}
// If we have an active config (and thus support ABR), compute the
// target config. Otherwise set the target config to nil so we fall
// back to the legacy behavior where we write to the A partition, and
// attempt to write to the B partition.
var targetConfig *paver.Configuration
if activeConfig == nil {
targetConfig = nil
} else {
targetConfig, err = calculateTargetConfig(*activeConfig)
if err != nil {
return err
}
}
for _, img := range imgs {
if err := writeImg(dataSink, img, updatePkg, targetConfig, skipRecovery); err != nil {
return err
}
}
if updateMode == UpdateModeNormal && targetConfig != nil {
if err := setConfigurationActive(bootManager, *targetConfig); err != nil {
return err
}
} else if updateMode == UpdateModeForceRecovery {
for _, config := range []paver.Configuration{paver.ConfigurationA, paver.ConfigurationB} {
if err := setConfigurationUnbootable(bootManager, config); err != nil {
return fmt.Errorf("failed to set configuration unbootable: %v", err)
}
}
}
return nil
}
// queryActiveConfig asks the boot manager what partition the device booted
// from. If the device does not support ABR, it returns nil as the
// configuration.
func queryActiveConfig(bootManager *paver.BootManagerWithCtxInterface) (*paver.Configuration, error) {
activeConfig, err := bootManager.QueryActiveConfiguration(context.Background())
if err != nil {
// FIXME(fxb/43577): If the paver service runs into a problem
// creating a boot manager, it will close the channel with an
// epitaph. The error we are particularly interested in is
// whether or not the current device supports ABR.
// Unfortunately the go fidl bindings do not support epitaphs,
// so we can't actually check for this error condition. All we
// can observe is that the channel has been closed, so treat
// this condition as the device does not support ABR.
if err, ok := err.(*zx.Error); ok && err.Status == zx.ErrPeerClosed {
syslog.Warnf("img_writer: boot manager channel closed, assuming device does not support ABR")
return nil, nil
}
return nil, fmt.Errorf("querying active config: %v", err)
}
if activeConfig.Which() == paver.BootManagerQueryActiveConfigurationResultResponse {
syslog.Infof("img_writer: device supports ABR")
return &activeConfig.Response.Configuration, nil
}
statusErr := zx.Status(activeConfig.Err)
if statusErr == zx.ErrNotSupported {
// this device doesn't support ABR, so fall back to the
// legacy workflow.
syslog.Infof("img_writer: device does not support ABR")
return nil, nil
}
return nil, &zx.Error{Status: statusErr}
}
func calculateTargetConfig(activeConfig paver.Configuration) (*paver.Configuration, error) {
var config paver.Configuration
switch activeConfig {
case paver.ConfigurationA:
config = paver.ConfigurationB
case paver.ConfigurationB:
config = paver.ConfigurationA
case paver.ConfigurationRecovery:
syslog.Warnf("img_writer: configured for recovery, using partition A instead")
config = paver.ConfigurationA
default:
return nil, fmt.Errorf("img_writer: unknown config: %s", activeConfig)
}
syslog.Infof("img_writer: writing to configuration %s", config)
return &config, nil
}
func setConfigurationActive(bootManager *paver.BootManagerWithCtxInterface, targetConfig paver.Configuration) error {
syslog.Infof("img_writer: setting configuration %s active", targetConfig)
status, err := bootManager.SetConfigurationActive(context.Background(), targetConfig)
if err != nil {
return err
}
statusErr := zx.Status(status)
if statusErr != zx.ErrOk {
return &zx.Error{Status: statusErr}
}
return nil
}
func setConfigurationUnbootable(bootManager *paver.BootManagerWithCtxInterface, targetConfig paver.Configuration) error {
syslog.Infof("img_writer: setting configuration %s unbootable", targetConfig)
status, err := bootManager.SetConfigurationUnbootable(context.Background(), targetConfig)
if err != nil {
return err
}
statusErr := zx.Status(status)
if statusErr != zx.ErrOk {
return &zx.Error{Status: statusErr}
}
return nil
}
func writeAsset(svc *paver.DataSinkWithCtxInterface, configuration paver.Configuration, asset paver.Asset, payload *mem.Buffer) error {
syslog.Infof("img_writer: writing asset %q to %q", asset, configuration)
status, err := svc.WriteAsset(context.Background(), configuration, asset, *payload)
if err != nil {
syslog.Errorf("img_writer: failed to write asset %q: %s", asset, err)
return err
}
statusErr := zx.Status(status)
if statusErr != zx.ErrOk {
return &zx.Error{Status: statusErr}
}
return nil
}
func writeImg(svc *paver.DataSinkWithCtxInterface, img Image, updatePkg *UpdatePackage, targetConfig *paver.Configuration, skipRecovery bool) error {
f, err := updatePkg.Open(img.Filename())
if err != nil {
syslog.Warnf("img_writer: %q image not found, skipping", img.Filename())
return nil
}
if fi, err := f.Stat(); err != nil || fi.Size() == 0 {
syslog.Warnf("img_writer: %q zero length, skipping", img.Filename())
return nil
}
defer f.Close()
buffer, err := bufferForFile(f)
if err != nil {
return fmt.Errorf("img_writer: while getting vmo for %q: %q", img.Filename(), err)
}
defer buffer.Vmo.Close()
var writeImg func() error
switch img.Name {
case "zbi", "zbi.signed":
childVmo, err := buffer.Vmo.CreateChild(zx.VMOChildOptionCopyOnWrite|zx.VMOChildOptionResizable, 0, buffer.Size)
if err != nil {
return fmt.Errorf("img_writer: while getting vmo for %q: %q", img.Filename(), err)
}
buffer2 := &mem.Buffer{
Vmo: childVmo,
Size: buffer.Size,
}
defer buffer2.Vmo.Close()
if targetConfig == nil {
// device does not support ABR, so write the ZBI to the
// A partition. We also try to write to the B partition
// in order to be forwards compatible with devices that
// will eventually support ABR, but we ignore errors
// because some devices won't have a B partition.
writeImg = func() error {
if err := writeAsset(svc, paver.ConfigurationA, paver.AssetKernel, buffer); err != nil {
return err
}
if err := writeAsset(svc, paver.ConfigurationB, paver.AssetKernel, buffer2); err != nil {
asZxErr, ok := err.(*zx.Error)
if ok && asZxErr.Status == zx.ErrNotSupported {
syslog.Warnf("img_writer: skipping writing %q to B: %v", img.Filename(), err)
} else {
return err
}
}
return nil
}
} else {
// device supports ABR, so only write the ZB to the
// target partition.
writeImg = func() error {
return writeAsset(svc, *targetConfig, paver.AssetKernel, buffer)
}
}
case "fuchsia.vbmeta":
childVmo, err := buffer.Vmo.CreateChild(zx.VMOChildOptionCopyOnWrite|zx.VMOChildOptionResizable, 0, buffer.Size)
if err != nil {
return fmt.Errorf("img_writer: while getting vmo for %q: %q", img.Filename(), err)
}
buffer2 := &mem.Buffer{
Vmo: childVmo,
Size: buffer.Size,
}
defer buffer2.Vmo.Close()
if targetConfig == nil {
// device does not support ABR, so write vbmeta to the
// A partition, and try to write to the B partiton. See
// the comment in the zbi case for more details.
if err := writeAsset(svc, paver.ConfigurationA,
paver.AssetVerifiedBootMetadata, buffer); err != nil {
return err
}
return writeAsset(svc, paver.ConfigurationB, paver.AssetVerifiedBootMetadata, buffer2)
} else {
// device supports ABR, so write the vbmeta to the
// target partition.
writeImg = func() error {
return writeAsset(svc, *targetConfig, paver.AssetVerifiedBootMetadata, buffer2)
}
}
case "zedboot", "zedboot.signed":
if skipRecovery {
return nil
} else {
writeImg = func() error {
return writeAsset(svc, paver.ConfigurationRecovery, paver.AssetKernel, buffer)
}
}
case "recovery.vbmeta":
if skipRecovery {
return nil
} else {
writeImg = func() error {
return writeAsset(svc, paver.ConfigurationRecovery, paver.AssetVerifiedBootMetadata, buffer)
}
}
case "bootloader":
// Keep support for update packages still using the older "bootloader"
// file, which is handled identically to "firmware" but without type
// support so img.Type will always be "".
fallthrough
case "firmware":
writeImg = func() error {
result, err := svc.WriteFirmware(context.Background(), img.Type, *buffer)
if err != nil {
return err
}
if result.Which() == paver.WriteFirmwareResultUnsupportedType {
syslog.Infof("img_writer: skipping unsupported firmware type %q", img.Type)
// Return nil here to skip unsupported types rather than failing.
// This lets us add new types in the future without breaking
// the update flow from older devices.
return nil
}
statusErr := zx.Status(result.Status)
if statusErr != zx.ErrOk {
return fmt.Errorf("%s", statusErr)
}
return nil
}
case "board":
return nil
default:
return fmt.Errorf("unrecognized image %q", img.Filename())
}
syslog.Infof("img_writer: writing %q from update package", img.Filename())
if err := writeImg(); err != nil {
return fmt.Errorf("img_writer: error writing %q: %q", img.Filename(), err)
}
syslog.Infof("img_writer: wrote %q successfully", img.Filename())
return nil
}
func bufferForFile(f *os.File) (*mem.Buffer, error) {
fio := syscall.FDIOForFD(int(f.Fd())).(*fdio.File)
if fio == nil {
return nil, fmt.Errorf("not fdio file")
}
status, buffer, err := fio.GetBuffer(zxio.VmoFlagRead)
if err != nil {
return nil, fmt.Errorf("GetBuffer fidl error: %q", err)
}
statusErr := zx.Status(status)
if statusErr != zx.ErrOk {
return nil, fmt.Errorf("GetBuffer error: %q", statusErr)
}
defer buffer.Vmo.Close()
// VMOs acquired over FIDL are not guaranteed to be resizable, so create a child VMO that is.
childVmo, err := buffer.Vmo.CreateChild(zx.VMOChildOptionCopyOnWrite|zx.VMOChildOptionResizable, 0, buffer.Size)
if err != nil {
return nil, err
}
return &mem.Buffer{
Vmo: childVmo,
Size: buffer.Size,
}, nil
}
// UpdateCurrentChannel persists the update channel info for a successful update
func UpdateCurrentChannel() error {
targetPath := "/misc/ota/target_channel.json"
contents, err := ioutil.ReadFile(targetPath)
if err != nil {
return fmt.Errorf("no target channel recorded in %v: %w", targetPath, err)
}
currentPath := "/misc/ota/current_channel.json"
partPath := currentPath + ".part"
f, err := os.Create(partPath)
if err != nil {
return fmt.Errorf("unable to write current channel to %v: %w", partPath, err)
}
defer f.Close()
buf := bytes.NewBuffer(contents)
_, err = buf.WriteTo(f)
if err != nil {
return fmt.Errorf("unable to write current channel to %v: %w", currentPath, err)
}
f.Sync()
f.Close()
if err := os.Rename(partPath, currentPath); err != nil {
return fmt.Errorf("error moving %v to %v: %w", partPath, currentPath, err)
}
return nil
}