fix(guestfs): keep guest file deploy inside the mounted filesystem (#25601)

Co-authored-by: Qiu Jian <qiujian@yunionyun.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jian Qiu
2026-09-08 23:47:10 +08:00
committed by GitHub
parent 41d79c09d6
commit 4979e0f7b9
10 changed files with 559 additions and 41 deletions

View File

@@ -932,6 +932,10 @@ func (self *SGuest) PerformDeploy(
input.ResetPassword = true
}
if err := ValidateDeployConfigs(input.DeployConfigs); err != nil {
return nil, err
}
driver, err := self.GetDriver()
if err != nil {
return nil, errors.Wrapf(err, "GetDriver")

View File

@@ -0,0 +1,42 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or authorized to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"yunion.io/x/pkg/util/sets"
api "yunion.io/x/onecloud/pkg/apis/compute"
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/util/fileutils2"
)
var deployConfigActions = sets.NewString("", "create", "append")
func ValidateDeployConfigs(deploys []*api.DeployConfig) error {
for i, d := range deploys {
if d == nil {
continue
}
if !deployConfigActions.Has(d.Action) {
return httperrors.NewInputParameterError("invalid deploy action %q", d.Action)
}
clean, err := fileutils2.CleanGuestDeployPath(d.Path)
if err != nil {
return httperrors.NewInputParameterError("deploy_configs[%d].path: %v", i, err)
}
d.Path = clean
}
return nil
}

View File

@@ -0,0 +1,53 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or authorized to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package models
import (
"testing"
api "yunion.io/x/onecloud/pkg/apis/compute"
)
func TestValidateDeployConfigs(t *testing.T) {
cfgs := []*api.DeployConfig{
{Action: "create", Path: "/etc/hosts", Content: "x"},
}
if err := ValidateDeployConfigs(cfgs); err != nil {
t.Fatalf("valid: %v", err)
}
if cfgs[0].Path != "/etc/hosts" {
t.Fatalf("path %q", cfgs[0].Path)
}
rel := []*api.DeployConfig{{Action: "create", Path: "attack/etc/pwn"}}
if err := ValidateDeployConfigs(rel); err == nil {
t.Fatal("expected relative path to fail")
}
root := []*api.DeployConfig{{Action: "create", Path: "/"}}
if err := ValidateDeployConfigs(root); err == nil {
t.Fatal("expected / to fail")
}
badAct := []*api.DeployConfig{{Action: "exec", Path: "/etc/hosts"}}
if err := ValidateDeployConfigs(badAct); err == nil {
t.Fatal("expected invalid action to fail")
}
cleaned := []*api.DeployConfig{{Path: "/etc/../root/.ssh/authorized_keys"}}
if err := ValidateDeployConfigs(cleaned); err != nil {
t.Fatalf("cleaned path: %v", err)
}
if cleaned[0].Path != "/root/.ssh/authorized_keys" {
t.Fatalf("got %q", cleaned[0].Path)
}
}

View File

@@ -1762,6 +1762,10 @@ func (manager *SGuestManager) validateCreateData(
return nil, err
}
if err := ValidateDeployConfigs(input.DeployConfigs); err != nil {
return nil, err
}
if len(input.Metadata) > 20 {
return nil, httperrors.NewInputParameterError("metadata must be less than 20")
}
@@ -5606,6 +5610,9 @@ func (self *SGuest) GetDeployConfigOnHost(ctx context.Context, userCred mcclient
if err != nil {
return nil, err
}
if err := ValidateDeployConfigs(deploys); err != nil {
return nil, err
}
if len(deploys) > 0 {
config.Add(jsonutils.Marshal(deploys), "deploys")

View File

@@ -31,6 +31,7 @@ import (
"yunion.io/x/onecloud/pkg/httperrors"
"yunion.io/x/onecloud/pkg/mcclient"
modules "yunion.io/x/onecloud/pkg/mcclient/modules/compute"
"yunion.io/x/onecloud/pkg/util/fileutils2"
"yunion.io/x/onecloud/pkg/util/procutils"
)
@@ -59,6 +60,11 @@ func (d *sGuestRootFsDriver) DeployFiles(deploys []*deployapi.DeployContent) err
if len(deploy.Path) == 0 {
return fmt.Errorf("Deploy file missing param path")
}
clean, err := fileutils2.CleanGuestDeployPath(deploy.Path)
if err != nil {
return errors.Wrap(err, "deploy path")
}
deploy.Path = clean
dirname := filepath.Dir(deploy.Path)
if !d.GetPartition().Exists(dirname, caseInsensitive) {
modeRWXOwner := syscall.S_IRWXU | syscall.S_IRGRP | syscall.S_IXGRP | syscall.S_IROTH | syscall.S_IXOTH

View File

@@ -562,6 +562,9 @@ func (l *sLinuxRootFs) GetArch(rootFs IDiskPartition) string {
log.Errorf("readlink of %s: %s", p, err)
continue
}
if mnt := rootFs.GetMountPath(); mnt != "" && !fileutils2.IsPathInside(mnt, rp) {
continue
}
elfHeader, err := elf.Open(rp)
if err != nil {
log.Errorf("failed read file elf %s: %s", rp, err)
@@ -2462,7 +2465,11 @@ func (d *SCoreOsRootFs) GetLoginAccount(rootFs IDiskPartition, user string, defa
func (d *SCoreOsRootFs) DeployFiles(deploys []*deployapi.DeployContent) error {
for _, deploy := range deploys {
d.GetConfig().AddWriteFile(deploy.Path, deploy.Content, "", "", false)
clean, err := fileutils2.CleanGuestDeployPath(deploy.Path)
if err != nil {
return errors.Wrap(err, "deploy path")
}
d.GetConfig().AddWriteFile(clean, deploy.Content, "", "", false)
}
return nil
}

View File

@@ -16,10 +16,10 @@ package kvmpart
import (
"fmt"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"yunion.io/x/log"
@@ -48,33 +48,111 @@ func (f *SLocalGuestFS) SupportSerialPorts() bool {
}
func (f *SLocalGuestFS) GetLocalPath(sPath string, caseInsensitive bool) string {
p, err := f.resolveLocalPath(sPath, caseInsensitive, false)
if err != nil {
return ""
}
return p
}
func (f *SLocalGuestFS) resolveLocalPath(sPath string, caseInsensitive bool, allowMissingLast bool) (string, error) {
if sPath == "." {
sPath = ""
}
var fullPath = f.mountPath
pathSegs := strings.Split(sPath, "/")
for _, seg := range pathSegs {
if len(seg) > 0 {
var realSeg string
files, _ := ioutil.ReadDir(fullPath)
for _, file := range files {
var f = file.Name()
if f == seg || (caseInsensitive && strings.ToLower(f) == strings.ToLower(seg)) ||
(seg[len(seg)-1] == '*' && (strings.HasPrefix(f, seg[:len(seg)-1]) ||
(caseInsensitive && strings.HasPrefix(strings.ToLower(f),
strings.ToLower(seg[:len(seg)-1]))))) {
realSeg = f
break
}
mount := filepath.Clean(f.mountPath)
fullPath := mount
segs := strings.Split(sPath, "/")
for i, seg := range segs {
if len(seg) == 0 || seg == "." {
continue
}
if seg == ".." {
parent := filepath.Dir(fullPath)
if !fileutils2.IsPathInside(mount, parent) {
return "", errors.Errorf("path %q is outside mount", sPath)
}
if len(realSeg) > 0 {
fullPath = path.Join(fullPath, realSeg)
} else {
return ""
fullPath = parent
continue
}
isLast := i == len(segs)-1
realSeg, fi, err := f.lookupSeg(fullPath, seg, caseInsensitive)
if err != nil {
return "", err
}
if realSeg == "" {
if allowMissingLast && isLast {
joined := filepath.Join(fullPath, seg)
if !fileutils2.IsPathInside(mount, joined) {
return "", errors.Errorf("path %q is outside mount", sPath)
}
return joined, nil
}
return "", errors.Errorf("path %q not found", sPath)
}
next := filepath.Join(fullPath, realSeg)
if !fileutils2.IsPathInside(mount, next) {
return "", errors.Errorf("path %q is outside mount", sPath)
}
if fi != nil && !isLast {
if fi.Mode()&os.ModeSymlink != 0 {
return "", errors.Errorf("path %q traverses a symlink", sPath)
}
if !fi.IsDir() {
return "", errors.Errorf("path %q traverses a non-directory", sPath)
}
}
fullPath = next
}
return fullPath
if !fileutils2.IsPathInside(mount, fullPath) {
return "", errors.Errorf("path %q is outside mount", sPath)
}
return fullPath, nil
}
func (f *SLocalGuestFS) lookupSeg(dir, seg string, caseInsensitive bool) (string, os.FileInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return "", nil, err
}
var realSeg string
for _, entry := range entries {
name := entry.Name()
match := name == seg
if !match && caseInsensitive && strings.EqualFold(name, seg) {
match = true
}
if !match && len(seg) > 0 && seg[len(seg)-1] == '*' {
prefix := seg[:len(seg)-1]
match = strings.HasPrefix(name, prefix) ||
(caseInsensitive && strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)))
}
if match {
realSeg = name
break
}
}
if realSeg == "" {
return "", nil, nil
}
fi, err := os.Lstat(filepath.Join(dir, realSeg))
if err != nil {
return "", nil, err
}
return realSeg, fi, nil
}
func (f *SLocalGuestFS) mustRegularDir(p string) error {
fi, err := os.Lstat(p)
if err != nil {
return err
}
if fi.Mode()&os.ModeSymlink != 0 {
return errors.Errorf("%s is a symlink", p)
}
if !fi.IsDir() {
return errors.Errorf("%s is not a directory", p)
}
return nil
}
func (f *SLocalGuestFS) Remove(path string, caseInsensitive bool) {
@@ -88,16 +166,25 @@ func (f *SLocalGuestFS) Mkdir(sPath string, mode int, caseInsensitive bool) erro
segs := strings.Split(sPath, "/")
sPath = ""
pPath := f.GetLocalPath("/", caseInsensitive)
if err := f.mustRegularDir(pPath); err != nil {
return err
}
for _, s := range segs {
if len(s) > 0 {
sPath = path.Join(sPath, s)
vPath := f.GetLocalPath(sPath, caseInsensitive)
if len(vPath) == 0 {
if err := f.mustRegularDir(pPath); err != nil {
return err
}
if err := os.Mkdir(path.Join(pPath, s), os.FileMode(mode)); err != nil {
return err
}
pPath = f.GetLocalPath(sPath, caseInsensitive)
} else {
if err := f.mustRegularDir(vPath); err != nil {
return err
}
pPath = vPath
}
}
@@ -108,7 +195,11 @@ func (f *SLocalGuestFS) Mkdir(sPath string, mode int, caseInsensitive bool) erro
func (f *SLocalGuestFS) ListDir(sPath string, caseInsensitive bool) []string {
sPath = f.GetLocalPath(sPath, caseInsensitive)
if len(sPath) > 0 {
files, err := ioutil.ReadDir(sPath)
if err := f.mustRegularDir(sPath); err != nil {
log.Errorln(err)
return nil
}
files, err := os.ReadDir(sPath)
if err != nil {
log.Errorln(err)
return nil
@@ -155,7 +246,7 @@ func (f *SLocalGuestFS) Passwd(account, password string, caseInsensitive bool) e
func (f *SLocalGuestFS) Stat(usrDir string, caseInsensitive bool) os.FileInfo {
sPath := f.GetLocalPath(usrDir, caseInsensitive)
if len(sPath) > 0 {
fileInfo, err := os.Stat(sPath)
fileInfo, err := os.Lstat(sPath)
if err != nil {
log.Errorln(err)
}
@@ -173,6 +264,9 @@ func (f *SLocalGuestFS) Symlink(src string, dst string, caseInsensitive bool) er
f.Remove(dst, caseInsensitive)
}
dir = f.GetLocalPath(dir, caseInsensitive)
if err := f.mustRegularDir(dir); err != nil {
return err
}
dst = path.Join(dir, path.Base(dst))
return os.Symlink(src, dst)
}
@@ -188,6 +282,9 @@ func (f *SLocalGuestFS) Exists(sPath string, caseInsensitive bool) bool {
func (f *SLocalGuestFS) Chown(sPath string, uid, gid int, caseInsensitive bool) error {
sPath = f.GetLocalPath(sPath, caseInsensitive)
if len(sPath) > 0 {
if fileutils2.IsSymlink(sPath) {
return errors.Errorf("cannot chown symlink %s", sPath)
}
return os.Chown(sPath, uid, gid)
}
return nil
@@ -196,6 +293,9 @@ func (f *SLocalGuestFS) Chown(sPath string, uid, gid int, caseInsensitive bool)
func (f *SLocalGuestFS) Chmod(sPath string, mode uint32, caseInsensitive bool) error {
sPath = f.GetLocalPath(sPath, caseInsensitive)
if len(sPath) > 0 {
if fileutils2.IsSymlink(sPath) {
return errors.Errorf("cannot chmod symlink %s", sPath)
}
return os.Chmod(sPath, os.FileMode(mode))
}
return nil
@@ -206,10 +306,14 @@ func (f *SLocalGuestFS) updateUserEtcShadow(username string) error {
if !fileutils2.Exists(sPath) {
return nil
}
content, err := fileutils2.FileGetContents(sPath)
if fileutils2.IsSymlink(sPath) {
return errors.Errorf("cannot update symlink %s", sPath)
}
contentBytes, err := fileutils2.FileGetContentsNoFollow(sPath)
if err != nil {
return errors.Wrap(err, "read /etc/shadow")
}
content := string(contentBytes)
var (
minimumDays = "0" // -m 0
@@ -231,7 +335,7 @@ func (f *SLocalGuestFS) updateUserEtcShadow(username string) error {
}
}
newContent := strings.Join(lines, "\n")
err = fileutils2.FilePutContents(sPath, newContent, false)
err = fileutils2.FilePutContentsNoFollow(sPath, newContent, false)
if err != nil {
return errors.Wrapf(err, "read %s, put %s to /etc/shadow", content, newContent)
}
@@ -316,27 +420,28 @@ func (f *SLocalGuestFS) FileGetContents(sPath string, caseInsensitive bool) ([]b
}
func (f *SLocalGuestFS) FileGetContentsByPath(sPath string) ([]byte, error) {
if len(sPath) > 0 {
return ioutil.ReadFile(sPath)
if len(sPath) == 0 {
return nil, fmt.Errorf("Cann't find local path")
}
return nil, fmt.Errorf("Cann't find local path")
if fileutils2.IsSymlink(sPath) {
return nil, errors.Errorf("cannot read symlink %s", sPath)
}
return fileutils2.FileGetContentsNoFollow(sPath)
}
func (f *SLocalGuestFS) FilePutContents(sPath, content string, modAppend, caseInsensitive bool) error {
sFilePath := f.GetLocalPath(sPath, caseInsensitive)
if len(sFilePath) > 0 {
sPath = sFilePath
} else {
dirPath := f.GetLocalPath(path.Dir(sPath), caseInsensitive)
if len(dirPath) > 0 {
sPath = path.Join(dirPath, path.Base(sPath))
}
target, err := f.resolveLocalPath(sPath, caseInsensitive, true)
if err != nil {
return err
}
if len(sPath) > 0 {
return fileutils2.FilePutContents(sPath, content, modAppend)
} else {
return fmt.Errorf("Can't put content to empty Path")
if fileutils2.IsSymlink(target) {
return errors.Errorf("cannot write through symlink %s", sPath)
}
parent := filepath.Dir(target)
if err := f.mustRegularDir(parent); err != nil {
return err
}
return fileutils2.FilePutContentsNoFollow(target, content, modAppend)
}
func (f *SLocalGuestFS) GenerateSshHostKeys() error {

View File

@@ -0,0 +1,100 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or authorized to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package kvmpart
import (
"os"
"path/filepath"
"testing"
)
func TestGetLocalPathDoesNotFollowSymlink(t *testing.T) {
mount := t.TempDir()
fs := NewLocalGuestFS(mount)
if err := os.MkdirAll(filepath.Join(mount, "etc"), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/", filepath.Join(mount, "attack")); err != nil {
t.Fatal(err)
}
if p := fs.GetLocalPath("/attack/etc", false); p != "" {
t.Fatalf("expected empty path, got %q", p)
}
if p := fs.GetLocalPath("/etc", false); p != filepath.Join(mount, "etc") {
t.Fatalf("got %q", p)
}
}
func TestFilePutContentsDoesNotFollowSymlink(t *testing.T) {
mount := t.TempDir()
fs := NewLocalGuestFS(mount)
if err := os.MkdirAll(filepath.Join(mount, "etc"), 0755); err != nil {
t.Fatal(err)
}
if err := os.Symlink("/", filepath.Join(mount, "attack")); err != nil {
t.Fatal(err)
}
if err := fs.FilePutContents("/attack/etc/pwn", "x", false, false); err == nil {
t.Fatal("expected write through symlink prefix to fail")
}
marker := filepath.Join(t.TempDir(), "host-target")
if err := os.WriteFile(marker, []byte("orig"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(marker, filepath.Join(mount, "etc", "hosts")); err != nil {
t.Fatal(err)
}
if err := fs.FilePutContents("/etc/hosts", "pwn", false, false); err == nil {
t.Fatal("expected write through symlink file to fail")
}
got, err := os.ReadFile(marker)
if err != nil {
t.Fatal(err)
}
if string(got) != "orig" {
t.Fatalf("host file changed: %q", got)
}
if err := fs.FilePutContents("/etc/hostname", "vm1", false, false); err != nil {
t.Fatalf("regular write: %v", err)
}
got, err = os.ReadFile(filepath.Join(mount, "etc", "hostname"))
if err != nil {
t.Fatal(err)
}
if string(got) != "vm1" {
t.Fatalf("got %q", got)
}
}
func TestMkdirDoesNotFollowSymlink(t *testing.T) {
mount := t.TempDir()
fs := NewLocalGuestFS(mount)
if err := os.Symlink("/", filepath.Join(mount, "attack")); err != nil {
t.Fatal(err)
}
if err := fs.Mkdir("/attack/etc/cron.d", 0755, false); err == nil {
t.Fatal("expected mkdir through symlink to fail")
}
if err := fs.Mkdir("/etc/cron.d", 0755, false); err != nil {
t.Fatalf("regular mkdir: %v", err)
}
if st, err := os.Stat(filepath.Join(mount, "etc", "cron.d")); err != nil || !st.IsDir() {
t.Fatalf("cron.d not created: %v", err)
}
}

View File

@@ -0,0 +1,103 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or authorized to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package fileutils2
import (
"io"
"os"
"path"
"path/filepath"
"strings"
"syscall"
"yunion.io/x/pkg/errors"
)
// IsPathInside reports whether target is the same as base or a descendant of it.
func IsPathInside(base, target string) bool {
base = filepath.Clean(base)
target = filepath.Clean(target)
rel, err := filepath.Rel(base, target)
if err != nil {
return false
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
return !filepath.IsAbs(rel)
}
// CleanGuestDeployPath requires an absolute guest path and rejects parent-dir
// traversal after cleaning.
func CleanGuestDeployPath(p string) (string, error) {
p = strings.TrimSpace(p)
if p == "" {
return "", errors.Errorf("path is empty")
}
if strings.ContainsRune(p, 0) {
return "", errors.Errorf("path contains NUL")
}
if !path.IsAbs(p) && !filepath.IsAbs(p) {
return "", errors.Errorf("path %q must be absolute", p)
}
clean := path.Clean("/" + strings.TrimPrefix(filepath.ToSlash(p), "/"))
if !path.IsAbs(clean) {
return "", errors.Errorf("path %q must be absolute", p)
}
if clean == "/" {
return "", errors.Errorf("path %q is not allowed", p)
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.Errorf("path %q is not allowed", p)
}
return clean, nil
}
func IsSymlink(path string) bool {
fi, err := os.Lstat(path)
if err != nil {
return false
}
return fi.Mode()&os.ModeSymlink != 0
}
func OpenFileNoFollow(name string, flag int, perm os.FileMode) (*os.File, error) {
return os.OpenFile(name, flag|syscall.O_NOFOLLOW, perm)
}
func FilePutContentsNoFollow(filename string, content string, modAppend bool) error {
mode := os.O_WRONLY | os.O_CREATE
if modAppend {
mode |= os.O_APPEND
} else {
mode |= os.O_TRUNC
}
fd, err := OpenFileNoFollow(filename, mode, 0644)
if err != nil {
return err
}
defer fd.Close()
_, err = fd.WriteString(content)
return err
}
func FileGetContentsNoFollow(filename string) ([]byte, error) {
fd, err := OpenFileNoFollow(filename, os.O_RDONLY, 0)
if err != nil {
return nil, err
}
defer fd.Close()
return io.ReadAll(fd)
}

View File

@@ -0,0 +1,91 @@
// Copyright 2019 Yunion
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or authorized to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package fileutils2
import (
"os"
"path/filepath"
"testing"
)
func TestIsPathInside(t *testing.T) {
if !IsPathInside("/mnt/guest", "/mnt/guest") {
t.Fatal("self")
}
if !IsPathInside("/mnt/guest", "/mnt/guest/etc/hosts") {
t.Fatal("child")
}
if IsPathInside("/mnt/guest", "/etc/hosts") {
t.Fatal("outside")
}
if IsPathInside("/mnt/guest", "/mnt/guest/../etc") {
t.Fatal("escape")
}
}
func TestFilePutContentsNoFollow(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "real")
link := filepath.Join(dir, "link")
if err := os.WriteFile(target, []byte("orig"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
if err := FilePutContentsNoFollow(link, "pwn", false); err == nil {
t.Fatal("expected write through symlink to fail")
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(got) != "orig" {
t.Fatalf("target changed: %q", got)
}
regular := filepath.Join(dir, "file")
if err := FilePutContentsNoFollow(regular, "ok", false); err != nil {
t.Fatal(err)
}
got, err = os.ReadFile(regular)
if err != nil {
t.Fatal(err)
}
if string(got) != "ok" {
t.Fatalf("got %q", got)
}
}
func TestCleanGuestDeployPath(t *testing.T) {
got, err := CleanGuestDeployPath("/etc/hosts")
if err != nil {
t.Fatal(err)
}
if got != "/etc/hosts" {
t.Fatalf("got %q", got)
}
got, err = CleanGuestDeployPath("/etc/../root/.ssh/authorized_keys")
if err != nil {
t.Fatal(err)
}
if got != "/root/.ssh/authorized_keys" {
t.Fatalf("got %q", got)
}
for _, p := range []string{"", "relative", "/", "/."} {
if _, err := CleanGuestDeployPath(p); err == nil {
t.Fatalf("expected error for %q", p)
}
}
}