Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 18 additions & 7 deletions extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,10 +193,9 @@ func (e *Extractor) Extract(ctx context.Context) (err error) {
return err
}

// handle deferred symlink creation and update directory metadata
// (otherwise modification dates are incorrect)
// Create all symlinks. This will update parent directory mtimes to current time.
for _, file := range e.zr.File {
if file.Mode()&os.ModeSymlink == 0 && !file.Mode().IsDir() {
if file.Mode()&os.ModeSymlink == 0 {
continue
}

Expand All @@ -205,13 +204,25 @@ func (e *Extractor) Extract(ctx context.Context) (err error) {
return err
}

if file.Mode()&os.ModeSymlink != 0 {
if err := e.createSymlink(path, file); err != nil {
return err
}
// createSymlink() handles the symlink's own timestamp preservation
// but will update the parent directory's mtime to current time.
if err := e.createSymlink(path, file); err != nil {
return err
}
}

// Update ALL directory metadata after symlinks are created.
// This ensures all directory timestamps and permissions are correctly preserved.
for _, file := range e.zr.File {
if !file.Mode().IsDir() {
continue
}

path, err := filepath.Abs(filepath.Join(e.chroot, file.Name))
if err != nil {
return err
}

err = e.updateFileMetadata(path, file)
if err != nil {
return err
Expand Down
109 changes: 109 additions & 0 deletions extractor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/klauspost/compress/zip"
"github.com/klauspost/compress/zstd"
Expand Down Expand Up @@ -267,6 +268,114 @@ func benchmarkExtractOptions(b *testing.B, stdDeflate bool, ao []ArchiverOption,
}
}

func TestExtractSymlinkDirectoryTimestamps(t *testing.T) {
// Create a specific past time for testing (different from fixedModTime used by testCreateFiles)
pastTime := time.Date(2019, 3, 15, 14, 30, 0, 0, time.UTC)

testFiles := map[string]testFile{
"target_file": {mode: 0644, contents: "target content"},
"parent_dir": {mode: 0755 | os.ModeDir},
"parent_dir/symlink": {mode: 0777 | os.ModeSymlink, contents: "../target_file"},
"another_dir": {mode: 0755 | os.ModeDir},
"another_dir/file.txt": {mode: 0644, contents: "regular file"},
}

// Create files using the existing test helper
files, dir := testCreateFiles(t, testFiles)
defer os.RemoveAll(dir)

// Override timestamps on directories to our specific past time
// (testCreateFiles sets all timestamps to fixedModTime = 2020-02-01)
require.NoError(t, os.Chtimes(filepath.Join(dir, "parent_dir"), pastTime, pastTime))
require.NoError(t, os.Chtimes(filepath.Join(dir, "another_dir"), pastTime, pastTime))

// Update the FileInfo in the map to reflect the new timestamps
parentDirPath := filepath.Join(dir, "parent_dir")
anotherDirPath := filepath.Join(dir, "another_dir")

parentDirInfo, err := os.Lstat(parentDirPath)
require.NoError(t, err)
anotherDirInfo, err := os.Lstat(anotherDirPath)
require.NoError(t, err)

// Update the FileInfo entries using the exact absolute paths
files[parentDirPath] = parentDirInfo
files[anotherDirPath] = anotherDirInfo

testCreateArchive(t, dir, files, func(filename, chroot string) {
// Extract to a new directory
extractDir := t.TempDir()
e, err := NewExtractor(filename, extractDir)
require.NoError(t, err)
defer e.Close()

// Wait a bit to ensure current time is different from pastTime
time.Sleep(50 * time.Millisecond)
currentTime := time.Now()

require.NoError(t, e.Extract(context.Background()))

// Check that directory containing symlink preserved its timestamp
parentDirPath := filepath.Join(extractDir, "parent_dir")
parentDirInfo, err := os.Lstat(parentDirPath)
require.NoError(t, err)

// The directory timestamp should match the original archived time,
// not the current extraction time
actualTime := parentDirInfo.ModTime().UTC().Truncate(time.Second)
expectedTime := pastTime.Truncate(time.Second)
extractTime := currentTime.UTC().Truncate(time.Second)

assert.Equal(t, expectedTime, actualTime,
"Directory containing symlink should preserve original timestamp (%v), not extraction time (%v)",
expectedTime, extractTime)

// Also check that regular directory (without symlink) preserves timestamp
anotherDirPath := filepath.Join(extractDir, "another_dir")
anotherDirInfo, err := os.Lstat(anotherDirPath)
require.NoError(t, err)

actualTime2 := anotherDirInfo.ModTime().UTC().Truncate(time.Second)
assert.Equal(t, expectedTime, actualTime2,
"Regular directory should also preserve original timestamp")

// Verify symlink itself exists and points to correct target
symlinkPath := filepath.Join(extractDir, "parent_dir", "symlink")
symlinkInfo, err := os.Lstat(symlinkPath)
require.NoError(t, err)

// Verify it's actually a symlink
assert.True(t, symlinkInfo.Mode()&os.ModeSymlink != 0,
"Should be a symlink")

// Verify symlink points to correct target
target, err := os.Readlink(symlinkPath)
require.NoError(t, err)

// Test that the symlink actually resolves to the expected file
expectedTargetPath := filepath.Join(extractDir, "target_file")
actualTargetPath := filepath.Join(filepath.Dir(symlinkPath), target)
actualTargetPath = filepath.Clean(actualTargetPath)

// Verify both paths point to the same file
expectedInfo, err := os.Stat(expectedTargetPath)
require.NoError(t, err)
actualInfo, err := os.Stat(actualTargetPath)
require.NoError(t, err)

// Compare file contents or other properties to ensure they're the same file
assert.Equal(t, expectedInfo.Size(), actualInfo.Size(),
"Symlink should resolve to the correct target file")

// The key assertion: ensure directories containing symlinks
// don't have their timestamps updated during symlink creation
timeDifference := actualTime.Sub(extractTime).Abs()
assert.Greater(t, timeDifference, time.Duration(30*time.Second),
"Directory timestamp should be significantly different from extraction time, "+
"indicating it was preserved from the archive rather than updated during extraction")
})
}

func BenchmarkExtractStore_1(b *testing.B) {
benchmarkExtractOptions(b, true, aopts(WithArchiverMethod(zip.Store)), WithExtractorConcurrency(1))
}
Expand Down
Loading