Record file mode in the manifest, 0000 unless asked (closes #161)
check / check (push) Failing after 4s

MFFilePath gains mode (field 304): a file's permission bits, 0777 at
most, or 0000, meaning none recorded. gen and freshen record real modes
only with --include-permissions (ScannerOptions.IncludePermissions); the
builder keeps only mode.Perm(), so setuid, setgid and sticky are never
written. list -l and export show the mode in octal. check reports
MODE_MISMATCH for a recorded mode other than 0000 the file lacks. fetch
refuses a manifest with a mode above 0777 before requesting any file,
sets only the permission bits of each recorded mode on the files it
writes, and downloads again a present file whose mode differs. The decoding-cost bound counts a file
entry at 176 bytes, up from 160.

Model: opus-5-5
This commit was merged in pull request #163.
This commit is contained in:
2026-10-06 11:43:18 +02:00
parent ce66f7c1c1
commit 2a270b40c5
26 changed files with 652 additions and 106 deletions
+7
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"sort"
"strings"
"sync"
@@ -137,12 +138,14 @@ func (b *Builder) SetSeed(seed string) {
}
// AddFile reads file content from reader, computes hashes, and adds to manifest.
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
// Progress updates are sent to the progress channel (if non-nil) without blocking.
// Returns the number of bytes read.
func (b *Builder) AddFile(
path RelFilePath,
size FileSize,
mtime ModTime,
mode fs.FileMode,
reader io.Reader,
progress chan<- FileHashProgress,
) (FileSize, error) {
@@ -198,6 +201,7 @@ func (b *Builder) AddFile(
{MultiHash: mh},
},
Mtime: mtime.Timestamp(),
Mode: uint32(mode.Perm()),
}
b.mu.Lock()
@@ -229,12 +233,14 @@ func (b *Builder) FileCount() int {
// AddFileWithHash adds a file entry with a pre-computed hash.
// This is useful when the hash is already known (e.g., from an existing manifest).
// Only mode's permission bits (mode.Perm()) are recorded; 0 records none.
// Returns an error if path is invalid, size is negative, or hash is not a
// multihash with a digest of at least 32 bytes, as long as SHA-256's.
func (b *Builder) AddFileWithHash(
path RelFilePath,
size FileSize,
mtime ModTime,
mode fs.FileMode,
hash Multihash,
) error {
err := ValidatePath(string(path))
@@ -268,6 +274,7 @@ func (b *Builder) AddFileWithHash(
{MultiHash: hash},
},
Mtime: mtime.Timestamp(),
Mode: uint32(mode.Perm()),
}
b.mu.Lock()
+18 -18
View File
@@ -35,7 +35,7 @@ func TestBuilderAddFile(t *testing.T) {
reader := bytes.NewReader(content)
bytesRead, err := b.AddFile(
"test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil,
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
)
require.NoError(t, err)
assert.Equal(t, FileSize(len(content)), bytesRead)
@@ -49,7 +49,7 @@ func TestBuilderAddFileWithHash(t *testing.T) {
hash, err := multihash.Encode(make([]byte, sha256.Size), multihash.SHA2_256)
require.NoError(t, err)
err = b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), hash)
err = b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, hash)
require.NoError(t, err)
assert.Equal(t, 1, b.FileCount())
}
@@ -64,7 +64,7 @@ func TestBuilderAddFileWithHashValidation(t *testing.T) {
t.Parallel()
b := NewBuilder()
err := b.AddFileWithHash("", 100, ModTime(time.Now()), sha256Hash)
err := b.AddFileWithHash("", 100, ModTime(time.Now()), 0, sha256Hash)
require.Error(t, err)
assert.Contains(t, err.Error(), "path")
})
@@ -73,7 +73,7 @@ func TestBuilderAddFileWithHashValidation(t *testing.T) {
t.Parallel()
b := NewBuilder()
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), sha256Hash)
err := b.AddFileWithHash("test.txt", -1, ModTime(time.Now()), 0, sha256Hash)
require.Error(t, err)
assert.Contains(t, err.Error(), "size")
})
@@ -82,7 +82,7 @@ func TestBuilderAddFileWithHashValidation(t *testing.T) {
t.Parallel()
b := NewBuilder()
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), sha256Hash)
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, sha256Hash)
require.NoError(t, err)
assert.Equal(t, 1, b.FileCount())
})
@@ -118,7 +118,7 @@ func TestBuilderAddFileWithHashRejectsBadHashes(t *testing.T) {
t.Parallel()
b := NewBuilder()
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), tt.hash)
err := b.AddFileWithHash("test.txt", 100, ModTime(time.Now()), 0, tt.hash)
require.ErrorIs(t, err, tt.want)
assert.Equal(t, 0, b.FileCount())
})
@@ -133,7 +133,7 @@ func TestBuilderBuild(t *testing.T) {
reader := bytes.NewReader(content)
_, err := b.AddFile(
"test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil,
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
)
require.NoError(t, err)
@@ -196,7 +196,7 @@ func TestBuilderDeterministicOutput(t *testing.T) {
for _, f := range files {
r := bytes.NewReader([]byte(f.content))
_, err := b.AddFile(
RelFilePath(f.path), FileSize(len(f.content)), mtime, r, nil,
RelFilePath(f.path), FileSize(len(f.content)), mtime, 0, r, nil,
)
require.NoError(t, err)
}
@@ -279,7 +279,7 @@ func TestBuilderAddFileSizeMismatch(t *testing.T) {
reader := bytes.NewReader(content)
// Declare wrong size
_, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), reader, nil)
_, err := b.AddFile("test.txt", FileSize(100), ModTime(time.Now()), 0, reader, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "size mismatch")
}
@@ -291,12 +291,12 @@ func TestBuilderAddFileInvalidPath(t *testing.T) {
content := []byte("data")
reader := bytes.NewReader(content)
_, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), reader, nil)
_, err := b.AddFile("", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil)
require.Error(t, err)
reader.Reset(content)
_, err = b.AddFile(
"/absolute", FileSize(len(content)), ModTime(time.Now()), reader, nil,
"/absolute", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
)
assert.Error(t, err)
}
@@ -310,7 +310,7 @@ func TestBuilderAddFileWithProgress(t *testing.T) {
progress := make(chan FileHashProgress, 100)
bytesRead, err := b.AddFile(
"test.txt", FileSize(len(content)), ModTime(time.Now()), reader, progress,
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, progress,
)
close(progress)
require.NoError(t, err)
@@ -345,7 +345,7 @@ func TestBuilderBuildRoundTrip(t *testing.T) {
for _, f := range files {
reader := bytes.NewReader(f.content)
_, err := b.AddFile(
RelFilePath(f.path), FileSize(len(f.content)), ModTime(now), reader, nil,
RelFilePath(f.path), FileSize(len(f.content)), ModTime(now), 0, reader, nil,
)
require.NoError(t, err)
}
@@ -387,7 +387,7 @@ func TestBuilderBuildRoundTripLargeManifest(t *testing.T) {
for i := range 4000 {
path := RelFilePath(fmt.Sprintf("dir/file-%05d.txt", i))
require.NoError(t, b.AddFileWithHash(path, FileSize(i), ModTime{}, hash))
require.NoError(t, b.AddFileWithHash(path, FileSize(i), ModTime{}, 0, hash))
}
var buf bytes.Buffer
@@ -452,7 +452,7 @@ func TestManifestString(t *testing.T) {
content := []byte("test")
reader := bytes.NewReader(content)
_, err := b.AddFile(
"test.txt", FileSize(len(content)), ModTime(time.Now()), reader, nil,
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
)
require.NoError(t, err)
@@ -484,7 +484,7 @@ func TestBuilderOmitsCreatedAtByDefault(t *testing.T) {
b := NewBuilder()
content := []byte("hello")
_, err := b.AddFile(
"test.txt", FileSize(len(content)), ModTime(time.Now()),
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0,
bytes.NewReader(content), nil,
)
require.NoError(t, err)
@@ -506,7 +506,7 @@ func TestBuilderIncludesCreatedAtWhenRequested(t *testing.T) {
content := []byte("hello")
_, err := b.AddFile(
"test.txt", FileSize(len(content)), ModTime(time.Now()),
"test.txt", FileSize(len(content)), ModTime(time.Now()), 0,
bytes.NewReader(content), nil,
)
require.NoError(t, err)
@@ -532,7 +532,7 @@ func TestBuilderDeterministicFileOrder(t *testing.T) {
content := []byte("content of " + name)
_, err := b.AddFile(
RelFilePath(name), FileSize(len(content)),
ModTime(time.Unix(1000, 0)), bytes.NewReader(content), nil,
ModTime(time.Unix(1000, 0)), 0, bytes.NewReader(content), nil,
)
require.NoError(t, err)
}
+17 -3
View File
@@ -36,6 +36,7 @@ const (
StatusMissing // File not found on disk
StatusSizeMismatch // File size differs from manifest
StatusHashMismatch // File hash differs from manifest
StatusModeMismatch // File permission bits differ from a recorded mode
StatusExtra // File exists on disk but not in manifest
StatusError // Error occurred during verification
)
@@ -50,6 +51,8 @@ func (s Status) String() string {
return "SIZE_MISMATCH"
case StatusHashMismatch:
return "HASH_MISMATCH"
case StatusModeMismatch:
return "MODE_MISMATCH"
case StatusExtra:
return "EXTRA"
case StatusError:
@@ -408,11 +411,22 @@ func (c *Checker) checkFile(entry *MFFilePath, checkedBytes *FileSize) Result {
return Result{Path: relPath, Status: StatusError, Message: err.Error()}
}
// Check against all hashes in manifest (at least one must match)
// Check against all hashes in manifest (at least one must match),
// then against the recorded mode, where one is: 0 means none was.
for _, hash := range entry.GetHashes() {
if bytes.Equal(computed, hash.GetMultiHash()) {
return Result{Path: relPath, Status: StatusOK}
if !bytes.Equal(computed, hash.GetMultiHash()) {
continue
}
if entry.GetMode() != 0 && info.Mode().Perm() != os.FileMode(entry.GetMode()) {
return Result{
Path: relPath,
Status: StatusModeMismatch,
Message: "mode mismatch",
}
}
return Result{Path: relPath, Status: StatusOK}
}
return Result{
+52 -2
View File
@@ -34,6 +34,7 @@ func TestStatusString(t *testing.T) {
{StatusMissing, "MISSING"},
{StatusSizeMismatch, "SIZE_MISMATCH"},
{StatusHashMismatch, "HASH_MISMATCH"},
{StatusModeMismatch, "MODE_MISMATCH"},
{StatusExtra, "EXTRA"},
{StatusError, "ERROR"},
{Status(99), "UNKNOWN"},
@@ -59,7 +60,7 @@ func createTestManifest(
for path, content := range files {
reader := bytes.NewReader(content)
_, err := builder.AddFile(
RelFilePath(path), FileSize(len(content)), ModTime(time.Now()), reader, nil,
RelFilePath(path), FileSize(len(content)), ModTime(time.Now()), 0, reader, nil,
)
require.NoError(t, err)
}
@@ -279,7 +280,8 @@ func TestCheckMissingFile(t *testing.T) {
missingCount++
assert.Equal(t, RelFilePath("missing.txt"), r.Path)
case StatusSizeMismatch, StatusHashMismatch, StatusExtra, StatusError:
case StatusSizeMismatch, StatusHashMismatch, StatusModeMismatch,
StatusExtra, StatusError:
// Not expected in this test; counted assertions below will fail.
}
}
@@ -349,6 +351,54 @@ func TestCheckHashMismatch(t *testing.T) {
assert.Equal(t, RelFilePath(testFileName), r.Path)
}
// A recorded mode other than 0000 that differs from the file's permission
// bits fails the check; a recorded 0000 is never checked.
func TestCheckMode(t *testing.T) {
t.Parallel()
for _, tc := range []struct {
name string
recorded os.FileMode
onDisk os.FileMode
want Status
}{
{"recorded mode matches", 0o640, 0o640, StatusOK},
{"recorded mode differs", 0o640, 0o600, StatusModeMismatch},
{"0000 is not checked", 0, 0o600, StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
content := []byte("content")
b := NewBuilder()
_, err := b.AddFile(testFileName, FileSize(len(content)), ModTime{},
tc.recorded, bytes.NewReader(content), nil)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, b.Build(context.Background(), &buf))
require.NoError(t, afero.WriteFile(fs, testManifestPath, buf.Bytes(), 0o644))
require.NoError(t, fs.MkdirAll(testDataDir, 0o755))
require.NoError(t, afero.WriteFile(fs,
testDataDir+"/"+testFileName, content, tc.onDisk))
chk, err := NewChecker(&CheckerOptions{
ManifestPath: testManifestPath,
BasePath: testDataDir,
Fs: fs,
})
require.NoError(t, err)
results := make(chan Result, 1)
require.NoError(t, chk.Check(context.Background(), results, nil))
assert.Equal(t, tc.want, (<-results).Status)
})
}
}
func TestCheckWithProgress(t *testing.T) {
t.Parallel()
+4 -4
View File
@@ -29,8 +29,8 @@ const (
// Bytes decoding sets aside for each file entry, hash, timestamp and
// MIME type, however short its encoding. checkDecodedSize refuses an
// inner message for which these add up to more than maxDecodedGrowth
// times its size.
decodedFileEntrySize = 160
// times its size. The mode is held in the file entry itself.
decodedFileEntrySize = 176
decodedHashSize = 112
decodedTimestampSize = 64
decodedMIMETypeSize = 16
@@ -38,7 +38,7 @@ const (
// Each file entry mfer writes holds a path of at least one byte, a
// multihash at least as long as SHA-256's 34 bytes (AddFileWithHash
// refuses shorter ones) and a modification time: at least 47 bytes,
// counted at 336. So its manifests add up to at most about 7.15 times
// their size, and this limit is about 12% above that.
// counted at 352. So its manifests add up to at most about 7.49 times
// their size, and this limit is about 7% above that.
maxDecodedGrowth = 8
)
+9 -9
View File
@@ -119,11 +119,11 @@ func TestDeserializeRejectsInvalidEntryPaths(t *testing.T) {
}
// Entries of a path, an empty hash, an empty MIME type and empty modification
// and change times are counted at 416 bytes each (160 + 112 + 16 + 64 + 64)
// and take 16 bytes plus the path to encode. A 35-character path makes that
// 51 bytes, about 8.2 times: refused, and leaving any one of the five
// uncounted, even the MIME type, brings it under 8. A 37-character path makes
// it 53 bytes, about 7.8 times: loaded.
// and change times are counted at 432 bytes each (176 + 112 + 16 + 64 + 64)
// and take 16 bytes plus the path to encode. A 37-character path makes that
// 53 bytes, about 8.2 times: refused, and leaving any one of the five
// uncounted, even the MIME type, brings it under 8. A 39-character path makes
// it 55 bytes, about 7.9 times: loaded.
func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
t.Parallel()
@@ -131,8 +131,8 @@ func TestDeserializeRefusesEntriesThatDecodeTooLarge(t *testing.T) {
pathLen int
refused bool
}{
{35, true},
{37, false},
{37, true},
{39, false},
}
for _, tt := range tests {
@@ -215,7 +215,7 @@ func TestDeserializeLoadsDensestManifest(t *testing.T) {
const files = 10000
for i := range files {
name := RelFilePath(strconv.FormatInt(int64(i), 36))
require.NoError(t, b.AddFileWithHash(name, 0, ModTime(time.Unix(0, 0)), hash))
require.NoError(t, b.AddFileWithHash(name, 0, ModTime(time.Unix(0, 0)), 0, hash))
}
var buf bytes.Buffer
@@ -233,7 +233,7 @@ func TestDeserializeValidManifestRoundTrips(t *testing.T) {
require.NoError(t, err)
b := NewBuilder()
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, hash))
require.NoError(t, b.AddFileWithHash("dir/file.txt", 123, ModTime{}, 0, hash))
var buf bytes.Buffer
require.NoError(t, b.Build(context.Background(), &buf))
+4 -4
View File
@@ -187,7 +187,7 @@ func TestBuilderWithSigning(t *testing.T) {
// Add a test file
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
@@ -314,7 +314,7 @@ func TestManifestSignatureVerification(t *testing.T) {
// Add a test file
content := []byte("test file content for verification")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
@@ -344,7 +344,7 @@ func TestManifestTamperedSignatureFails(t *testing.T) {
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
var buf bytes.Buffer
@@ -377,7 +377,7 @@ func TestBuilderWithoutSigning(t *testing.T) {
// Add a test file
content := []byte("test file content")
reader := bytes.NewReader(content)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, reader, nil)
_, err := b.AddFile("test.txt", FileSize(len(content)), ModTime{}, 0, reader, nil)
require.NoError(t, err)
// Build the manifest
+15 -5
View File
@@ -337,9 +337,11 @@ type MFFilePath struct {
// gotta have at least one:
Hashes []*MFFileChecksum `protobuf:"bytes,3,rep,name=hashes,proto3" json:"hashes,omitempty"`
// optional per-file metadata
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
MimeType *string `protobuf:"bytes,301,opt,name=mimeType,proto3,oneof" json:"mimeType,omitempty"`
Mtime *Timestamp `protobuf:"bytes,302,opt,name=mtime,proto3,oneof" json:"mtime,omitempty"`
Ctime *Timestamp `protobuf:"bytes,303,opt,name=ctime,proto3,oneof" json:"ctime,omitempty"`
// permission bits, at most 0777; 0 when not recorded
Mode uint32 `protobuf:"varint,304,opt,name=mode,proto3" json:"mode,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -416,6 +418,13 @@ func (x *MFFilePath) GetCtime() *Timestamp {
return nil
}
func (x *MFFilePath) GetMode() uint32 {
if x != nil {
return x.Mode
}
return 0
}
type MFFileChecksum struct {
state protoimpl.MessageState `protogen:"open.v1"`
// 1.0 golang implementation must write a multihash here
@@ -561,7 +570,7 @@ const file_mf_proto_rawDesc = "" +
"\n" +
"_signatureB\t\n" +
"\a_signerB\x10\n" +
"\x0e_signingPubKey\"\xf0\x01\n" +
"\x0e_signingPubKey\"\x85\x02\n" +
"\n" +
"MFFilePath\x12\x12\n" +
"\x04path\x18\x01 \x01(\tR\x04path\x12\x12\n" +
@@ -571,7 +580,8 @@ const file_mf_proto_rawDesc = "" +
"\x05mtime\x18\xae\x02 \x01(\v2\n" +
".TimestampH\x01R\x05mtime\x88\x01\x01\x12&\n" +
"\x05ctime\x18\xaf\x02 \x01(\v2\n" +
".TimestampH\x02R\x05ctime\x88\x01\x01B\v\n" +
".TimestampH\x02R\x05ctime\x88\x01\x01\x12\x13\n" +
"\x04mode\x18\xb0\x02 \x01(\rR\x04modeB\v\n" +
"\t_mimeTypeB\b\n" +
"\x06_mtimeB\b\n" +
"\x06_ctime\".\n" +
+2
View File
@@ -59,6 +59,8 @@ message MFFilePath {
optional string mimeType = 301;
optional Timestamp mtime = 302;
optional Timestamp ctime = 303;
// permission bits, at most 0777; 0 when not recorded
uint32 mode = 304;
}
message MFFileChecksum {
+1 -1
View File
@@ -1 +1 @@
fa6fceaba5553c8667c631994535fe5c307c8adb19f3ad289babf79a0f438650 mf.proto
3d4dcb0b2f4640dd3ae6bb48b833e9f26ae9771e2d3e88bd646e7f1724dda654 mf.proto
+1
View File
@@ -41,6 +41,7 @@ func TestFileEntryFieldsMatchSpec(t *testing.T) {
"mimeType": 301,
"mtime": 302,
"ctime": 303,
"mode": 304,
}
got := map[string]protoreflect.FieldNumber{}
+12
View File
@@ -52,6 +52,9 @@ type ScannerOptions struct {
// IncludeTimestamps includes a createdAt timestamp in the manifest
// (default: omit for determinism).
IncludeTimestamps bool
// IncludePermissions records each file's permission bits, 0777 at
// most, in the manifest (default: record 0000).
IncludePermissions bool
// Fs is the filesystem to use, defaults to OsFs if nil.
Fs afero.Fs
// SigningOptions holds GPG signing options (nil = no signing).
@@ -69,6 +72,7 @@ type FileEntry struct {
Size FileSize // File size in bytes
Mtime ModTime // Last modification time
Ctime time.Time // Creation time (platform-dependent)
Mode fs.FileMode // Permission bits (Perm() of the file's mode)
}
// Scanner accumulates files and generates manifests from them.
@@ -353,11 +357,18 @@ func (s *Scanner) scanFile(
}(scannedBytes, scannedFiles)
}
// A mode of 0 records 0000, which means none was recorded.
var mode fs.FileMode
if s.options.IncludePermissions {
mode = entry.Mode
}
// Add to manifest with progress channel
bytesRead, err := builder.AddFile(
entry.Path,
entry.Size,
entry.Mtime,
mode,
f,
fileProgress,
)
@@ -461,6 +472,7 @@ func (s *Scanner) enumerateFileWithInfo(
AbsPath: AbsFilePath(absPath),
Size: FileSize(info.Size()),
Mtime: ModTime(info.ModTime()),
Mode: info.Mode().Perm(),
// Note: Ctime not available from fs.FileInfo on all platforms
// Will need platform-specific code to extract it
}
+41
View File
@@ -4,6 +4,7 @@ package mfer
import (
"bytes"
"context"
"os"
"testing"
"time"
@@ -350,6 +351,46 @@ func TestScannerFileEntryFields(t *testing.T) {
assert.WithinDuration(t, now, time.Time(entry.Mtime), 2*time.Second)
}
// A manifest records every mode as 0000 unless the creator asks for
// permissions; then it records each file's permission bits and never the
// setuid, setgid or sticky bits.
func TestScannerRecordsModeOnlyWhenAsked(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
require.NoError(t, afero.WriteFile(fs, "/"+testFile1, []byte("a"), 0o640))
require.NoError(t, afero.WriteFile(fs, "/run.sh", []byte("b"), 0o755))
require.NoError(t, afero.WriteFile(fs, "/su", []byte("c"), 0o755))
require.NoError(t, fs.Chmod("/su", 0o755|os.ModeSetuid|os.ModeSetgid|os.ModeSticky))
for _, tc := range []struct {
includePermissions bool
want map[string]uint32
}{
{false, map[string]uint32{testFile1: 0, "run.sh": 0, "su": 0}},
{true, map[string]uint32{testFile1: 0o640, "run.sh": 0o755, "su": 0o755}},
} {
s := NewScannerWithOptions(&ScannerOptions{
Fs: fs,
IncludePermissions: tc.includePermissions,
})
require.NoError(t, s.EnumerateFS(fs, "/", nil))
var buf bytes.Buffer
require.NoError(t, s.ToManifest(context.Background(), &buf, nil))
m, err := NewManifestFromReader(&buf)
require.NoError(t, err)
got := map[string]uint32{}
for _, f := range m.Files() {
got[f.GetPath()] = f.GetMode()
}
assert.Equal(t, tc.want, got, "IncludePermissions: %v", tc.includePermissions)
}
}
func TestScannerLargeFileEnumeration(t *testing.T) {
t.Parallel()