A virtual jailed shell environment for Go apps backed by a virtual filesystem. xeiaso.net/blog/2026/dancing-mad-sandboxing/
shell agents terrible-idea sandbox
0

Configure Feed

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

feat(s3fs): use tigrisfs-compatible unix metadata storage

Store POSIX file attributes (uid, gid, mode, mtime) as S3 user
metadata so files written through s3fs round-trip their 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. sophia gates it behind --fs-unix-metadata (plus --fs-user,
--fs-group, --fs-umask), so existing deployments are unaffected.

The new unixmeta package implements the x-amz-meta-* convention
documented in docs/reference. It deals only in numeric uid/gid;
name resolution is exposed as optional helpers callers may use.

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

+947 -35
+31 -3
cmd/s3fs-test/main.go
··· 7 7 "io/fs" 8 8 "log" 9 9 "os" 10 + "strconv" 10 11 11 12 "github.com/spf13/pflag" 12 13 "github.com/tigrisdata/storage-go" 13 14 "tangled.org/xeiaso.net/kefka/s3fs" 15 + "tangled.org/xeiaso.net/kefka/s3fs/unixmeta" 14 16 15 17 _ "github.com/joho/godotenv/autoload" 16 18 ) 17 19 18 20 var ( 19 21 bucket = pflag.String("bucket", os.Getenv("BUCKET_NAME"), "bucket to operate on") 22 + 23 + fsUnixMetadata = pflag.Bool("fs-unix-metadata", false, "store/read POSIX file attributes as S3 user metadata") 24 + fsUser = pflag.String("fs-user", "0", "owner (name or numeric uid) for written files") 25 + fsGroup = pflag.String("fs-group", "0", "group (name or numeric gid) for written files") 26 + fsUmask = pflag.String("fs-umask", "022", "octal umask applied to new files") 20 27 ) 21 28 22 29 func main() { ··· 28 35 log.Fatal(fmt.Errorf("can't make storage client: %w", err)) 29 36 } 30 37 31 - fsys, err := s3fs.NewS3FS(client, *bucket) 38 + var opts []s3fs.Option 39 + if *fsUnixMetadata { 40 + uid, err := unixmeta.LookupUID(*fsUser) 41 + if err != nil { 42 + log.Fatal(fmt.Errorf("--fs-user %q: %w", *fsUser, err)) 43 + } 44 + gid, err := unixmeta.LookupGID(*fsGroup) 45 + if err != nil { 46 + log.Fatal(fmt.Errorf("--fs-group %q: %w", *fsGroup, err)) 47 + } 48 + umask, err := strconv.ParseUint(*fsUmask, 8, 32) 49 + if err != nil { 50 + log.Fatal(fmt.Errorf("--fs-umask %q: must be octal: %w", *fsUmask, err)) 51 + } 52 + opts = append(opts, s3fs.WithUnixMetadata(uid, gid, os.FileMode(umask))) 53 + } 54 + 55 + fsys, err := s3fs.NewS3FS(client, *bucket, opts...) 32 56 if err != nil { 33 57 panic(err) 34 58 } ··· 39 63 fmt.Printf("Stat(%q) -> err: %v (is fs.ErrNotExist=%v)\n", p, err, errors.Is(err, fs.ErrNotExist)) 40 64 return 41 65 } 42 - fmt.Printf("Stat(%q) -> name=%q dir=%v size=%d mtime=%s\n", 43 - p, info.Name(), info.IsDir(), info.Size(), info.ModTime().Format("2006-01-02T15:04:05")) 66 + owner := "" 67 + if st, ok := info.Sys().(*s3fs.FileStat); ok { 68 + owner = fmt.Sprintf(" uid=%d gid=%d", st.UID, st.GID) 69 + } 70 + fmt.Printf("Stat(%q) -> name=%q dir=%v mode=%s size=%d mtime=%s%s\n", 71 + p, info.Name(), info.IsDir(), info.Mode(), info.Size(), info.ModTime().Format("2006-01-02T15:04:05"), owner) 44 72 } 45 73 46 74 readdir := func(p string) {
+40 -2
cmd/sophia/main.go
··· 10 10 "log/slog" 11 11 "os" 12 12 "os/signal" 13 + "strconv" 13 14 "sync" 14 15 "sync/atomic" 15 16 "syscall" ··· 31 32 "tangled.org/xeiaso.net/kefka/command/registry/wasmprog" 32 33 "tangled.org/xeiaso.net/kefka/internal/billysh" 33 34 "tangled.org/xeiaso.net/kefka/s3fs" 35 + "tangled.org/xeiaso.net/kefka/s3fs/unixmeta" 34 36 35 37 _ "embed" 36 38 ··· 46 48 shutdownGrace = pflag.Duration("shutdown-grace", 30*time.Second, "how long to wait for in-flight SSH sessions to finish after a shutdown signal before forcing them closed") 47 49 shutdownForceTimeout = pflag.Duration("shutdown-force-timeout", 90*time.Second, "how long to wait, after force-closing connections, for per-session bucket cleanup to finish") 48 50 51 + fsUnixMetadata = pflag.Bool("fs-unix-metadata", false, "store POSIX file attributes (uid/gid/mode/mtime) as S3 user metadata") 52 + fsUser = pflag.String("fs-user", "0", "owner (name or numeric uid) recorded on written files when --fs-unix-metadata is set") 53 + fsGroup = pflag.String("fs-group", "0", "group (name or numeric gid) recorded on written files when --fs-unix-metadata is set") 54 + fsUmask = pflag.String("fs-umask", "022", "octal umask applied to new files when --fs-unix-metadata is set") 55 + 49 56 //go:embed static/motd 50 57 motd []byte 51 58 ) ··· 72 79 func run() error { 73 80 srv := New() 74 81 82 + fsOpts, err := buildFSOptions() 83 + if err != nil { 84 + return err 85 + } 86 + srv.fsOpts = fsOpts 87 + 75 88 server := &ssh.Server{ 76 89 Addr: *bind, 77 90 Handler: srv.HandleSSH, ··· 83 96 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) 84 97 defer stop() 85 98 86 - slog.Info("listening", "bind", *bind, "timeout", *timeout, "sshPrivateKey", *sshPrivateKey, "sshPublicKey", *sshPublicKey) 99 + slog.Info("listening", "bind", *bind, "timeout", *timeout, "sshPrivateKey", *sshPrivateKey, "sshPublicKey", *sshPublicKey, "unixMetadata", *fsUnixMetadata) 87 100 88 101 errCh := make(chan error, 1) 89 102 go func() { ··· 132 145 return nil 133 146 } 134 147 148 + // buildFSOptions resolves the --fs-* flags into s3fs options. When 149 + // --fs-unix-metadata is off it returns nil, so the filesystem behaves exactly as 150 + // it did before the feature existed. 151 + func buildFSOptions() ([]s3fs.Option, error) { 152 + if !*fsUnixMetadata { 153 + return nil, nil 154 + } 155 + 156 + uid, err := unixmeta.LookupUID(*fsUser) 157 + if err != nil { 158 + return nil, fmt.Errorf("--fs-user %q: %w", *fsUser, err) 159 + } 160 + gid, err := unixmeta.LookupGID(*fsGroup) 161 + if err != nil { 162 + return nil, fmt.Errorf("--fs-group %q: %w", *fsGroup, err) 163 + } 164 + umask, err := strconv.ParseUint(*fsUmask, 8, 32) 165 + if err != nil { 166 + return nil, fmt.Errorf("--fs-umask %q: must be octal: %w", *fsUmask, err) 167 + } 168 + 169 + return []s3fs.Option{s3fs.WithUnixMetadata(uid, gid, os.FileMode(umask))}, nil 170 + } 171 + 135 172 type Server struct { 136 173 // sessions tracks in-flight HandleSSH invocations so shutdown can wait 137 174 // for each runKefka's deferred bucket cleanup to complete. gliderlabs/ssh's 138 175 // own connWg counts connection-loop goroutines, which return before the 139 176 // session handler goroutine does, so Server.Shutdown alone is not enough. 140 177 sessions sync.WaitGroup 178 + fsOpts []s3fs.Option 141 179 } 142 180 143 181 func New() *Server { ··· 201 239 lg.Info("cleaned up bucket") 202 240 }() 203 241 204 - fsys, err := s3fs.NewS3FS(client, sessBucket) 242 + fsys, err := s3fs.NewS3FS(client, sessBucket, s.fsOpts...) 205 243 if err != nil { 206 244 return fmt.Errorf("can't setup s3fs: %w", err) 207 245 }
+139
docs/plans/s3fs-unix-metadata.md
··· 1 + # Plan: Optional Unix-permission metadata in s3fs 2 + 3 + ## Context 4 + 5 + `s3fs/` is a go-billy filesystem mapped onto Tigris (S3) storage, consumed by the 6 + `sophia` SSH server (`cmd/sophia`) and `cmd/s3fs-test`. Today 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 + (`s3fs/fileinfo.go:21`, `s3fs/file.go:198`). 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: **username**, **group name**, and 15 + **umask**. 16 + 17 + ### Constraints / assumptions (from user) 18 + 19 + - **Off by default in sophia.** When disabled, behavior is byte-for-byte what it 20 + is today. 21 + - Knobs are configured values (not derived from `sess.User()`). 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 `s3fs/unixmeta` 32 + 33 + New file `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. Follow `go-table-driven-tests` + `xe-go-style`. 46 + 47 + ### 2. Opt-in config on `S3FS` (`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 + } 60 + 61 + type Option func(*S3FS) 62 + func WithUnixMetadata(uid, gid uint32, umask os.FileMode) Option 63 + 64 + func NewS3FS(client *storage.Client, bucket string, opts ...Option) (billy.Filesystem, error) 65 + ``` 66 + 67 + Variadic options keep both existing callers (`cmd/sophia`, `cmd/s3fs-test`) 68 + compiling unchanged. 69 + 70 + ### 3. Write path (`s3fs/file.go`) 71 + 72 + Thread `*unixMetaConfig` into `newS3WriteFile` and `newS3MultipartUploadFile` 73 + (plumbed from `OpenFile` in `s3fs/basic.go`). In each `Close()`: 74 + 75 + - If config is nil → unchanged (no `Metadata`). 76 + - Else set `PutObjectInput.Metadata = unixmeta.Encode(unixmeta.Attrs{UID, GID, 77 + Mode: 0o666 &^ umask, Mtime: time.Now()})`. (Multipart: `Metadata` on 78 + `CreateMultipartUploadInput` at construction.) 79 + 80 + ### 4. Read path (`s3fs/basic.go`, `s3fs/fileinfo.go`) 81 + 82 + - Extend `s3FileInfo` with `uid, gid uint32`; make `Sys()` return a small struct 83 + exposing `Uid()/Gid()` (instead of `nil`) so consumers can read the raw numeric 84 + ownership and resolve to names themselves if they want. 85 + - Add `newFileInfoFromHead(name, head, cfg)` that, when `cfg != nil`, runs 86 + `unixmeta.Decode(head.Metadata, defaults)` with defaults `{UID: cfg.uid, 87 + GID: cfg.gid, Mode: 0o644, Mtime: head.LastModified}` and builds a fully 88 + populated `s3FileInfo`. When `cfg == nil`, keep the current `0666` path. 89 + - Wire this into `Stat` (`s3fs/basic.go:127`). `Lstat` already delegates to `Stat`. 90 + - **ReadDir** (`s3fs/dir.go`): `ListObjectsV2` does not return user metadata, so 91 + list entries keep default modes; full attributes come from `Stat`. (`ls` stats 92 + entries for the long format.) Documented limitation; avoids an N-Head fan-out. 93 + 94 + ### 5. sophia wiring (`cmd/sophia/main.go`) — default OFF 95 + 96 + Add flags, all defaulting to the disabled state: 97 + 98 + - `--fs-unix-metadata` (bool, default **false**) 99 + - `--fs-user` (string), `--fs-group` (string), `--fs-umask` (e.g. octal string, default `022`) 100 + 101 + At `s3fs.NewS3FS` (line 136): only pass `WithUnixMetadata(...)` when 102 + `--fs-unix-metadata` is true. sophia (the caller) resolves the `--fs-user` / 103 + `--fs-group` strings to numeric IDs using the `unixmeta.LookupUID/LookupGID` 104 + helpers and passes numbers into `WithUnixMetadata`. When the flag is false, call 105 + `NewS3FS(client, sessBucket)` exactly as today. 106 + 107 + ### 6. (Optional, same PR if desired) `ls -l` ownership display 108 + 109 + `command/internal/ls/ls.go:387-397` hardcodes owner/group to `"user"`/`"0"`. 110 + Could read the numeric uid/gid from `info.Sys()` when present (numeric display 111 + only; no name resolution). **Out of scope by default** — mode bits already 112 + display correctly once `Stat` returns decoded mode. 113 + 114 + ## Files touched 115 + 116 + - `s3fs/unixmeta/unixmeta.go` (new), `s3fs/unixmeta/unixmeta_test.go` (new) 117 + - `s3fs/filesystem.go` — config + `Option` + `WithUnixMetadata` 118 + - `s3fs/basic.go` — plumb config into `OpenFile`; decode in `Stat` 119 + - `s3fs/file.go` — encode metadata in write/multipart `Close` 120 + - `s3fs/fileinfo.go` — uid/gid fields, `Sys()`, decode constructor 121 + - `cmd/sophia/main.go` — opt-in flags (default off) 122 + - `cmd/s3fs-test/main.go` — optional flags to exercise the feature 123 + 124 + ## Verification 125 + 126 + - `go test ./s3fs/...` — unixmeta round-trip + tolerance tests pass. 127 + - `go build ./...` — both commands still compile. 128 + - Manual (needs Tigris creds + `BUCKET_NAME`): run `cmd/s3fs-test` with the 129 + feature on; write a file, then `HeadObject` (via `tigris-objects` MCP / aws cli) 130 + and confirm `x-amz-meta-uid/gid/mode/mtime` are present and correct; read it 131 + back and confirm `Stat().Mode()` reflects `0666 &^ umask`. 132 + - Confirm default path: run sophia without `--fs-unix-metadata`, write a file, 133 + `HeadObject` shows **no** `x-amz-meta-*` headers (regression guard). 134 + 135 + ## Deferred (not in this change) 136 + 137 + - `billy.Change` (chmod/chown/chtimes) writeback via metadata-rewriting CopyObject. 138 + - Symlink target / device-node (`rdev`) storage. 139 + - 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
s3fs/basic.go
··· 11 11 "path" 12 12 "strings" 13 13 14 - "github.com/aws/aws-sdk-go-v2/aws" 15 14 "github.com/aws/aws-sdk-go-v2/service/s3" 16 15 "github.com/aws/smithy-go" 17 16 "github.com/go-git/go-billy/v5" ··· 101 100 return nil, &os.PathError{Op: "open", Path: filename, Err: fs.ErrNotExist} 102 101 103 102 case O_WRONLY: 104 - return newS3WriteFile(fs3.client, fs3.bucket, p) 103 + return newS3WriteFile(fs3.client, fs3.bucket, p, fs3.unixMeta) 105 104 106 105 case O_WRMULTIPART: 107 - return newS3MultipartUploadFile(fs3.client, fs3.bucket, p) 106 + return newS3MultipartUploadFile(fs3.client, fs3.bucket, p, fs3.unixMeta) 108 107 109 108 default: 110 109 return nil, errors.New("unsupported open flag") ··· 125 124 Key: &key, 126 125 }) 127 126 if err == nil { 128 - return newFileInfo( 129 - path.Base(key), 130 - aws.ToInt64(head.ContentLength), 131 - aws.ToTime(head.LastModified), 132 - ), nil 127 + return newFileInfoFromHead(path.Base(key), head, fs3.unixMeta), nil 133 128 } 134 129 135 130 var apiErr smithy.APIError
+39 -17
s3fs/file.go
··· 9 9 "io/ioutil" 10 10 "os" 11 11 "syscall" 12 + "time" 12 13 13 14 "github.com/aws/aws-sdk-go-v2/service/s3" 14 15 "github.com/tigrisdata/storage-go" 15 16 "go.uber.org/atomic" 17 + "tangled.org/xeiaso.net/kefka/s3fs/unixmeta" 16 18 ) 19 + 20 + // newFileMetadata returns the x-amz-meta-* map to attach to a newly written 21 + // object, or nil when the Unix-metadata feature is disabled. New files take the 22 + // session's default owner and a mode of 0666 masked by the session umask. 23 + func newFileMetadata(cfg *unixMetaConfig) map[string]string { 24 + if cfg == nil { 25 + return nil 26 + } 27 + return unixmeta.Encode(unixmeta.Attrs{ 28 + UID: cfg.uid, 29 + GID: cfg.gid, 30 + Mode: 0o666 &^ cfg.umask, 31 + Mtime: time.Now(), 32 + }) 33 + } 17 34 18 35 const ( 19 36 ModeMultipartUpload os.FileMode = fs.ModePerm + 1 // Custom os.FileMode for S3 multipart upload ··· 133 150 // Upon creation, a buffer is created to store the file contents. Upon close, 134 151 // the file is uploaded to S3. 135 152 type s3WriteFile struct { 136 - client *storage.Client // s3 skd client 137 - bucket string // S3 bucket name 138 - key string // File object's key in S3 139 - closed bool // Is the file closed? 140 - buf *bytes.Buffer // Buffer for storing the file before it's uploaded 153 + client *storage.Client // s3 skd client 154 + bucket string // S3 bucket name 155 + key string // File object's key in S3 156 + closed bool // Is the file closed? 157 + buf *bytes.Buffer // Buffer for storing the file before it's uploaded 158 + unixMeta *unixMetaConfig // optional POSIX attribute defaults (nil = disabled) 141 159 } 142 160 143 161 // newS3WriteFile creates a new s3ReadFile. 144 - func newS3WriteFile(client *storage.Client, bucket, key string) (*s3WriteFile, error) { 162 + func newS3WriteFile(client *storage.Client, bucket, key string, cfg *unixMetaConfig) (*s3WriteFile, error) { 145 163 // TODO: Validate the key 146 164 // ... 147 165 148 166 return &s3WriteFile{ 149 - client: client, 150 - bucket: bucket, 151 - key: key, 152 - buf: bytes.NewBuffer(nil), 167 + client: client, 168 + bucket: bucket, 169 + key: key, 170 + buf: bytes.NewBuffer(nil), 171 + unixMeta: cfg, 153 172 }, nil 154 173 } 155 174 ··· 199 218 // Run the GetObject operation 200 219 // TODO: Currently `res` is not used. Should it be? 201 220 _, err := f.client.PutObject(ctx, &s3.PutObjectInput{ 202 - Bucket: &f.bucket, 203 - Key: &f.key, 204 - Body: body, 221 + Bucket: &f.bucket, 222 + Key: &f.key, 223 + Body: body, 224 + Metadata: newFileMetadata(f.unixMeta), 205 225 }) 206 226 if err != nil { 207 227 return fmt.Errorf("unable to perform GetObject operation: %w", err) ··· 237 257 } 238 258 239 259 // newS3MultipartUploadFile creates a new s3ReadFile. 240 - func newS3MultipartUploadFile(client *storage.Client, bucket, key string) (*s3MultipartUploadFile, error) { 260 + func newS3MultipartUploadFile(client *storage.Client, bucket, key string, cfg *unixMetaConfig) (*s3MultipartUploadFile, error) { 241 261 // TODO: Check if the file exists 242 262 // ... 243 263 244 264 // Create the context 245 265 ctx := context.TODO() // TODO: How can user-supplied contexts be supported? 246 266 247 - // Run the GetObject operation 267 + // Run the GetObject operation. POSIX attributes (if enabled) must be set 268 + // now: CompleteMultipartUpload cannot attach user metadata. 248 269 res, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ 249 - Bucket: &bucket, 250 - Key: &key, 270 + Bucket: &bucket, 271 + Key: &key, 272 + Metadata: newFileMetadata(cfg), 251 273 }) 252 274 if err != nil { 253 275 return nil, fmt.Errorf("unable to create multipart upload: %w", err)
+44 -2
s3fs/fileinfo.go
··· 4 4 "io/fs" 5 5 "os" 6 6 "time" 7 + 8 + "github.com/aws/aws-sdk-go-v2/aws" 9 + "github.com/aws/aws-sdk-go-v2/service/s3" 10 + "tangled.org/xeiaso.net/kefka/s3fs/unixmeta" 7 11 ) 8 12 13 + // FileStat is the value returned by s3FileInfo.Sys() when the Unix-metadata 14 + // feature is enabled. It carries the raw numeric owner and group so consumers 15 + // can resolve them to names however they like. 16 + type FileStat struct { 17 + UID, GID uint32 18 + } 19 + 9 20 // s3FileInfo implements os.FileInfo 10 21 type s3FileInfo struct { 11 22 name string 12 23 size int64 13 24 mode os.FileMode 14 25 modTime time.Time 26 + sys *FileStat 15 27 } 16 28 17 29 func newFileInfo(name string, size int64, modTime time.Time) os.FileInfo { ··· 31 43 } 32 44 } 33 45 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 s3FileInfo{ 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 + 34 73 func (fi s3FileInfo) Name() string { 35 74 return fi.name 36 75 } ··· 47 86 return fi.mode.IsDir() 48 87 } 49 88 50 - func (fi s3FileInfo) Sys() interface{} { 51 - return nil 89 + func (fi s3FileInfo) Sys() any { 90 + if fi.sys == nil { 91 + return nil 92 + } 93 + return fi.sys 52 94 } 53 95 54 96 func (fi s3FileInfo) ModTime() time.Time {
+31 -3
s3fs/filesystem.go
··· 2 2 3 3 import ( 4 4 "fmt" 5 + "os" 5 6 "path" 6 7 7 8 "github.com/go-git/go-billy/v5" ··· 11 12 const ( 12 13 DefaultSeparator = "/" 13 14 ) 15 + 16 + // unixMetaConfig holds the session defaults used when the optional Unix-metadata 17 + // feature is enabled. A nil *unixMetaConfig means the feature is off and the 18 + // filesystem behaves as if no POSIX attributes exist. 19 + type unixMetaConfig struct { 20 + uid, gid uint32 21 + umask os.FileMode 22 + } 14 23 15 24 type S3FS struct { 16 25 client *storage.Client 17 26 bucket string 18 27 root string 19 28 separator string 29 + unixMeta *unixMetaConfig 30 + } 31 + 32 + // Option configures an S3FS at construction time. 33 + type Option func(*S3FS) 34 + 35 + // WithUnixMetadata enables storing and reading POSIX file attributes as S3 user 36 + // metadata (see the unixmeta package). uid and gid are the numeric owner/group 37 + // recorded on newly written objects; callers resolve any names to numbers 38 + // themselves. umask is applied to the default mode (0666 for files) when 39 + // writing. Without this option the filesystem stores no attributes. 40 + func WithUnixMetadata(uid, gid uint32, umask os.FileMode) Option { 41 + return func(fs3 *S3FS) { 42 + fs3.unixMeta = &unixMetaConfig{uid: uid, gid: gid, umask: umask} 43 + } 20 44 } 21 45 22 46 // NewS3FS creates a new S3FS Filesystem. 23 - func NewS3FS(client *storage.Client, bucket string) (billy.Filesystem, error) { 47 + func NewS3FS(client *storage.Client, bucket string, opts ...Option) (billy.Filesystem, error) { 24 48 // Check for a non-nil client 25 49 if client == nil { 26 50 return nil, fmt.Errorf("s3 client cannot be nil") 27 51 } 28 - return &S3FS{ 52 + fs3 := &S3FS{ 29 53 client: client, 30 54 bucket: bucket, 31 55 root: "", 32 56 separator: DefaultSeparator, 33 - }, nil 57 + } 58 + for _, opt := range opts { 59 + opt(fs3) 60 + } 61 + return fs3, nil 34 62 } 35 63 36 64 // Capabilities returns the filesystem capabilities.
+207
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
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 + }