e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
572 lines
15 KiB
Go
572 lines
15 KiB
Go
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
//
|
|
// 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 agreed 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 isolation
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/vfs"
|
|
)
|
|
|
|
// MergedView provides a userspace overlay filesystem view: reads check upper
|
|
// first then fall through to lower; writes always go to upper.
|
|
//
|
|
// Limitation (overlay mode): MergedView writes to the upper directory on the
|
|
// host are NOT visible inside a running bwrap overlayfs mount. The kernel VFS
|
|
// caches directory entries at mount time; direct modifications to the upper
|
|
// directory bypass the overlay and are invisible to processes inside the
|
|
// namespace. Only the Run→API direction works (process writes go through the
|
|
// overlay to upper, MergedView reads upper on host). For bidirectional
|
|
// file exchange, use workspace mode "rw" instead of "overlay".
|
|
// Compile-time check: MergedView satisfies vfs.FS.
|
|
var _ vfs.FS = (*MergedView)(nil)
|
|
|
|
type MergedView struct {
|
|
LowerDir string
|
|
UpperDir string
|
|
Uid, Gid uint32
|
|
Mode WorkspaceMode
|
|
}
|
|
|
|
// NewMergedView creates a merged view. upperDir may be empty (tmpfs).
|
|
func NewMergedView(lower, upper string, mode WorkspaceMode, uid, gid uint32) *MergedView {
|
|
return &MergedView{
|
|
LowerDir: lower,
|
|
UpperDir: upper,
|
|
Uid: uid,
|
|
Gid: gid,
|
|
Mode: mode,
|
|
}
|
|
}
|
|
|
|
// resolveUpper returns the upper path for a relative path.
|
|
func (m *MergedView) resolveUpper(rel string) string {
|
|
return filepath.Join(m.UpperDir, rel)
|
|
}
|
|
|
|
// resolveLower returns the lower path for a relative path.
|
|
func (m *MergedView) resolveLower(rel string) string {
|
|
return filepath.Join(m.LowerDir, rel)
|
|
}
|
|
|
|
// safePath validates and returns a path relative to the workspace.
|
|
// Absolute paths under LowerDir are stripped to relative; all others
|
|
// are cleaned normally.
|
|
func (m *MergedView) safePath(path string) (string, error) {
|
|
cleaned := filepath.Clean(path)
|
|
// Strip workspace prefix from absolute paths so Join works correctly.
|
|
if m.LowerDir != "" && strings.HasPrefix(cleaned, m.LowerDir+"/") {
|
|
cleaned = strings.TrimPrefix(cleaned, m.LowerDir+"/")
|
|
} else if m.LowerDir != "" && cleaned == m.LowerDir {
|
|
cleaned = "."
|
|
}
|
|
if strings.HasPrefix(cleaned, "..") {
|
|
return "", fmt.Errorf("path traversal denied: %s", path)
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func (m *MergedView) rejectSymlink(path string) error {
|
|
// Check every existing component, not just the final element.
|
|
var check string
|
|
for _, seg := range strings.Split(filepath.Clean(path), string(filepath.Separator)) {
|
|
if seg == "" {
|
|
check = string(filepath.Separator)
|
|
continue
|
|
}
|
|
check = filepath.Join(check, seg)
|
|
info, err := os.Lstat(check)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return fmt.Errorf("symlink target denied: %s", check)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// createWhiteout creates a whiteout marker in upper to hide a lower entry.
|
|
func (m *MergedView) createWhiteout(rel string) error {
|
|
whName := filepath.Join(filepath.Dir(rel), ".wh."+filepath.Base(rel))
|
|
whPath := m.resolveUpper(whName)
|
|
if err := os.MkdirAll(filepath.Dir(whPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.Create(whPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return f.Close()
|
|
}
|
|
|
|
// hasWhiteout checks if a whiteout marker exists for the given relative path.
|
|
func (m *MergedView) hasWhiteout(rel string) bool {
|
|
if m.UpperDir == "" {
|
|
return false
|
|
}
|
|
whName := filepath.Join(filepath.Dir(rel), ".wh."+filepath.Base(rel))
|
|
_, err := os.Lstat(m.resolveUpper(whName))
|
|
return err == nil
|
|
}
|
|
|
|
// Stat returns file info for a path. Checks upper first, then lower.
|
|
func (m *MergedView) Stat(path string) (os.FileInfo, error) {
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if m.UpperDir != "" {
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := m.rejectSymlink(upperPath); err != nil {
|
|
return nil, err
|
|
}
|
|
if info, err := os.Lstat(upperPath); err == nil {
|
|
return info, nil
|
|
}
|
|
if m.hasWhiteout(rel) {
|
|
return nil, &os.PathError{Op: "stat", Path: path, Err: os.ErrNotExist}
|
|
}
|
|
}
|
|
return os.Lstat(m.resolveLower(rel))
|
|
}
|
|
|
|
// ReadDir lists directory contents, merging upper and lower entries.
|
|
func (m *MergedView) ReadDir(path string) ([]os.DirEntry, error) {
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
entryMap := make(map[string]os.DirEntry)
|
|
|
|
// Lower first.
|
|
lowerEntries, lowerErr := os.ReadDir(m.resolveLower(rel))
|
|
for _, e := range lowerEntries {
|
|
entryMap[e.Name()] = e
|
|
}
|
|
|
|
// Upper overlays — takes precedence over lower.
|
|
var upperErr error
|
|
if m.UpperDir != "" {
|
|
var upperEntries []os.DirEntry
|
|
upperEntries, upperErr = os.ReadDir(m.resolveUpper(rel))
|
|
for _, e := range upperEntries {
|
|
if strings.HasPrefix(e.Name(), ".wh.") {
|
|
origName := strings.TrimPrefix(e.Name(), ".wh.")
|
|
delete(entryMap, origName)
|
|
continue
|
|
}
|
|
entryMap[e.Name()] = e
|
|
}
|
|
}
|
|
|
|
if len(entryMap) == 0 && os.IsNotExist(lowerErr) && (m.UpperDir == "" || os.IsNotExist(upperErr)) {
|
|
return nil, &os.PathError{Op: "readdir", Path: path, Err: os.ErrNotExist}
|
|
}
|
|
|
|
entries := make([]os.DirEntry, 0, len(entryMap))
|
|
for _, e := range entryMap {
|
|
entries = append(entries, e)
|
|
}
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
return entries[i].Name() < entries[j].Name()
|
|
})
|
|
return entries, nil
|
|
}
|
|
|
|
// Open opens a file for reading. Checks upper first, then lower.
|
|
func (m *MergedView) Open(path string) (*os.File, error) {
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if m.UpperDir != "" {
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := m.rejectSymlink(upperPath); err != nil {
|
|
return nil, err
|
|
}
|
|
if f, err := os.Open(upperPath); err == nil {
|
|
return f, nil
|
|
}
|
|
if m.hasWhiteout(rel) {
|
|
return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrNotExist}
|
|
}
|
|
}
|
|
return os.Open(m.resolveLower(rel))
|
|
}
|
|
|
|
// ReadFile reads file content. Checks upper first, then lower.
|
|
func (m *MergedView) ReadFile(path string) ([]byte, error) {
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if m.UpperDir != "" {
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := m.rejectSymlink(upperPath); err != nil {
|
|
return nil, err
|
|
}
|
|
if data, err := os.ReadFile(upperPath); err == nil {
|
|
return data, nil
|
|
}
|
|
if m.hasWhiteout(rel) {
|
|
return nil, &os.PathError{Op: "read", Path: path, Err: os.ErrNotExist}
|
|
}
|
|
}
|
|
return os.ReadFile(m.resolveLower(rel))
|
|
}
|
|
|
|
// WriteFile writes data to upper directory.
|
|
// In overlay mode, files written here are visible to subsequent MergedView
|
|
// reads but NOT to processes inside the bwrap namespace (see MergedView doc).
|
|
func (m *MergedView) WriteFile(path string, data []byte, perm os.FileMode) error {
|
|
if m.Mode == WorkspaceRO {
|
|
return fmt.Errorf("write denied: workspace is read-only")
|
|
}
|
|
if m.UpperDir == "" {
|
|
return fmt.Errorf("no upper directory")
|
|
}
|
|
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := m.rejectSymlink(upperPath); err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(upperPath, data, perm); err != nil {
|
|
return err
|
|
}
|
|
return os.Chown(upperPath, int(m.Uid), int(m.Gid))
|
|
}
|
|
|
|
// WriteFileReader writes from a reader to upper directory.
|
|
func (m *MergedView) WriteFileReader(path string, r io.Reader, perm os.FileMode) (int64, error) {
|
|
if m.Mode == WorkspaceRO {
|
|
return 0, fmt.Errorf("write denied: workspace is read-only")
|
|
}
|
|
if m.UpperDir == "" {
|
|
return 0, fmt.Errorf("no upper directory")
|
|
}
|
|
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := m.rejectSymlink(upperPath); err != nil {
|
|
return 0, err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
f, err := os.OpenFile(upperPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer f.Close()
|
|
|
|
n, err := io.Copy(f, r)
|
|
if err != nil {
|
|
return n, err
|
|
}
|
|
return n, os.Chown(upperPath, int(m.Uid), int(m.Gid))
|
|
}
|
|
|
|
// Remove deletes a file. For overlay mode, creates a whiteout to hide
|
|
// lower-only files.
|
|
func (m *MergedView) Remove(path string) error {
|
|
if m.Mode == WorkspaceRO {
|
|
return fmt.Errorf("remove denied: workspace is read-only")
|
|
}
|
|
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.UpperDir != "" {
|
|
upperPath := m.resolveUpper(rel)
|
|
if _, err := os.Stat(upperPath); err == nil {
|
|
if err := os.Remove(upperPath); err != nil {
|
|
return err
|
|
}
|
|
// If it also exists in lower, create whiteout to hide it.
|
|
if _, err := os.Stat(m.resolveLower(rel)); err == nil {
|
|
return m.createWhiteout(rel)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// File exists only in lower — create whiteout to mask it.
|
|
lowerPath := m.resolveLower(rel)
|
|
if _, err := os.Stat(lowerPath); err == nil {
|
|
if m.UpperDir == "" {
|
|
return fmt.Errorf("remove denied: no upper directory")
|
|
}
|
|
return m.createWhiteout(rel)
|
|
}
|
|
|
|
return fs.ErrNotExist
|
|
}
|
|
|
|
// RemoveAll deletes a path recursively. In overlay mode, removes the upper
|
|
// tree and creates a whiteout to hide any lower entry at the same path.
|
|
func (m *MergedView) RemoveAll(path string) error {
|
|
if m.Mode == WorkspaceRO {
|
|
return fmt.Errorf("remove denied: workspace is read-only")
|
|
}
|
|
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.UpperDir == "" {
|
|
return fmt.Errorf("remove denied: no upper directory")
|
|
}
|
|
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := os.RemoveAll(upperPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
if _, err := os.Stat(m.resolveLower(rel)); err == nil {
|
|
return m.createWhiteout(rel)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MkdirAll creates directories in upper.
|
|
func (m *MergedView) MkdirAll(path string, perm os.FileMode) error {
|
|
if m.Mode == WorkspaceRO {
|
|
return fmt.Errorf("mkdir denied: workspace is read-only")
|
|
}
|
|
if m.UpperDir == "" {
|
|
return fmt.Errorf("no upper directory")
|
|
}
|
|
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
upperPath := m.resolveUpper(rel)
|
|
if err := os.MkdirAll(upperPath, perm); err != nil {
|
|
return err
|
|
}
|
|
return os.Chown(upperPath, int(m.Uid), int(m.Gid))
|
|
}
|
|
|
|
// Rename moves a file within upper, or copies lower→upper then creates
|
|
// a whiteout to hide the lower source.
|
|
func (m *MergedView) Rename(oldPath, newPath string) error {
|
|
if m.Mode == WorkspaceRO {
|
|
return fmt.Errorf("rename denied: workspace is read-only")
|
|
}
|
|
|
|
oldRel, err := m.safePath(oldPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
newRel, err := m.safePath(newPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.UpperDir == "" {
|
|
return fmt.Errorf("no upper directory")
|
|
}
|
|
|
|
oldUpper := m.resolveUpper(oldRel)
|
|
newUpper := m.resolveUpper(newRel)
|
|
|
|
copiedUp := false
|
|
if _, err := os.Stat(oldUpper); os.IsNotExist(err) {
|
|
data, err := os.ReadFile(m.resolveLower(oldRel))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(oldUpper), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(oldUpper, data, 0o644); err != nil { //nolint:gosec
|
|
return err
|
|
}
|
|
copiedUp = true
|
|
}
|
|
|
|
if err := os.MkdirAll(filepath.Dir(newUpper), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(oldUpper, newUpper); err != nil {
|
|
return err
|
|
}
|
|
|
|
// If source existed in lower (either copied-up or both layers),
|
|
// create whiteout to hide the lower source.
|
|
if copiedUp {
|
|
return m.createWhiteout(oldRel)
|
|
}
|
|
if _, err := os.Stat(m.resolveLower(oldRel)); err == nil {
|
|
return m.createWhiteout(oldRel)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Chmod changes permissions on a path. Copy-up from lower if needed
|
|
// to avoid mutating the original workspace.
|
|
func (m *MergedView) Chmod(path string, mode os.FileMode) error {
|
|
if m.Mode == WorkspaceRO {
|
|
return fmt.Errorf("chmod denied: workspace is read-only")
|
|
}
|
|
|
|
rel, err := m.safePath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if m.UpperDir != "" {
|
|
upperPath := m.resolveUpper(rel)
|
|
if _, err := os.Stat(upperPath); err == nil {
|
|
return os.Chmod(upperPath, mode)
|
|
}
|
|
// File only in lower — copy-up first to avoid mutating original.
|
|
lowerPath := m.resolveLower(rel)
|
|
data, err := os.ReadFile(lowerPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(upperPath), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(upperPath, data, mode); err != nil {
|
|
return err
|
|
}
|
|
return os.Chown(upperPath, int(m.Uid), int(m.Gid))
|
|
}
|
|
|
|
if m.Mode == WorkspaceRW {
|
|
return os.Chmod(m.resolveLower(rel), mode)
|
|
}
|
|
return fmt.Errorf("chmod denied: no upper directory")
|
|
}
|
|
|
|
// Search walks the merged view under root and returns matching file paths.
|
|
// Directories are excluded from results. Whiteout entries are respected.
|
|
func (m *MergedView) Search(root, pattern string) ([]string, error) { //nolint:gocognit
|
|
rootRel, err := m.safePath(root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var results []string
|
|
seen := make(map[string]bool)
|
|
whiteouts := make(map[string]bool)
|
|
|
|
// Collect whiteouts from upper first.
|
|
if m.UpperDir != "" {
|
|
upperRoot := m.resolveUpper(rootRel)
|
|
_ = filepath.WalkDir(upperRoot, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil //nolint:nilerr // skip unreadable dirs
|
|
}
|
|
if strings.HasPrefix(d.Name(), ".wh.") {
|
|
rel, _ := filepath.Rel(m.UpperDir, p)
|
|
origName := strings.TrimPrefix(d.Name(), ".wh.")
|
|
origRel := filepath.Join(filepath.Dir(rel), origName)
|
|
whiteouts[origRel] = true
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Walk lower.
|
|
if m.LowerDir != "" {
|
|
lowerRoot := m.resolveLower(rootRel)
|
|
_ = filepath.WalkDir(lowerRoot, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil //nolint:nilerr // skip unreadable dirs
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(m.LowerDir, p)
|
|
if whiteouts[rel] {
|
|
return nil
|
|
}
|
|
if matched, _ := filepath.Match(pattern, filepath.Base(rel)); matched {
|
|
seen[rel] = true
|
|
results = append(results, rel)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// Walk upper.
|
|
if m.UpperDir != "" {
|
|
upperRoot := m.resolveUpper(rootRel)
|
|
_ = filepath.WalkDir(upperRoot, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil //nolint:nilerr // skip unreadable dirs
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(d.Name(), ".wh.") {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(m.UpperDir, p)
|
|
if matched, _ := filepath.Match(pattern, filepath.Base(rel)); matched && !seen[rel] {
|
|
results = append(results, rel)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
sort.Strings(results)
|
|
return results, nil
|
|
}
|
|
|
|
// ReplaceContent reads a file, replaces text, and writes to upper.
|
|
func (m *MergedView) ReplaceContent(path, old, newStr string) error {
|
|
data, err := m.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
content := strings.ReplaceAll(string(data), old, newStr)
|
|
return m.WriteFile(path, []byte(content), 0o644) //nolint:gosec
|
|
}
|