github.com/google/osv-scalibr@v0.4.1/artifact/image/symlink/symlink.go (about) 1 // Copyright 2025 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package symlink provides symlink-related util functions for container extraction. 16 package symlink 17 18 import ( 19 "fmt" 20 "io/fs" 21 "os" 22 "path/filepath" 23 "strings" 24 25 "github.com/google/osv-scalibr/log" 26 "github.com/google/uuid" 27 ) 28 29 // RemoveObsoleteSymlinks removes symlinks that point to a destination file or directory path that 30 // does not exist. Note: There are three terms used in this function: symlink, target link, and 31 // destination file. 32 // 33 // symlink: Refers to the symlink file itself. 34 // target link: The link stored in a symlink file that points to another file (or symlink). 35 // destination file: The last file pointed to by a symlink. That is, if there is a chain of 36 // symlinks, the destination file is the file pointed to by the last symlink. 37 // 38 // Example: In this file system, the symlink, sym3, points to a destination file that doesn't exist 39 // 40 // (b.txt). This function would remove the sym3.txt file. 41 // root 42 // dir1 43 // a.txt 44 // sym1.txt -> ../dir2/sym2.txt 45 // dir2 46 // sym2.txt -> ../dir1/a.txt 47 // sym3.txt -> ../dir1/b.txt (would be removed since b.txt does not exist) 48 func RemoveObsoleteSymlinks(root string) error { 49 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { 50 if err != nil { 51 log.Warnf("Failed to walk directory %q: %v", path, err) 52 return fmt.Errorf("failed to walk directory %q: %w", path, err) 53 } 54 55 if (d.Type() & fs.ModeType) != fs.ModeSymlink { 56 return nil 57 } 58 59 // Gets the target link from the symlink file. 60 linkTarget, err := os.Readlink(path) 61 if err != nil { 62 log.Warnf("Failed to read target of symlink %q: %v", path, err) 63 return fmt.Errorf("failed to read target of symlink %q: %w", path, err) 64 } 65 66 // Relative symlinks must be resolved in order to determine if the destination exists. 67 if !filepath.IsAbs(linkTarget) { 68 linkTarget = filepath.Join(filepath.Dir(path), linkTarget) 69 } 70 71 // The destination exists, so we can return and go on to the next path. 72 if _, err = os.Stat(linkTarget); err == nil { 73 return nil 74 } 75 76 // Destination doesn't exist so remove symlink file. 77 err = os.Remove(path) 78 if err != nil { 79 log.Warnf("Failed to remove symlink %q: %v", path, err) 80 return err 81 } 82 log.Infof("Removed symlink %q", path) 83 return nil 84 }) 85 return err 86 } 87 88 // ResolveInterLayerSymlinks resolves absolute and relative symlinks in a layer sub-directory with 89 // a given layer digest by redirecting the symlink target path to point to the SQUASHED layer's 90 // symlink target path if it exists. 91 // 92 // The structure of the layered directory before resolving symlinks is as follows: 93 // 94 // root 95 // layer1digest 96 // dir1 97 // sample.txt 98 // layer2digest 99 // dir2 100 // relative-symlink.txt -> ../sample.txt (notice how ../sample.txt wouldn't be found due to the layering approach) 101 // absolute-symlink.txt -> /dir1/sample.txt (the /dir1/sample.txt target file also wouldn't be found) 102 // SQUASHED 103 // dir1 104 // sample.txt 105 // dir2 106 // relative-symlink.txt -> /root/SQUASHED/dir1/sample.txt 107 // absolute-symlink.txt -> /root/SQUASHED/dir1/sample.txt 108 // 109 // After resolving the layer with layer digest of "layer2digest", the file system is as follows: 110 // 111 // root 112 // layer1digest 113 // dir1 114 // sample.txt 115 // layer2digest 116 // dir2 117 // relative-symlink.txt -> /root/SQUASHED/dir2/relative-symlink.txt 118 // absolute-symlink.txt -> /root/SQUASHED/dir2/relative-symlink.txt 119 // SQUASHED 120 // dir1 121 // sample.txt 122 // dir2 123 // relative-symlink.txt -> /root/SQUASHED/dir1/sample.txt 124 // absolute-symlink.txt -> /root/SQUASHED/dir1/sample.txt 125 func ResolveInterLayerSymlinks(root, layerDigest, squashedImageDirectory string) error { 126 layerPath := filepath.Join(root, strings.ReplaceAll(layerDigest, ":", "-")) 127 128 // Walk through each symlink in the layer, convert to absolute symlink, then resolve cross layer 129 // symlinks. 130 err := filepath.WalkDir(layerPath, func(path string, d fs.DirEntry, err error) error { 131 if err != nil { 132 log.Warnf("Failed to walk directory %q: %v", path, err) 133 return fmt.Errorf("failed to walk directory %q: %w", path, err) 134 } 135 136 // Skip anything that isn't a symlink. 137 if (d.Type() & fs.ModeType) != fs.ModeSymlink { 138 return nil 139 } 140 141 if err = resolveSingleSymlink(root, path, layerPath, squashedImageDirectory); err != nil { 142 return fmt.Errorf("failed to resolve symlink %q: %w", path, err) 143 } 144 return nil 145 }) 146 if err != nil { 147 return fmt.Errorf("failed to walk directory %q: %w", layerPath, err) 148 } 149 return nil 150 } 151 152 // resolveSingleSymlink resolves a single symlink by checking if the target path of the symlink 153 // exists in the squashed layer. If it does, then the symlink is updated to point to the target path 154 // in the squashed layer. This relies on the squashed layer having all of its symlinks resolved 155 // properly. 156 func resolveSingleSymlink(root, symlink, layerPath, resolvedDirectory string) error { 157 targetPath, err := os.Readlink(symlink) 158 if err != nil { 159 return fmt.Errorf("failed to read symlink %q: %w", symlink, err) 160 } 161 162 if !filepath.IsAbs(targetPath) { 163 targetPath = removeLayerPathPrefix(filepath.Join(filepath.Dir(symlink), targetPath), layerPath) 164 } else { 165 targetPath = removeLayerPathPrefix(targetPath, layerPath) 166 } 167 168 targetPathInSquashedLayer := filepath.Join(root, resolvedDirectory, targetPath) 169 170 // Remove the existing symlink. 171 if err := os.Remove(symlink); err != nil && !os.IsNotExist(err) { 172 log.Warnf("Failed to remove symlink %q: %v", symlink, err) 173 return fmt.Errorf("failed to remove symlink %q: %w", symlink, err) 174 } 175 176 // If target path does not exist, then squashed layer did not contain the file that the symlink 177 // pointed to and will not be included in final file-system for SCALIBR to scan. 178 if _, err = os.Stat(targetPathInSquashedLayer); os.IsNotExist(err) { 179 return nil 180 } else if err != nil { 181 return fmt.Errorf("failed to get status of file %q: %w", targetPathInSquashedLayer, err) 182 } 183 184 // Recreate the symlink with the new destination path. 185 if err := os.Symlink(targetPathInSquashedLayer, symlink); err != nil { 186 log.Warnf("Failed to create symlink %q: %v", symlink, err) 187 return fmt.Errorf("failed to create symlink %q: %w", symlink, err) 188 } 189 return nil 190 } 191 192 func removeLayerPathPrefix(path, layerPath string) string { 193 return filepath.Clean(strings.TrimPrefix(path, layerPath)) 194 } 195 196 // TargetOutsideRoot checks if the target of a symlink points outside of the root directory of that 197 // symlink's path. 198 // For example, if a symlink with path `a/symlink.txt“ points to “../../file.text“, then 199 // this function would return true because the target file is outside of the root directory. 200 func TargetOutsideRoot(path, target string) bool { 201 // Create a marker directory as root to check if the target path is outside of the root directory. 202 markerDir := uuid.New().String() 203 if filepath.IsAbs(target) { 204 // Absolute paths may still point outside of the root directory. 205 // e.g. "/../file.txt" 206 markerTarget := filepath.Join(markerDir, target) 207 return !strings.Contains(markerTarget, markerDir) 208 } 209 210 markerTargetAbs := filepath.Join(markerDir, filepath.Dir(path), target) 211 return !strings.Contains(markerTargetAbs, markerDir) 212 }