internal/s3fs/ is a go-billy filesystem mapped onto Tigris (S3) storage,
consumed today by cmd/objgitd to back git repositories. It never reads or
writes POSIX attributes: every file reports mode 0666, directories ModeDir,
uid/gid are absent, and PutObject carries no user metadata
(internal/s3fs/fileinfo.go, internal/s3fs/file.go).
docs/reference/how-tigris-fs-unix-metadata.md defines a convention for storing
Unix attributes as x-amz-meta-* headers (uid, gid, mode, rdev, mtime,
--symlink-target). This change implements that convention in s3fs as an
opt-in feature with three session knobs: uid, gid, and umask.
- Off by default. When disabled, behavior is byte-for-byte what it is today.
objgitditself never sets the option — the git protocol does not surface POSIX attributes, so there's no win in enabling the feature for repo storage. Other consumers ofinternal/s3fs(current or future) can opt in. - s3fs deals in numeric uid/gid. The package does not resolve names→IDs or IDs→names itself; it exposes optional helper functions and lets callers decide whether/how to resolve.
- Scope = create-time write + read (uid/gid/mode/mtime). No chmod/chown
writeback (
billy.Change) and no symlink/device support in this pass — those are noted as follow-ups.
New file internal/s3fs/unixmeta/unixmeta.go implementing the doc verbatim:
Attrsstruct,PosixMode(os.FileMode) uint32,GoFileMode(uint32) os.FileMode,Encode(Attrs) map[string]string,Decode(meta map[string]string, defaults Attrs) Attrs.- Provide optional, caller-invoked helpers (the package itself never calls them;
callers decide whether to resolve names):
LookupUID(name string) (uint32, error)—user.Lookup, fall back to parsingnameas a decimal uint32.LookupGID(name string) (uint32, error)—user.LookupGroup, same fallback. No reverse (uid/gid → name) resolution is provided.
- Table-driven tests
unixmeta_test.go: PosixMode/GoFileMode round-trip across file/dir/symlink/setuid/sticky; Encode→Decode round-trip; malformed-header tolerance; missing-key-keeps-default.
Add a nil-able config (nil = disabled, preserving current behavior):
type unixMetaConfig struct { uid, gid uint32; umask os.FileMode }
type S3FS struct {
client *storage.Client
bucket string
root, separator string
unixMeta *unixMetaConfig // nil => feature off
// ...existing tempfs fields...
}
type Option func(*S3FS)
func WithUnixMetadata(uid, gid uint32, umask os.FileMode) Option
func NewS3FS(client *storage.Client, bucket string, opts ...Option) (billy.Filesystem, error)Variadic options keep the existing cmd/objgitd caller compiling unchanged.
Chroot must copy unixMeta onto the new *S3FS so chrooted views inherit the
session config.
Thread *unixMetaConfig into newS3WriteFile and newS3MultipartUploadFile
(plumbed from OpenFile in internal/s3fs/basic.go). In each Close():
- If config is nil → unchanged (no
Metadata). - Else set
PutObjectInput.Metadata = unixmeta.Encode(unixmeta.Attrs{UID, GID, Mode: 0o666 &^ umask, Mtime: time.Now()}). (Multipart:MetadataonCreateMultipartUploadInputat construction —CompleteMultipartUploadcannot attach user metadata.)
- Extend
simpleFileInfowith an optionalsys *FileStatfield;Sys()returns the pointer when set,nilotherwise.FileStat{UID, GID uint32}lets consumers read the raw numeric ownership and resolve to names themselves. - Add
newFileInfoFromHead(name, head, cfg)that, whencfg != nil, runsunixmeta.Decode(head.Metadata, defaults)with defaults{UID: cfg.uid, GID: cfg.gid, Mode: 0o666 &^ cfg.umask, Mtime: head.LastModified}and builds a fully populatedsimpleFileInfo. Whencfg == nil, keep the current0666path by delegating tonewFileInfo. - Wire this into
Stat(internal/s3fs/basic.go).Lstatalready delegates toStat. - ReadDir (
internal/s3fs/dir.go):ListObjectsV2does not return user metadata, so list entries keep default modes; full attributes come fromStat. (lsstats entries for the long format.) Documented limitation; avoids an N-Head fan-out.
No consumer in this repo currently opts in:
cmd/objgitdcallss3fs.NewS3FS(client, *bucket)with no options. Git packfile/loose-object storage gains nothing from POSIX metadata, so adding-fs-*flags to objgitd would be ceremony without payoff. The variadic signature means the call site does not change.
External callers (or a future objgit binary that exposes a POSIX-shaped surface) opt in with:
s3fs.NewS3FS(client, bucket, s3fs.WithUnixMetadata(uid, gid, umask))and resolve any name strings to numeric IDs with
unixmeta.LookupUID / unixmeta.LookupGID before passing them in.
internal/s3fs/unixmeta/unixmeta.go(new),internal/s3fs/unixmeta/unixmeta_test.go(new)internal/s3fs/filesystem.go— config +Option+WithUnixMetadatainternal/s3fs/chroot.go— propagateunixMetato the chrooted*S3FSinternal/s3fs/basic.go— plumb config intoOpenFile; decode inStatinternal/s3fs/file.go— encode metadata in write/multipartCloseinternal/s3fs/fileinfo.go—FileStat, optionalsysfield, decode constructor
go test ./internal/s3fs/...— unixmeta round-trip + tolerance tests pass.go test ./...— full suite, including the git-protocol tests, still passes with the feature off (regression guard for the default path).go build ./...—objgitdstill compiles.- Manual (needs Tigris creds +
BUCKET): a separate driver could open ans3fswithWithUnixMetadata, write a file, thenHeadObject(viatigris-objectsMCP / aws cli) and confirmx-amz-meta-uid/gid/mode/mtimeare present and correct; read it back and confirmStat().Mode()reflects0666 &^ umask. With the feature off, the same flow should show nox-amz-meta-*headers.
billy.Change(chmod/chown/chtimes) writeback via metadata-rewriting CopyObject.- Symlink target / device-node (
rdev) storage. - Per-entry metadata in
ReadDir.