Git backed by object storage because you can't stop me
git object-storage kefka
10

Configure Feed

Select the types of activity you want to include in your feed.

feat(s3fs): add optional Unix-metadata storage as S3 user metadata

Store POSIX file attributes (uid, gid, mode, mtime) as x-amz-meta-*
headers on S3 objects so POSIX filesystems layered on Tigris can
round-trip ownership and permissions instead of always reporting
mode 0666 with no owner.

The feature is opt-in and off by default: NewS3FS takes functional
options, and without WithUnixMetadata the filesystem behaves exactly
as before. Callers opt in by passing WithUnixMetadata(uid, gid, umask)
and resolve any name strings to numeric IDs themselves.

New unixmeta package implements Encode/Decode and PosixMode/GoFileMode
conversion; the wire format is documented in docs/reference.

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso (May 28, 2026, 12:32 PM EDT) 4ae20f98 984bd5d7

+887 -35
+145
docs/plans/s3fs-unix-metadata.md
··· 1 + # Plan: Optional Unix-permission metadata in s3fs 2 + 3 + ## Context 4 + 5 + `internal/s3fs/` is a go-billy filesystem mapped onto Tigris (S3) storage, 6 + consumed today by `cmd/objgitd` to back git repositories. It never reads or 7 + writes POSIX attributes: every file reports mode `0666`, directories `ModeDir`, 8 + uid/gid are absent, and `PutObject` carries no user metadata 9 + (`internal/s3fs/fileinfo.go`, `internal/s3fs/file.go`). 10 + 11 + `docs/reference/how-tigris-fs-unix-metadata.md` defines a convention for storing 12 + Unix attributes as `x-amz-meta-*` headers (uid, gid, mode, rdev, mtime, 13 + `--symlink-target`). This change implements that convention in s3fs as an 14 + **opt-in** feature with three session knobs: **uid**, **gid**, and **umask**. 15 + 16 + ### Constraints / assumptions 17 + 18 + - **Off by default.** When disabled, behavior is byte-for-byte what it is today. 19 + `objgitd` itself never sets the option — the git protocol does not surface 20 + POSIX attributes, so there's no win in enabling the feature for repo storage. 21 + Other consumers of `internal/s3fs` (current or future) can opt in. 22 + - **s3fs deals in numeric uid/gid.** The package does not resolve names→IDs or 23 + IDs→names itself; it exposes optional helper functions and lets callers decide 24 + whether/how to resolve. 25 + - Scope = **create-time write + read** (uid/gid/mode/mtime). No chmod/chown 26 + writeback (`billy.Change`) and no symlink/device support in this pass — those 27 + are noted as follow-ups. 28 + 29 + ## Approach 30 + 31 + ### 1. New package `internal/s3fs/unixmeta` 32 + 33 + New file `internal/s3fs/unixmeta/unixmeta.go` implementing the doc verbatim: 34 + 35 + - `Attrs` struct, `PosixMode(os.FileMode) uint32`, `GoFileMode(uint32) os.FileMode`, 36 + `Encode(Attrs) map[string]string`, `Decode(meta map[string]string, defaults Attrs) Attrs`. 37 + - Provide optional, caller-invoked helpers (the package itself never calls them; 38 + callers decide whether to resolve names): 39 + - `LookupUID(name string) (uint32, error)` — `user.Lookup`, fall back to parsing 40 + `name` as a decimal uint32. 41 + - `LookupGID(name string) (uint32, error)` — `user.LookupGroup`, same fallback. 42 + No reverse (uid/gid → name) resolution is provided. 43 + - Table-driven tests `unixmeta_test.go`: PosixMode/GoFileMode round-trip across 44 + file/dir/symlink/setuid/sticky; Encode→Decode round-trip; malformed-header 45 + tolerance; missing-key-keeps-default. 46 + 47 + ### 2. Opt-in config on `S3FS` (`internal/s3fs/filesystem.go`) 48 + 49 + Add a nil-able config (nil = disabled, preserving current behavior): 50 + 51 + ```go 52 + type unixMetaConfig struct { uid, gid uint32; umask os.FileMode } 53 + 54 + type S3FS struct { 55 + client *storage.Client 56 + bucket string 57 + root, separator string 58 + unixMeta *unixMetaConfig // nil => feature off 59 + // ...existing tempfs fields... 60 + } 61 + 62 + type Option func(*S3FS) 63 + func WithUnixMetadata(uid, gid uint32, umask os.FileMode) Option 64 + 65 + func NewS3FS(client *storage.Client, bucket string, opts ...Option) (billy.Filesystem, error) 66 + ``` 67 + 68 + Variadic options keep the existing `cmd/objgitd` caller compiling unchanged. 69 + `Chroot` must copy `unixMeta` onto the new `*S3FS` so chrooted views inherit the 70 + session config. 71 + 72 + ### 3. Write path (`internal/s3fs/file.go`) 73 + 74 + Thread `*unixMetaConfig` into `newS3WriteFile` and `newS3MultipartUploadFile` 75 + (plumbed from `OpenFile` in `internal/s3fs/basic.go`). In each `Close()`: 76 + 77 + - If config is nil → unchanged (no `Metadata`). 78 + - Else set `PutObjectInput.Metadata = unixmeta.Encode(unixmeta.Attrs{UID, GID, 79 + Mode: 0o666 &^ umask, Mtime: time.Now()})`. (Multipart: `Metadata` on 80 + `CreateMultipartUploadInput` at construction — `CompleteMultipartUpload` 81 + cannot attach user metadata.) 82 + 83 + ### 4. Read path (`internal/s3fs/basic.go`, `internal/s3fs/fileinfo.go`) 84 + 85 + - Extend `simpleFileInfo` with an optional `sys *FileStat` field; `Sys()` returns 86 + the pointer when set, `nil` otherwise. `FileStat{UID, GID uint32}` lets 87 + consumers read the raw numeric ownership and resolve to names themselves. 88 + - Add `newFileInfoFromHead(name, head, cfg)` that, when `cfg != nil`, runs 89 + `unixmeta.Decode(head.Metadata, defaults)` with defaults `{UID: cfg.uid, 90 + GID: cfg.gid, Mode: 0o666 &^ cfg.umask, Mtime: head.LastModified}` and builds 91 + a fully populated `simpleFileInfo`. When `cfg == nil`, keep the current 92 + `0666` path by delegating to `newFileInfo`. 93 + - Wire this into `Stat` (`internal/s3fs/basic.go`). `Lstat` already delegates 94 + to `Stat`. 95 + - **ReadDir** (`internal/s3fs/dir.go`): `ListObjectsV2` does not return user 96 + metadata, so list entries keep default modes; full attributes come from 97 + `Stat`. (`ls` stats entries for the long format.) Documented limitation; 98 + avoids an N-Head fan-out. 99 + 100 + ### 5. Consumer wiring 101 + 102 + No consumer in this repo currently opts in: 103 + 104 + - `cmd/objgitd` calls `s3fs.NewS3FS(client, *bucket)` with no options. Git 105 + packfile/loose-object storage gains nothing from POSIX metadata, so adding 106 + `-fs-*` flags to objgitd would be ceremony without payoff. The variadic 107 + signature means the call site does not change. 108 + 109 + External callers (or a future objgit binary that exposes a POSIX-shaped surface) 110 + opt in with: 111 + 112 + ```go 113 + s3fs.NewS3FS(client, bucket, s3fs.WithUnixMetadata(uid, gid, umask)) 114 + ``` 115 + 116 + and resolve any name strings to numeric IDs with 117 + `unixmeta.LookupUID` / `unixmeta.LookupGID` before passing them in. 118 + 119 + ## Files touched 120 + 121 + - `internal/s3fs/unixmeta/unixmeta.go` (new), `internal/s3fs/unixmeta/unixmeta_test.go` (new) 122 + - `internal/s3fs/filesystem.go` — config + `Option` + `WithUnixMetadata` 123 + - `internal/s3fs/chroot.go` — propagate `unixMeta` to the chrooted `*S3FS` 124 + - `internal/s3fs/basic.go` — plumb config into `OpenFile`; decode in `Stat` 125 + - `internal/s3fs/file.go` — encode metadata in write/multipart `Close` 126 + - `internal/s3fs/fileinfo.go` — `FileStat`, optional `sys` field, decode constructor 127 + 128 + ## Verification 129 + 130 + - `go test ./internal/s3fs/...` — unixmeta round-trip + tolerance tests pass. 131 + - `go test ./...` — full suite, including the git-protocol tests, still passes 132 + with the feature off (regression guard for the default path). 133 + - `go build ./...` — `objgitd` still compiles. 134 + - Manual (needs Tigris creds + `BUCKET`): a separate driver could open an 135 + `s3fs` with `WithUnixMetadata`, write a file, then `HeadObject` (via 136 + `tigris-objects` MCP / aws cli) and confirm `x-amz-meta-uid/gid/mode/mtime` 137 + are present and correct; read it back and confirm `Stat().Mode()` reflects 138 + `0666 &^ umask`. With the feature off, the same flow should show **no** 139 + `x-amz-meta-*` headers. 140 + 141 + ## Deferred (not in this change) 142 + 143 + - `billy.Change` (chmod/chown/chtimes) writeback via metadata-rewriting CopyObject. 144 + - Symlink target / device-node (`rdev`) storage. 145 + - Per-entry metadata in `ReadDir`.
+281
docs/reference/how-tigris-fs-unix-metadata.md
··· 1 + # How Unix metadata is stored on Tigris objects 2 + 3 + A POSIX filesystem mapped onto S3-compatible object storage needs somewhere to keep 4 + Unix attributes (owner, group, permissions, timestamps, symlink targets) that S3 5 + itself does not natively model. The convention used here is to store them as 6 + **S3 user-defined metadata** — a small set of `x-amz-meta-*` HTTP headers attached 7 + to each object. 8 + 9 + This document describes the on-the-wire format and shows how to read and write it 10 + with `aws-sdk-go-v2`. 11 + 12 + ## The headers 13 + 14 + Every value is a string. Integers are decimal-encoded. 15 + 16 + | Header | Meaning | Value format | 17 + |---|---|---| 18 + | `x-amz-meta-uid` | Owner user ID | decimal `uint32` | 19 + | `x-amz-meta-gid` | Owner group ID | decimal `uint32` | 20 + | `x-amz-meta-mode` | POSIX file mode (type + permission bits) | decimal `uint32` | 21 + | `x-amz-meta-rdev` | Device number (block/character devices only) | decimal `uint32` | 22 + | `x-amz-meta-mtime` | Modification time | Unix seconds, decimal | 23 + | `x-amz-meta---symlink-target` | Target path of a symlink | URL-percent-escaped string | 24 + 25 + The header names are lowercase. Headers are written only when the value differs 26 + from the consumer's default; a missing header means "use the default" rather than 27 + "value is zero." Typical defaults are the mounting user's UID/GID and `0o644` for 28 + files / `0o755` for directories. 29 + 30 + The `--symlink-target` header really does have three dashes after `meta-`: the 31 + attribute name is the literal string `--symlink-target`, and the S3 SDK prepends 32 + the standard `x-amz-meta-` prefix. 33 + 34 + ## Mode encoding 35 + 36 + The `mode` value is the standard POSIX `mode_t` integer: the low 9 bits encode 37 + `rwxrwxrwx`, the next 3 encode setuid/setgid/sticky, and the file type lives in 38 + the high bits. 39 + 40 + | Type | Octal mask | 41 + |---|---| 42 + | Regular file | `0o100000` | 43 + | Directory | `0o040000` | 44 + | Symbolic link | `0o120000` | 45 + | Block device | `0o060000` | 46 + | Character device | `0o020000` | 47 + | FIFO | `0o010000` | 48 + | Socket | `0o140000` | 49 + 50 + So `33188` (decimal) = `0o100644` = regular file with `rw-r--r--`. 51 + 52 + Go's `os.FileMode` uses a different layout (type bits at `1<<31` and friends), so 53 + a conversion is required at the boundary. 54 + 55 + ## Value escaping 56 + 57 + HTTP header values must be plain ASCII without control characters, and S3 58 + normalizes whitespace. To keep arbitrary bytes (UTF-8 paths, control characters, 59 + `%`) round-tripping safely, values are percent-encoded before being put in the 60 + header and decoded after reading. This is important for `--symlink-target`, 61 + whose value is an arbitrary filesystem path. 62 + 63 + ## Writing metadata 64 + 65 + The AWS SDK accepts user metadata as a `map[string]string` on 66 + `PutObjectInput.Metadata`. The SDK prepends `x-amz-meta-` and lowercases the 67 + keys, so you supply the bare attribute name. 68 + 69 + ```go 70 + package unixmeta 71 + 72 + import ( 73 + "net/url" 74 + "os" 75 + "strconv" 76 + "time" 77 + ) 78 + 79 + // PosixMode converts a Go os.FileMode to the POSIX mode_t integer 80 + // stored in x-amz-meta-mode. 81 + func PosixMode(m os.FileMode) uint32 { 82 + out := uint32(m.Perm()) // low 9 permission bits 83 + if m&os.ModeSetuid != 0 { 84 + out |= 0o4000 85 + } 86 + if m&os.ModeSetgid != 0 { 87 + out |= 0o2000 88 + } 89 + if m&os.ModeSticky != 0 { 90 + out |= 0o1000 91 + } 92 + switch { 93 + case m&os.ModeDir != 0: 94 + out |= 0o040000 95 + case m&os.ModeSymlink != 0: 96 + out |= 0o120000 97 + case m&os.ModeDevice != 0 && m&os.ModeCharDevice != 0: 98 + out |= 0o020000 99 + case m&os.ModeDevice != 0: 100 + out |= 0o060000 101 + case m&os.ModeNamedPipe != 0: 102 + out |= 0o010000 103 + case m&os.ModeSocket != 0: 104 + out |= 0o140000 105 + default: 106 + out |= 0o100000 107 + } 108 + return out 109 + } 110 + 111 + // Attrs is what a caller wants to record on an object. 112 + type Attrs struct { 113 + UID, GID uint32 114 + Mode os.FileMode 115 + Rdev uint32 116 + Mtime time.Time 117 + SymlinkTarget string // "" if not a symlink 118 + } 119 + 120 + // Encode produces the user-metadata map for a PutObject call. 121 + // Pass the result as PutObjectInput.Metadata. 122 + func Encode(a Attrs) map[string]string { 123 + mode := PosixMode(a.Mode) 124 + m := map[string]string{ 125 + "uid": strconv.FormatUint(uint64(a.UID), 10), 126 + "gid": strconv.FormatUint(uint64(a.GID), 10), 127 + "mode": strconv.FormatUint(uint64(mode), 10), 128 + "mtime": strconv.FormatInt(a.Mtime.Unix(), 10), 129 + } 130 + if a.Mode&(os.ModeDevice|os.ModeCharDevice) != 0 { 131 + m["rdev"] = strconv.FormatUint(uint64(a.Rdev), 10) 132 + } 133 + if a.SymlinkTarget != "" { 134 + m["--symlink-target"] = url.QueryEscape(a.SymlinkTarget) 135 + } 136 + return m 137 + } 138 + ``` 139 + 140 + Used at the call site: 141 + 142 + ```go 143 + _, err := client.PutObject(ctx, &s3.PutObjectInput{ 144 + Bucket: aws.String("mybucket"), 145 + Key: aws.String("path/to/file"), 146 + Body: body, 147 + Metadata: unixmeta.Encode(unixmeta.Attrs{ 148 + UID: 1000, 149 + GID: 1000, 150 + Mode: 0o644, 151 + Mtime: time.Now(), 152 + }), 153 + }) 154 + ``` 155 + 156 + ## Reading metadata 157 + 158 + `HeadObject` and `GetObject` both return user metadata in `Metadata 159 + map[string]string`, with the `x-amz-meta-` prefix already stripped and keys 160 + lowercased. 161 + 162 + ```go 163 + package unixmeta 164 + 165 + import ( 166 + "net/url" 167 + "os" 168 + "strconv" 169 + "time" 170 + ) 171 + 172 + // GoFileMode is the inverse of PosixMode. 173 + func GoFileMode(p uint32) os.FileMode { 174 + m := os.FileMode(p & 0o777) 175 + if p&0o4000 != 0 { 176 + m |= os.ModeSetuid 177 + } 178 + if p&0o2000 != 0 { 179 + m |= os.ModeSetgid 180 + } 181 + if p&0o1000 != 0 { 182 + m |= os.ModeSticky 183 + } 184 + switch p & 0o170000 { 185 + case 0o040000: 186 + m |= os.ModeDir 187 + case 0o120000: 188 + m |= os.ModeSymlink 189 + case 0o020000: 190 + m |= os.ModeDevice | os.ModeCharDevice 191 + case 0o060000: 192 + m |= os.ModeDevice 193 + case 0o010000: 194 + m |= os.ModeNamedPipe 195 + case 0o140000: 196 + m |= os.ModeSocket 197 + case 0o100000: 198 + // regular file: no extra bits 199 + } 200 + return m 201 + } 202 + 203 + // Decode merges metadata from a HeadObject / GetObject response into defaults. 204 + // Missing keys leave the corresponding field of `defaults` untouched, which is 205 + // the behavior a POSIX filesystem usually wants: an object with no uid header 206 + // inherits the mount's default uid, not zero. 207 + func Decode(meta map[string]string, defaults Attrs) Attrs { 208 + out := defaults 209 + if s, ok := meta["uid"]; ok { 210 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 211 + out.UID = uint32(v) 212 + } 213 + } 214 + if s, ok := meta["gid"]; ok { 215 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 216 + out.GID = uint32(v) 217 + } 218 + } 219 + if s, ok := meta["mode"]; ok { 220 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 221 + out.Mode = GoFileMode(uint32(v)) 222 + } 223 + } 224 + if s, ok := meta["rdev"]; ok { 225 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 226 + out.Rdev = uint32(v) 227 + } 228 + } 229 + if s, ok := meta["mtime"]; ok { 230 + if v, err := strconv.ParseInt(s, 0, 64); err == nil { 231 + out.Mtime = time.Unix(v, 0) 232 + } 233 + } 234 + if s, ok := meta["--symlink-target"]; ok { 235 + if dec, err := url.QueryUnescape(s); err == nil { 236 + out.SymlinkTarget = dec 237 + } 238 + } 239 + return out 240 + } 241 + ``` 242 + 243 + Used at the call site: 244 + 245 + ```go 246 + head, err := client.HeadObject(ctx, &s3.HeadObjectInput{ 247 + Bucket: aws.String("mybucket"), 248 + Key: aws.String("path/to/file"), 249 + }) 250 + if err != nil { 251 + return err 252 + } 253 + 254 + attrs := unixmeta.Decode(head.Metadata, unixmeta.Attrs{ 255 + UID: uint32(os.Getuid()), 256 + GID: uint32(os.Getgid()), 257 + Mode: 0o644, 258 + // Mtime defaults to the object's S3 LastModified if mtime is missing: 259 + Mtime: aws.ToTime(head.LastModified), 260 + }) 261 + ``` 262 + 263 + ## Parsing notes 264 + 265 + - `strconv.ParseUint(s, 0, ...)` with base `0` accepts decimal, `0o`-prefixed 266 + octal, and `0x`-prefixed hex. Writers should emit decimal; readers should be 267 + liberal. 268 + - Treat malformed values as missing — parse errors fall through to the default 269 + rather than failing the whole lookup. A single bad header should not make a 270 + file unreadable. 271 + - The `mode` header carries both the permission bits and the file-type bits. 272 + When reapplying it to an in-memory inode, mask out the type bits if you only 273 + want to update permissions (for `chmod`), and mask out the permission bits if 274 + you only want to update the type (rare — typically set once at creation). 275 + 276 + ## Directories and zero-byte objects 277 + 278 + Directories are zero-byte S3 objects whose key ends in `/`. They carry the same 279 + metadata headers as regular files, with `mode` containing the directory type bit 280 + (`0o040000`). Symlinks are likewise zero-byte objects; the link target lives 281 + entirely in the `--symlink-target` header, not in the object body.
+3 -8
internal/s3fs/basic.go
··· 13 13 "strings" 14 14 "time" 15 15 16 - "github.com/aws/aws-sdk-go-v2/aws" 17 16 "github.com/aws/aws-sdk-go-v2/service/s3" 18 17 "github.com/aws/smithy-go" 19 18 "github.com/go-git/go-billy/v6" ··· 110 109 return nil, &os.PathError{Op: "open", Path: filename, Err: fs.ErrNotExist} 111 110 112 111 case O_WRONLY: 113 - return newS3WriteFile(fs3.client, fs3.bucket, key, filename) 112 + return newS3WriteFile(fs3.client, fs3.bucket, key, filename, fs3.unixMeta) 114 113 115 114 case O_WRMULTIPART: 116 - return newS3MultipartUploadFile(fs3.client, fs3.bucket, key, filename) 115 + return newS3MultipartUploadFile(fs3.client, fs3.bucket, key, filename, fs3.unixMeta) 117 116 118 117 default: 119 118 return nil, errors.New("unsupported open flag") ··· 140 139 Key: &key, 141 140 }) 142 141 if err == nil { 143 - return newFileInfo( 144 - path.Base(key), 145 - aws.ToInt64(head.ContentLength), 146 - aws.ToTime(head.LastModified), 147 - ), nil 142 + return newFileInfoFromHead(path.Base(key), head, fs3.unixMeta), nil 148 143 } 149 144 150 145 var apiErr smithy.APIError
+1
internal/s3fs/chroot.go
··· 23 23 bucket: fs3.bucket, 24 24 root: p, 25 25 separator: fs3.separator, 26 + unixMeta: fs3.unixMeta, 26 27 temps: make(map[string]*tempBuffer), 27 28 } 28 29 return nfs, nil
+40 -19
internal/s3fs/file.go
··· 15 15 "github.com/aws/aws-sdk-go-v2/service/s3" 16 16 "github.com/tigrisdata/storage-go" 17 17 "go.uber.org/atomic" 18 + "tangled.org/xeiaso.net/objgit/internal/s3fs/unixmeta" 18 19 ) 20 + 21 + // newFileMetadata returns the x-amz-meta-* map to attach to a newly written 22 + // object, or nil when the Unix-metadata feature is disabled. New files take the 23 + // session's default owner and a mode of 0666 masked by the session umask. 24 + func newFileMetadata(cfg *unixMetaConfig) map[string]string { 25 + if cfg == nil { 26 + return nil 27 + } 28 + return unixmeta.Encode(unixmeta.Attrs{ 29 + UID: cfg.uid, 30 + GID: cfg.gid, 31 + Mode: 0o666 &^ cfg.umask, 32 + Mtime: time.Now(), 33 + }) 34 + } 19 35 20 36 const ( 21 37 ModeMultipartUpload os.FileMode = fs.ModePerm + 1 // Custom os.FileMode for S3 multipart upload ··· 159 175 // Upon creation, a buffer is created to store the file contents. Upon close, 160 176 // the file is uploaded to S3. 161 177 type s3WriteFile struct { 162 - client *storage.Client // s3 skd client 163 - bucket string // S3 bucket name 164 - key string // File object's key in S3 165 - name string // Root-relative path as presented to Open 166 - closed bool // Is the file closed? 167 - buf *bytes.Buffer // Buffer for storing the file before it's uploaded 178 + client *storage.Client // s3 skd client 179 + bucket string // S3 bucket name 180 + key string // File object's key in S3 181 + name string // Root-relative path as presented to Open 182 + closed bool // Is the file closed? 183 + buf *bytes.Buffer // Buffer for storing the file before it's uploaded 184 + unixMeta *unixMetaConfig // optional POSIX attribute defaults (nil = disabled) 168 185 } 169 186 170 187 // newS3WriteFile creates a new s3WriteFile. key is the full S3 object key; name 171 188 // is the root-relative path the caller passed to Open (returned by Name). 172 - func newS3WriteFile(client *storage.Client, bucket, key, name string) (*s3WriteFile, error) { 189 + func newS3WriteFile(client *storage.Client, bucket, key, name string, cfg *unixMetaConfig) (*s3WriteFile, error) { 173 190 return &s3WriteFile{ 174 - client: client, 175 - bucket: bucket, 176 - key: key, 177 - name: name, 178 - buf: bytes.NewBuffer(nil), 191 + client: client, 192 + bucket: bucket, 193 + key: key, 194 + name: name, 195 + buf: bytes.NewBuffer(nil), 196 + unixMeta: cfg, 179 197 }, nil 180 198 } 181 199 ··· 229 247 // Run the GetObject operation 230 248 // TODO: Currently `res` is not used. Should it be? 231 249 _, err := f.client.PutObject(ctx, &s3.PutObjectInput{ 232 - Bucket: &f.bucket, 233 - Key: &f.key, 234 - Body: body, 250 + Bucket: &f.bucket, 251 + Key: &f.key, 252 + Body: body, 253 + Metadata: newFileMetadata(f.unixMeta), 235 254 }) 236 255 if err != nil { 237 256 return fmt.Errorf("unable to perform GetObject operation: %w", err) ··· 273 292 274 293 // newS3MultipartUploadFile creates a new s3MultipartUploadFile. key is the full 275 294 // S3 object key; name is the root-relative path passed to Open. 276 - func newS3MultipartUploadFile(client *storage.Client, bucket, key, name string) (*s3MultipartUploadFile, error) { 295 + func newS3MultipartUploadFile(client *storage.Client, bucket, key, name string, cfg *unixMetaConfig) (*s3MultipartUploadFile, error) { 277 296 // TODO: Check if the file exists 278 297 // ... 279 298 280 299 // Create the context 281 300 ctx := context.TODO() // TODO: How can user-supplied contexts be supported? 282 301 283 - // Run the GetObject operation 302 + // Run the GetObject operation. POSIX attributes (if enabled) must be set 303 + // now: CompleteMultipartUpload cannot attach user metadata. 284 304 res, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ 285 - Bucket: &bucket, 286 - Key: &key, 305 + Bucket: &bucket, 306 + Key: &key, 307 + Metadata: newFileMetadata(cfg), 287 308 }) 288 309 if err != nil { 289 310 return nil, fmt.Errorf("unable to create multipart upload: %w", err)
+47 -5
internal/s3fs/fileinfo.go
··· 5 5 "os" 6 6 "time" 7 7 8 + "github.com/aws/aws-sdk-go-v2/aws" 8 9 "github.com/aws/aws-sdk-go-v2/service/s3" 10 + "tangled.org/xeiaso.net/objgit/internal/s3fs/unixmeta" 9 11 ) 10 12 13 + // FileStat is the value returned by simpleFileInfo.Sys() when the 14 + // Unix-metadata feature is enabled. It carries the raw numeric owner and group 15 + // so consumers can resolve them to names however they like. 16 + type FileStat struct { 17 + UID, GID uint32 18 + } 19 + 11 20 // simpleFileInfo implements os.FileInfo 12 21 type simpleFileInfo struct { 13 22 name string 14 23 size int64 15 24 mode os.FileMode 16 25 modTime time.Time 26 + sys *FileStat 17 27 } 18 28 19 29 func newFileInfo(name string, size int64, modTime time.Time) os.FileInfo { ··· 33 43 } 34 44 } 35 45 36 - func (fi simpleFileInfo) Name() string { return fi.name } 37 - func (fi simpleFileInfo) Size() int64 { return fi.size } 38 - func (fi simpleFileInfo) Mode() os.FileMode { return fi.mode } 39 - func (fi simpleFileInfo) IsDir() bool { return fi.mode.IsDir() } 40 - func (fi simpleFileInfo) Sys() interface{} { return nil } 46 + // newFileInfoFromHead builds a FileInfo from a HeadObject response. When cfg is 47 + // nil the Unix-metadata feature is off and the result matches newFileInfo; 48 + // otherwise the x-amz-meta-* attributes are decoded, falling back to the 49 + // session defaults for any header that is missing. 50 + func newFileInfoFromHead(name string, head *s3.HeadObjectOutput, cfg *unixMetaConfig) os.FileInfo { 51 + size := aws.ToInt64(head.ContentLength) 52 + modTime := aws.ToTime(head.LastModified) 53 + if cfg == nil { 54 + return newFileInfo(name, size, modTime) 55 + } 56 + 57 + attrs := unixmeta.Decode(head.Metadata, unixmeta.Attrs{ 58 + UID: cfg.uid, 59 + GID: cfg.gid, 60 + Mode: 0o666 &^ cfg.umask, 61 + Mtime: modTime, 62 + }) 63 + 64 + return simpleFileInfo{ 65 + name: name, 66 + size: size, 67 + mode: attrs.Mode, 68 + modTime: attrs.Mtime, 69 + sys: &FileStat{UID: attrs.UID, GID: attrs.GID}, 70 + } 71 + } 72 + 73 + func (fi simpleFileInfo) Name() string { return fi.name } 74 + func (fi simpleFileInfo) Size() int64 { return fi.size } 75 + func (fi simpleFileInfo) Mode() os.FileMode { return fi.mode } 76 + func (fi simpleFileInfo) IsDir() bool { return fi.mode.IsDir() } 77 + func (fi simpleFileInfo) Sys() any { 78 + if fi.sys == nil { 79 + return nil 80 + } 81 + return fi.sys 82 + } 41 83 func (fi simpleFileInfo) ModTime() time.Time { return fi.modTime } 42 84 43 85 type enrichedFileInfo struct {
+31 -3
internal/s3fs/filesystem.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "os" 5 6 "path" 6 7 "strings" 7 8 "sync" ··· 13 14 const ( 14 15 DefaultSeparator = "/" 15 16 ) 17 + 18 + // unixMetaConfig holds the session defaults used when the optional Unix-metadata 19 + // feature is enabled. A nil *unixMetaConfig means the feature is off and the 20 + // filesystem behaves as if no POSIX attributes exist. 21 + type unixMetaConfig struct { 22 + uid, gid uint32 23 + umask os.FileMode 24 + } 16 25 17 26 type S3FS struct { 18 27 client *storage.Client 19 28 bucket string 20 29 root string 21 30 separator string 31 + unixMeta *unixMetaConfig 22 32 23 33 // temps holds TempFile-backed buffers keyed by canonical S3 key, so a 24 34 // subsequent Open of the same path returns a reader over the same bytes ··· 27 37 temps map[string]*tempBuffer 28 38 } 29 39 40 + // Option configures an S3FS at construction time. 41 + type Option func(*S3FS) 42 + 43 + // WithUnixMetadata enables storing and reading POSIX file attributes as S3 user 44 + // metadata (see the unixmeta package). uid and gid are the numeric owner/group 45 + // recorded on newly written objects; callers resolve any names to numbers 46 + // themselves. umask is applied to the default mode (0666 for files) when 47 + // writing. Without this option the filesystem stores no attributes. 48 + func WithUnixMetadata(uid, gid uint32, umask os.FileMode) Option { 49 + return func(fs3 *S3FS) { 50 + fs3.unixMeta = &unixMetaConfig{uid: uid, gid: gid, umask: umask} 51 + } 52 + } 53 + 30 54 // NewS3FS creates a new S3FS Filesystem. 31 - func NewS3FS(client *storage.Client, bucket string) (billy.Filesystem, error) { 55 + func NewS3FS(client *storage.Client, bucket string, opts ...Option) (billy.Filesystem, error) { 32 56 // Check for a non-nil client 33 57 if client == nil { 34 58 return nil, fmt.Errorf("s3 client cannot be nil") 35 59 } 36 - return &S3FS{ 60 + fs3 := &S3FS{ 37 61 client: client, 38 62 bucket: bucket, 39 63 root: "", 40 64 separator: DefaultSeparator, 41 65 temps: make(map[string]*tempBuffer), 42 - }, nil 66 + } 67 + for _, opt := range opts { 68 + opt(fs3) 69 + } 70 + return fs3, nil 43 71 } 44 72 45 73 // Capabilities returns the filesystem capabilities.
+207
internal/s3fs/unixmeta/unixmeta.go
··· 1 + // Package unixmeta encodes and decodes POSIX file attributes (owner, group, 2 + // permissions, timestamps) as S3 user-defined metadata (x-amz-meta-* headers). 3 + // 4 + // S3 does not natively model Unix attributes, so a POSIX filesystem layered on 5 + // top of object storage keeps them in a small set of string-valued metadata 6 + // headers. The on-the-wire format is documented in 7 + // docs/reference/how-tigris-fs-unix-metadata.md. 8 + package unixmeta 9 + 10 + import ( 11 + "net/url" 12 + "os" 13 + "os/user" 14 + "strconv" 15 + "time" 16 + ) 17 + 18 + // Metadata keys (without the x-amz-meta- prefix the S3 SDK prepends). 19 + const ( 20 + keyUID = "uid" 21 + keyGID = "gid" 22 + keyMode = "mode" 23 + keyRdev = "rdev" 24 + keyMtime = "mtime" 25 + keySymlinkTarget = "--symlink-target" 26 + ) 27 + 28 + // POSIX file-type mask and the bits stored in the high part of mode_t. 29 + const ( 30 + modeTypeMask = 0o170000 31 + modeRegular = 0o100000 32 + modeDir = 0o040000 33 + modeSymlink = 0o120000 34 + modeBlock = 0o060000 35 + modeChar = 0o020000 36 + modeFIFO = 0o010000 37 + modeSocket = 0o140000 38 + ) 39 + 40 + // Attrs is the set of POSIX attributes recorded on an object. 41 + type Attrs struct { 42 + UID, GID uint32 43 + Mode os.FileMode 44 + Rdev uint32 45 + Mtime time.Time 46 + SymlinkTarget string // "" if not a symlink 47 + } 48 + 49 + // PosixMode converts a Go os.FileMode to the POSIX mode_t integer stored in 50 + // x-amz-meta-mode. 51 + func PosixMode(m os.FileMode) uint32 { 52 + out := uint32(m.Perm()) // low 9 permission bits 53 + if m&os.ModeSetuid != 0 { 54 + out |= 0o4000 55 + } 56 + if m&os.ModeSetgid != 0 { 57 + out |= 0o2000 58 + } 59 + if m&os.ModeSticky != 0 { 60 + out |= 0o1000 61 + } 62 + switch { 63 + case m&os.ModeDir != 0: 64 + out |= modeDir 65 + case m&os.ModeSymlink != 0: 66 + out |= modeSymlink 67 + case m&os.ModeDevice != 0 && m&os.ModeCharDevice != 0: 68 + out |= modeChar 69 + case m&os.ModeDevice != 0: 70 + out |= modeBlock 71 + case m&os.ModeNamedPipe != 0: 72 + out |= modeFIFO 73 + case m&os.ModeSocket != 0: 74 + out |= modeSocket 75 + default: 76 + out |= modeRegular 77 + } 78 + return out 79 + } 80 + 81 + // GoFileMode is the inverse of PosixMode. 82 + func GoFileMode(p uint32) os.FileMode { 83 + m := os.FileMode(p & 0o777) 84 + if p&0o4000 != 0 { 85 + m |= os.ModeSetuid 86 + } 87 + if p&0o2000 != 0 { 88 + m |= os.ModeSetgid 89 + } 90 + if p&0o1000 != 0 { 91 + m |= os.ModeSticky 92 + } 93 + switch p & modeTypeMask { 94 + case modeDir: 95 + m |= os.ModeDir 96 + case modeSymlink: 97 + m |= os.ModeSymlink 98 + case modeChar: 99 + m |= os.ModeDevice | os.ModeCharDevice 100 + case modeBlock: 101 + m |= os.ModeDevice 102 + case modeFIFO: 103 + m |= os.ModeNamedPipe 104 + case modeSocket: 105 + m |= os.ModeSocket 106 + case modeRegular: 107 + // regular file: no extra bits 108 + } 109 + return m 110 + } 111 + 112 + // Encode produces the user-metadata map for a PutObject call. Pass the result 113 + // as PutObjectInput.Metadata; the S3 SDK prepends x-amz-meta- and lowercases 114 + // the keys. 115 + func Encode(a Attrs) map[string]string { 116 + mode := PosixMode(a.Mode) 117 + m := map[string]string{ 118 + keyUID: strconv.FormatUint(uint64(a.UID), 10), 119 + keyGID: strconv.FormatUint(uint64(a.GID), 10), 120 + keyMode: strconv.FormatUint(uint64(mode), 10), 121 + keyMtime: strconv.FormatInt(a.Mtime.Unix(), 10), 122 + } 123 + if a.Mode&(os.ModeDevice|os.ModeCharDevice) != 0 { 124 + m[keyRdev] = strconv.FormatUint(uint64(a.Rdev), 10) 125 + } 126 + if a.SymlinkTarget != "" { 127 + m[keySymlinkTarget] = url.QueryEscape(a.SymlinkTarget) 128 + } 129 + return m 130 + } 131 + 132 + // Decode merges metadata from a HeadObject / GetObject response into defaults. 133 + // Missing keys leave the corresponding field of defaults untouched, which is 134 + // the behavior a POSIX filesystem usually wants: an object with no uid header 135 + // inherits the mount's default uid, not zero. Malformed values are treated as 136 + // missing so a single bad header doesn't make a file unreadable. 137 + func Decode(meta map[string]string, defaults Attrs) Attrs { 138 + out := defaults 139 + if s, ok := meta[keyUID]; ok { 140 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 141 + out.UID = uint32(v) 142 + } 143 + } 144 + if s, ok := meta[keyGID]; ok { 145 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 146 + out.GID = uint32(v) 147 + } 148 + } 149 + if s, ok := meta[keyMode]; ok { 150 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 151 + out.Mode = GoFileMode(uint32(v)) 152 + } 153 + } 154 + if s, ok := meta[keyRdev]; ok { 155 + if v, err := strconv.ParseUint(s, 0, 32); err == nil { 156 + out.Rdev = uint32(v) 157 + } 158 + } 159 + if s, ok := meta[keyMtime]; ok { 160 + if v, err := strconv.ParseInt(s, 0, 64); err == nil { 161 + out.Mtime = time.Unix(v, 0) 162 + } 163 + } 164 + if s, ok := meta[keySymlinkTarget]; ok { 165 + if dec, err := url.QueryUnescape(s); err == nil { 166 + out.SymlinkTarget = dec 167 + } 168 + } 169 + return out 170 + } 171 + 172 + // LookupUID resolves a user name to a numeric UID. It first consults the host 173 + // passwd database via os/user; if the name is not found there it is parsed as a 174 + // decimal UID. This lets callers pass either "alice" or "1000" and works in 175 + // containers that lack a matching passwd entry. The package never calls this 176 + // itself — callers decide whether to resolve names. 177 + func LookupUID(name string) (uint32, error) { 178 + if u, err := user.Lookup(name); err == nil { 179 + v, perr := strconv.ParseUint(u.Uid, 10, 32) 180 + if perr != nil { 181 + return 0, perr 182 + } 183 + return uint32(v), nil 184 + } 185 + v, err := strconv.ParseUint(name, 10, 32) 186 + if err != nil { 187 + return 0, err 188 + } 189 + return uint32(v), nil 190 + } 191 + 192 + // LookupGID resolves a group name to a numeric GID, with the same name-or-number 193 + // semantics as LookupUID. 194 + func LookupGID(name string) (uint32, error) { 195 + if g, err := user.LookupGroup(name); err == nil { 196 + v, perr := strconv.ParseUint(g.Gid, 10, 32) 197 + if perr != nil { 198 + return 0, perr 199 + } 200 + return uint32(v), nil 201 + } 202 + v, err := strconv.ParseUint(name, 10, 32) 203 + if err != nil { 204 + return 0, err 205 + } 206 + return uint32(v), nil 207 + }
+132
internal/s3fs/unixmeta/unixmeta_test.go
··· 1 + package unixmeta 2 + 3 + import ( 4 + "os" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func TestModeRoundTrip(t *testing.T) { 10 + t.Parallel() 11 + 12 + for _, tt := range []struct { 13 + name string 14 + mode os.FileMode 15 + }{ 16 + {name: "regular file rw-r--r--", mode: 0o644}, 17 + {name: "regular file rwxr-xr-x", mode: 0o755}, 18 + {name: "directory", mode: os.ModeDir | 0o755}, 19 + {name: "symlink", mode: os.ModeSymlink | 0o777}, 20 + {name: "block device", mode: os.ModeDevice | 0o660}, 21 + {name: "char device", mode: os.ModeDevice | os.ModeCharDevice | 0o620}, 22 + {name: "fifo", mode: os.ModeNamedPipe | 0o644}, 23 + {name: "socket", mode: os.ModeSocket | 0o755}, 24 + {name: "setuid", mode: os.ModeSetuid | 0o755}, 25 + {name: "setgid", mode: os.ModeSetgid | 0o755}, 26 + {name: "sticky dir", mode: os.ModeDir | os.ModeSticky | 0o777}, 27 + } { 28 + t.Run(tt.name, func(t *testing.T) { 29 + t.Parallel() 30 + got := GoFileMode(PosixMode(tt.mode)) 31 + if got != tt.mode { 32 + t.Logf("want: %v (%#o)", tt.mode, uint32(tt.mode)) 33 + t.Logf("got: %v (%#o)", got, uint32(got)) 34 + t.Error("mode did not survive round trip") 35 + } 36 + }) 37 + } 38 + } 39 + 40 + func TestPosixModeKnownValue(t *testing.T) { 41 + t.Parallel() 42 + 43 + // 0o100644 == 33188: regular file with rw-r--r--, per the reference doc. 44 + if got := PosixMode(0o644); got != 0o100644 { 45 + t.Errorf("PosixMode(0o644) = %#o, want %#o", got, 0o100644) 46 + } 47 + } 48 + 49 + func TestEncodeDecodeRoundTrip(t *testing.T) { 50 + t.Parallel() 51 + 52 + mtime := time.Unix(1716700000, 0) 53 + 54 + for _, tt := range []struct { 55 + name string 56 + in Attrs 57 + }{ 58 + { 59 + name: "regular file", 60 + in: Attrs{UID: 1000, GID: 1000, Mode: 0o644, Mtime: mtime}, 61 + }, 62 + { 63 + name: "directory", 64 + in: Attrs{UID: 0, GID: 0, Mode: os.ModeDir | 0o755, Mtime: mtime}, 65 + }, 66 + { 67 + name: "char device with rdev", 68 + in: Attrs{UID: 0, GID: 5, Mode: os.ModeDevice | os.ModeCharDevice | 0o620, Rdev: 1280, Mtime: mtime}, 69 + }, 70 + { 71 + name: "symlink with awkward target", 72 + in: Attrs{UID: 1000, GID: 1000, Mode: os.ModeSymlink | 0o777, Mtime: mtime, SymlinkTarget: "/etc/has spaces/and%percent"}, 73 + }, 74 + } { 75 + t.Run(tt.name, func(t *testing.T) { 76 + t.Parallel() 77 + got := Decode(Encode(tt.in), Attrs{}) 78 + if got.UID != tt.in.UID || got.GID != tt.in.GID { 79 + t.Errorf("uid/gid: want %d/%d, got %d/%d", tt.in.UID, tt.in.GID, got.UID, got.GID) 80 + } 81 + if got.Mode != tt.in.Mode { 82 + t.Errorf("mode: want %v, got %v", tt.in.Mode, got.Mode) 83 + } 84 + if !got.Mtime.Equal(tt.in.Mtime) { 85 + t.Errorf("mtime: want %v, got %v", tt.in.Mtime, got.Mtime) 86 + } 87 + if got.SymlinkTarget != tt.in.SymlinkTarget { 88 + t.Errorf("symlink target: want %q, got %q", tt.in.SymlinkTarget, got.SymlinkTarget) 89 + } 90 + if tt.in.Rdev != 0 && got.Rdev != tt.in.Rdev { 91 + t.Errorf("rdev: want %d, got %d", tt.in.Rdev, got.Rdev) 92 + } 93 + }) 94 + } 95 + } 96 + 97 + func TestDecodeMissingKeysKeepDefaults(t *testing.T) { 98 + t.Parallel() 99 + 100 + defaults := Attrs{UID: 501, GID: 20, Mode: 0o644, Mtime: time.Unix(42, 0)} 101 + got := Decode(map[string]string{}, defaults) 102 + if got != defaults { 103 + t.Logf("want: %+v", defaults) 104 + t.Logf("got: %+v", got) 105 + t.Error("empty metadata should leave defaults untouched") 106 + } 107 + } 108 + 109 + func TestDecodeMalformedTreatedAsMissing(t *testing.T) { 110 + t.Parallel() 111 + 112 + defaults := Attrs{UID: 501, GID: 20, Mode: 0o644, Mtime: time.Unix(42, 0)} 113 + meta := map[string]string{ 114 + "uid": "not-a-number", 115 + "gid": "99", 116 + "mode": "garbage", 117 + "mtime": "also-bad", 118 + } 119 + got := Decode(meta, defaults) 120 + if got.UID != defaults.UID { 121 + t.Errorf("malformed uid should fall back to default: want %d, got %d", defaults.UID, got.UID) 122 + } 123 + if got.GID != 99 { 124 + t.Errorf("valid gid should be parsed: want 99, got %d", got.GID) 125 + } 126 + if got.Mode != defaults.Mode { 127 + t.Errorf("malformed mode should fall back to default: want %v, got %v", defaults.Mode, got.Mode) 128 + } 129 + if !got.Mtime.Equal(defaults.Mtime) { 130 + t.Errorf("malformed mtime should fall back to default: want %v, got %v", defaults.Mtime, got.Mtime) 131 + } 132 + }