From d92df9df65805259b6f3b8114d9dc939d71f5264 Mon Sep 17 00:00:00 2001 From: sammy-da Date: Wed, 26 Feb 2025 00:16:06 -0500 Subject: [PATCH] feat: option to have File store preserve permissions when extracting (#891) This PR takes care of https://github.com/oras-project/oras-go/issues/886, or rather part of it. It adds an option to the File store that's similar to tar's `--preserve-permissions`. closes https://github.com/oras-project/oras-go/issues/886 Signed-off-by: Sammy Abed --- content/file/file.go | 5 ++- content/file/utils.go | 15 +++++--- content/file/utils_test.go | 8 ++--- content/file/utils_unix_test.go | 61 +++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 content/file/utils_unix_test.go diff --git a/content/file/file.go b/content/file/file.go index db09473a..5caaedd0 100644 --- a/content/file/file.go +++ b/content/file/file.go @@ -108,6 +108,9 @@ type Store struct { // value overrides the [AnnotationUnpack]. // Default value: false. SkipUnpack bool + // PreservePermissions controls whether to preserve file permissions when unpacking, + // disregarding the active umask, similar to tar's `--preserve-permissions` + PreservePermissions bool workingDir string // the working directory of the file store closed int32 // if the store is closed - 0: false, 1: true. @@ -499,7 +502,7 @@ func (s *Store) pushDir(name, target string, expected ocispec.Descriptor, conten checksum := expected.Annotations[AnnotationDigest] buf := bufPool.Get().(*[]byte) defer bufPool.Put(buf) - if err := extractTarGzip(target, name, gzPath, checksum, *buf); err != nil { + if err := extractTarGzip(target, name, gzPath, checksum, *buf, s.PreservePermissions); err != nil { return fmt.Errorf("failed to extract tar to %s: %w", target, err) } return nil diff --git a/content/file/utils.go b/content/file/utils.go index f8544ffd..bd1f4af1 100644 --- a/content/file/utils.go +++ b/content/file/utils.go @@ -113,7 +113,7 @@ func tarDirectory(ctx context.Context, root, prefix string, w io.Writer, removeT // extractTarGzip decompresses the gzip // and extracts tar file to a directory specified by the `dir` parameter. -func extractTarGzip(dirPath, dirName, gzPath, checksum string, buf []byte) (err error) { +func extractTarGzip(dirPath, dirName, gzPath, checksum string, buf []byte, preservePermissions bool) (err error) { fp, err := os.Open(gzPath) if err != nil { return err @@ -144,7 +144,7 @@ func extractTarGzip(dirPath, dirName, gzPath, checksum string, buf []byte) (err r = io.TeeReader(r, verifier) } } - if err := extractTarDirectory(dirPath, dirName, r, buf); err != nil { + if err := extractTarDirectory(dirPath, dirName, r, buf, preservePermissions); err != nil { return err } if verifier != nil && !verifier.Verified() { @@ -156,7 +156,7 @@ func extractTarGzip(dirPath, dirName, gzPath, checksum string, buf []byte) (err // extractTarDirectory extracts tar file to a directory specified by the `dir` // parameter. The file name prefix is ensured to be the string specified by the // `prefix` parameter and is trimmed. -func extractTarDirectory(dirPath, dirName string, r io.Reader, buf []byte) error { +func extractTarDirectory(dirPath, dirName string, r io.Reader, buf []byte, preservePermissions bool) error { tr := tar.NewReader(r) for { header, err := tr.Next() @@ -214,7 +214,14 @@ func extractTarDirectory(dirPath, dirName string, r io.Reader, buf []byte) error } // Change access time and modification time if possible (error ignored) - os.Chtimes(filePath, header.AccessTime, header.ModTime) + _ = os.Chtimes(filePath, header.AccessTime, header.ModTime) + + // Restore full mode bits + if preservePermissions && (header.Typeflag == tar.TypeReg || header.Typeflag == tar.TypeDir) { + if err := os.Chmod(filePath, os.FileMode(header.Mode)); err != nil { + return err + } + } } } diff --git a/content/file/utils_test.go b/content/file/utils_test.go index a3ef8a75..a379ff58 100644 --- a/content/file/utils_test.go +++ b/content/file/utils_test.go @@ -238,7 +238,7 @@ func Test_ensureLinkPath(t *testing.T) { func Test_extractTarGzip_Error(t *testing.T) { t.Run("Non-existing file", func(t *testing.T) { - err := extractTarGzip("", "", "non-existing-file", "", nil) + err := extractTarGzip("", "", "non-existing-file", "", nil, false) if err == nil { t.Fatal("expected error, got nil") } @@ -300,7 +300,7 @@ func Test_extractTarDirectory(t *testing.T) { dirPath := filepath.Join(tempDir, dirName) buf := make([]byte, 1024) - if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tt.tarData), buf); (err != nil) != tt.wantErr { + if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tt.tarData), buf, false); (err != nil) != tt.wantErr { t.Fatalf("extractTarDirectory() error = %v, wantErr %v", err, tt.wantErr) } if !tt.wantErr { @@ -348,7 +348,7 @@ func Test_extractTarDirectory_HardLink(t *testing.T) { {name: "base/test_hardlink", linkname: linkPath, mode: 0666, isHardLink: true}, }) - if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tarData), buf); err != nil { + if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tarData), buf, false); err != nil { t.Fatalf("extractTarDirectory() error = %v", err) } @@ -372,7 +372,7 @@ func Test_extractTarDirectory_HardLink(t *testing.T) { {name: "base/test_hardlink", linkname: "whatever", mode: 0666, isHardLink: true}, }) - if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tarData), buf); err == nil { + if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tarData), buf, false); err == nil { t.Error("extractTarDirectory() error = nil, wantErr = true") } }) diff --git a/content/file/utils_unix_test.go b/content/file/utils_unix_test.go new file mode 100644 index 00000000..4ecd9880 --- /dev/null +++ b/content/file/utils_unix_test.go @@ -0,0 +1,61 @@ +//go:build !windows + +/* +Copyright The ORAS Authors. +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 file + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func Test_extractTarDirectory_PreservePermissions(t *testing.T) { + fileContent := "hello world" + fileMode := os.FileMode(0771) + tarData := createTar(t, []tarEntry{ + {name: "base/", mode: os.ModeDir | 0777}, + {name: "base/test.txt", content: fileContent, mode: fileMode}, + }) + + tempDir := t.TempDir() + dirName := "base" + dirPath := filepath.Join(tempDir, dirName) + buf := make([]byte, 1024) + + if err := extractTarDirectory(dirPath, dirName, bytes.NewReader(tarData), buf, true); err != nil { + t.Fatalf("extractTarDirectory() error = %v", err) + } + + filePath := filepath.Join(dirPath, "test.txt") + fi, err := os.Lstat(filePath) + if err != nil { + t.Fatalf("failed to stat file %s: %v", filePath, err) + } + + gotContent, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("failed to read file %s: %v", filePath, err) + } + if string(gotContent) != fileContent { + t.Errorf("file content = %s, want %s", gotContent, fileContent) + } + + if fi.Mode() != fileMode { + t.Errorf("file %q mode = %s, want %s", fi.Name(), fi.Mode(), fileMode) + } +}