github.com/1aal/kubeblocks@v0.0.0-20231107070852-e1c03e598921/pkg/cli/cmd/plugin/pathutil.go (about) 1 /* 2 Copyright (C) 2022-2023 ApeCloud Co., Ltd 3 4 This file is part of KubeBlocks project 5 6 This program is free software: you can redistribute it and/or modify 7 it under the terms of the GNU Affero General Public License as published by 8 the Free Software Foundation, either version 3 of the License, or 9 (at your option) any later version. 10 11 This program is distributed in the hope that it will be useful 12 but WITHOUT ANY WARRANTY; without even the implied warranty of 13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 GNU Affero General Public License for more details. 15 16 You should have received a copy of the GNU Affero General Public License 17 along with this program. If not, see <http://www.gnu.org/licenses/>. 18 */ 19 20 package plugin 21 22 import ( 23 "io" 24 "os" 25 "path/filepath" 26 "strings" 27 "syscall" 28 29 "github.com/pkg/errors" 30 "k8s.io/klog/v2" 31 32 "github.com/1aal/kubeblocks/pkg/cli/util" 33 ) 34 35 type move struct { 36 from, to string 37 } 38 39 func findMoveTargets(fromDir, toDir string, fo FileOperation) ([]move, error) { 40 if fo.To != filepath.Clean(fo.To) { 41 return nil, errors.Errorf("the provided path is not clean, %q should be %q", fo.To, filepath.Clean(fo.To)) 42 } 43 fromDir, err := filepath.Abs(fromDir) 44 if err != nil { 45 return nil, errors.Wrap(err, "could not get the relative path for the move src") 46 } 47 48 klog.V(4).Infof("Trying to move single file directly from=%q to=%q with file operation=%#v", fromDir, toDir, fo) 49 if m, ok, err := getDirectMove(fromDir, toDir, fo); err != nil { 50 return nil, errors.Wrap(err, "failed to detect single move operation") 51 } else if ok { 52 klog.V(3).Infof("Detected single move from file operation=%#v", fo) 53 return []move{m}, nil 54 } 55 56 klog.V(4).Infoln("Wasn't a single file, proceeding with Glob move") 57 newDir, err := filepath.Abs(filepath.Join(filepath.FromSlash(toDir), filepath.FromSlash(fo.To))) 58 if err != nil { 59 return nil, errors.Wrap(err, "could not get the relative path for the move dst") 60 } 61 62 gl, err := filepath.Glob(filepath.Join(filepath.FromSlash(fromDir), filepath.FromSlash(fo.From))) 63 if err != nil { 64 return nil, errors.Wrap(err, "could not get files using a glob string") 65 } 66 if len(gl) == 0 { 67 return nil, errors.Errorf("no files in the plugin archive matched the glob pattern=%s", fo.From) 68 } 69 70 moves := make([]move, 0, len(gl)) 71 for _, v := range gl { 72 newPath := filepath.Join(newDir, filepath.Base(filepath.FromSlash(v))) 73 // Check secure path 74 m := move{from: v, to: newPath} 75 if !isMoveAllowed(fromDir, toDir, m) { 76 return nil, errors.Errorf("can't move, move target %v is not a subpath from=%q, to=%q", m, fromDir, toDir) 77 } 78 moves = append(moves, m) 79 } 80 return moves, nil 81 } 82 83 func getDirectMove(fromDir, toDir string, fo FileOperation) (move, bool, error) { 84 var m move 85 fromDir, err := filepath.Abs(fromDir) 86 if err != nil { 87 return m, false, errors.Wrap(err, "could not get the relative path for the move src") 88 } 89 90 toDir, err = filepath.Abs(toDir) 91 if err != nil { 92 return m, false, errors.Wrap(err, "could not get the relative path for the move dst") 93 } 94 95 // Check file (not a Glob) 96 fromFilePath := filepath.Clean(filepath.Join(fromDir, fo.From)) 97 _, err = os.Stat(fromFilePath) 98 if err != nil { 99 return m, false, nil 100 } 101 102 // If target is empty use old file name. 103 if filepath.Clean(fo.To) == "." { 104 fo.To = filepath.Base(fromFilePath) 105 } 106 107 // Build new file name 108 toFilePath, err := filepath.Abs(filepath.Join(filepath.FromSlash(toDir), filepath.FromSlash(fo.To))) 109 if err != nil { 110 return m, false, errors.Wrap(err, "could not get the relative path for the move dst") 111 } 112 113 // Check sane path 114 m = move{from: fromFilePath, to: toFilePath} 115 if !isMoveAllowed(fromDir, toDir, m) { 116 return move{}, false, errors.Errorf("can't move, move target %v is out of bounds from=%q, to=%q", m, fromDir, toDir) 117 } 118 119 return m, true, nil 120 } 121 122 func isMoveAllowed(fromBase, toBase string, m move) bool { 123 _, okFrom := IsSubPath(fromBase, m.from) 124 _, okTo := IsSubPath(toBase, m.to) 125 return okFrom && okTo 126 } 127 128 func moveFiles(fromDir, toDir string, fo FileOperation) error { 129 klog.V(4).Infof("Finding move targets from %q to %q with file operation=%#v", fromDir, toDir, fo) 130 moves, err := findMoveTargets(fromDir, toDir, fo) 131 if err != nil { 132 return errors.Wrap(err, "could not find move targets") 133 } 134 135 for _, m := range moves { 136 klog.V(2).Infof("Move file from %q to %q", m.from, m.to) 137 if err := os.MkdirAll(filepath.Dir(m.to), 0o755); err != nil { 138 return errors.Wrapf(err, "failed to create move path %q", filepath.Dir(m.to)) 139 } 140 141 if err = renameOrCopy(m.from, m.to); err != nil { 142 return errors.Wrapf(err, "could not rename/copy file from %q to %q", m.from, m.to) 143 } 144 } 145 klog.V(4).Infoln("Move operations completed") 146 return nil 147 } 148 149 func moveAllFiles(fromDir, toDir string, fos []FileOperation) error { 150 for _, fo := range fos { 151 if err := moveFiles(fromDir, toDir, fo); err != nil { 152 return errors.Wrap(err, "failed moving files") 153 } 154 } 155 return nil 156 } 157 158 // moveToInstallDir moves plugins from srcDir to dstDir (created in this method) with given FileOperation. 159 func moveToInstallDir(srcDir, installDir string, fos []FileOperation) error { 160 installationDir := filepath.Dir(installDir) 161 klog.V(4).Infof("Creating directory %q", installationDir) 162 if err := os.MkdirAll(installationDir, 0o755); err != nil { 163 return errors.Wrapf(err, "error creating directory at %q", installationDir) 164 } 165 166 tmp, err := os.MkdirTemp("", "kbcli-temp-move") 167 klog.V(4).Infof("Creating temp plugin move operations dir %q", tmp) 168 if err != nil { 169 return errors.Wrap(err, "failed to find a temporary director") 170 } 171 defer os.RemoveAll(tmp) 172 173 if err = moveAllFiles(srcDir, tmp, fos); err != nil { 174 return errors.Wrap(err, "failed to move files") 175 } 176 177 klog.V(2).Infof("Move directory %q to %q", tmp, installDir) 178 if err = renameOrCopy(tmp, installDir); err != nil { 179 defer func() { 180 klog.V(3).Info("Cleaning up installation directory due to error during copying files") 181 os.Remove(installDir) 182 }() 183 return errors.Wrapf(err, "could not rename/copy directory %q to %q", tmp, installDir) 184 } 185 return nil 186 } 187 188 // renameOrCopy tries to rename a dir or file. If rename is not supported, a manual copy will be performed. 189 // Existing files at "to" will be deleted. 190 func renameOrCopy(from, to string) error { 191 // Try atomic rename (does not work cross partition). 192 fi, err := os.Stat(to) 193 if err != nil && !os.IsNotExist(err) { 194 return errors.Wrapf(err, "error checking move target dir %q", to) 195 } 196 if fi != nil && fi.IsDir() { 197 klog.V(4).Infof("There's already a directory at move target %q. deleting.", to) 198 if err := os.RemoveAll(to); err != nil { 199 return errors.Wrapf(err, "error cleaning up dir %q", to) 200 } 201 klog.V(4).Infof("Move target directory %q cleaned up", to) 202 } 203 204 err = os.Rename(from, to) 205 // Fallback for invalid cross-device link (errno:18). 206 if isCrossDeviceRenameErr(err) { 207 klog.V(2).Infof("Cross-device link error while copying, fallback to manual copy") 208 return errors.Wrap(copyTree(from, to), "failed to copy directory tree as a fallback") 209 } 210 return err 211 } 212 213 // copyTree copies files or directories, recursively. 214 func copyTree(from, to string) (err error) { 215 return filepath.Walk(from, func(path string, info os.FileInfo, err error) error { 216 if err != nil { 217 return err 218 } 219 newPath, _ := ReplaceBase(path, from, to) 220 if info.IsDir() { 221 klog.V(4).Infof("Creating new dir %q", newPath) 222 err = os.MkdirAll(newPath, info.Mode()) 223 } else { 224 klog.V(4).Infof("Copying file %q", newPath) 225 err = copyFile(path, newPath, info.Mode()) 226 } 227 return err 228 }) 229 } 230 231 func copyFile(source, dst string, mode os.FileMode) (err error) { 232 sf, err := os.Open(source) 233 if err != nil { 234 return err 235 } 236 defer sf.Close() 237 238 df, err := os.Create(dst) 239 if err != nil { 240 return err 241 } 242 defer df.Close() 243 244 _, err = io.Copy(df, sf) 245 if err != nil { 246 return err 247 } 248 return os.Chmod(dst, mode) 249 } 250 251 // isCrossDeviceRenameErr determines if an os.Rename error is due to cross-fs/drive/volume copying. 252 func isCrossDeviceRenameErr(err error) bool { 253 le, ok := err.(*os.LinkError) 254 if !ok { 255 return false 256 } 257 errno, ok := le.Err.(syscall.Errno) 258 if !ok { 259 return false 260 } 261 return (util.IsWindows() && errno == 17) || // syscall.ERROR_NOT_SAME_DEVICE 262 (!util.IsWindows() && errno == 18) // syscall.EXDEV 263 } 264 265 // IsSubPath checks if the extending path is an extension of the basePath, it will return the extending path 266 // elements. Both paths have to be absolute or have the same root directory. The remaining path elements 267 func IsSubPath(basePath, subPath string) (string, bool) { 268 extendingPath, err := filepath.Rel(basePath, subPath) 269 if err != nil { 270 return "", false 271 } 272 if strings.HasPrefix(extendingPath, "..") { 273 return "", false 274 } 275 return extendingPath, true 276 } 277 278 // ReplaceBase returns a replacement path with replacement as a base of the path instead of the old base. a/b/c, a, d -> d/b/c 279 func ReplaceBase(path, old, replacement string) (string, error) { 280 extendingPath, ok := IsSubPath(old, path) 281 if !ok { 282 return "", errors.Errorf("can't replace %q in %q, it is not a subpath", old, path) 283 } 284 return filepath.Join(replacement, extendingPath), nil 285 } 286 287 // CanonicalPluginName resolves a plugin's index and name from input string. 288 // If an index is not specified, the default index name is assumed. 289 func CanonicalPluginName(in string) (string, string) { 290 if strings.Count(in, "/") == 0 { 291 return DefaultIndexName, in 292 } 293 p := strings.SplitN(in, "/", 2) 294 return p[0], p[1] 295 } 296 297 func createOrUpdateLink(binDir, binary, plugin string) error { 298 dst := filepath.Join(binDir, pluginNameToBin(plugin, util.IsWindows())) 299 300 if err := removeLink(dst); err != nil { 301 return errors.Wrap(err, "failed to remove old symlink") 302 } 303 if _, err := os.Stat(binary); os.IsNotExist(err) { 304 return errors.Wrapf(err, "can't create symbolic link, source binary (%q) cannot be found in extracted archive", binary) 305 } 306 307 // Create new 308 klog.V(2).Infof("Creating symlink to %q at %q", binary, dst) 309 if err := os.Symlink(binary, dst); err != nil { 310 return errors.Wrapf(err, "failed to create a symlink from %q to %q", binary, dst) 311 } 312 klog.V(2).Infof("Created symlink at %q", dst) 313 314 return nil 315 } 316 317 // removeLink removes a symlink reference if exists. 318 func removeLink(path string) error { 319 fi, err := os.Lstat(path) 320 if os.IsNotExist(err) { 321 klog.V(3).Infof("No file found at %q", path) 322 return nil 323 } else if err != nil { 324 return errors.Wrapf(err, "failed to read the symlink in %q", path) 325 } 326 327 if fi.Mode()&os.ModeSymlink == 0 { 328 return errors.Errorf("file %q is not a symlink (mode=%s)", path, fi.Mode()) 329 } 330 if err := os.Remove(path); err != nil { 331 return errors.Wrapf(err, "failed to remove the symlink in %q", path) 332 } 333 klog.V(3).Infof("Removed symlink from %q", path) 334 return nil 335 } 336 337 // pluginNameToBin creates the name of the symlink file for the plugin name. 338 // It converts dashes to underscores. 339 func pluginNameToBin(name string, isWindows bool) string { 340 name = strings.ReplaceAll(name, "-", "_") 341 name = "kbcli-" + name 342 if isWindows { 343 name += ".exe" 344 } 345 return name 346 }