From c109b440ec8e48762db688cbc256826c8b890bad Mon Sep 17 00:00:00 2001 From: Mikhail Kviatkovskii Date: Wed, 20 May 2026 13:15:44 +0000 Subject: [PATCH] s3: support x-amz-meta-mode for symlinks (s3fs-fuse interop) --- go/s3/s3.go | 52 ++++++++++++++++++++- go/s3/s3_test.go | 117 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 go/s3/s3_test.go diff --git a/go/s3/s3.go b/go/s3/s3.go index cea48a6e..373f4161 100644 --- a/go/s3/s3.go +++ b/go/s3/s3.go @@ -17,6 +17,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "xtx/ternfs/client" "xtx/ternfs/core/bufpool" @@ -213,6 +214,53 @@ func FileEtag(id msgs.InodeId) string { return fmt.Sprintf(`"%x"`, uint64(id)) } +// Modes echoed back on GET/HEAD/?attributes. TernFS has no per-inode +// permission storage, so we emit fixed sensible defaults whose only +// load-bearing bits are the file-type bits. +const ( + s3DefaultDirMode = syscall.S_IFDIR | 0o755 + s3DefaultFileMode = syscall.S_IFREG | 0o644 + s3SymlinkMode = syscall.S_IFLNK | 0o777 +) + +// parseAmzMetaMode reads the s3fs-fuse "x-amz-meta-mode" header and returns +// the TernFS inode type implied by it: msgs.SYMLINK when the type bits are +// S_IFLNK, msgs.FILE otherwise (including when the header is absent or +// unparseable — we don't error on a malformed header). The header value is +// parsed with base 0 (decimal, octal "0...", or hex "0x...") to match +// s3fs-fuse's strtoll-with-base-0 reader. +func parseAmzMetaMode(r *http.Request) msgs.InodeType { + v := r.Header.Get("X-Amz-Meta-Mode") + if v == "" { + return msgs.FILE + } + mode, err := strconv.ParseUint(strings.TrimSpace(v), 0, 32) + if err != nil { + return msgs.FILE + } + if mode&syscall.S_IFMT == syscall.S_IFLNK { + return msgs.SYMLINK + } + return msgs.FILE +} + +// emitAmzMetaMode writes a fixed x-amz-meta-mode header for the given inode +// type so s3fs-fuse can recognize the object's POSIX type on read. Without +// this header s3fs falls back to mode 0640/0750 and would lose the symlink +// type bit on round-trip. +func emitAmzMetaMode(w http.ResponseWriter, typ msgs.InodeType) { + var mode uint32 + switch typ { + case msgs.SYMLINK: + mode = s3SymlinkMode + case msgs.DIRECTORY: + mode = s3DefaultDirMode + default: + mode = s3DefaultFileMode + } + w.Header().Set("X-Amz-Meta-Mode", strconv.FormatUint(uint64(mode), 10)) +} + // parseRange parses the HTTP Range header. func parseRange(rangeHeader string, totalSize int64) (start, length int64, err error) { if !strings.HasPrefix(rangeHeader, "bytes=") { @@ -495,6 +543,7 @@ func (s *S3Server) handleGetObject(ctx context.Context, w http.ResponseWriter, r w.Header().Set("Last-Modified", lastModified.Time().Format(http.TimeFormat)) w.Header().Set("Accept-Ranges", "bytes") w.Header().Set("Content-Type", "application/octet-stream") + emitAmzMetaMode(w, dentry.TargetId.Type()) if rangeHeader == "" { w.Header().Set("Content-Length", strconv.FormatInt(int64(size), 10)) @@ -561,6 +610,7 @@ func (s *S3Server) handleGetObjectAttributes(ctx context.Context, w http.Respons w.Header().Set("x-amz-object-attributes-last-modified", lastModified.Time().UTC().Format(http.TimeFormat)) w.Header().Set("x-amz-object-attributes-object-size", strconv.FormatInt(int64(size), 10)) w.Header().Set("x-amz-storage-class", "STANDARD") + emitAmzMetaMode(w, inode.Type()) w.WriteHeader(http.StatusOK) return nil } @@ -723,7 +773,7 @@ func (s *S3Server) handlePutObject(ctx context.Context, w http.ResponseWriter, r } } else { fileResp := msgs.ConstructFileResp{} - if err := s.c.ShardRequest(s.log, ownerId.Shard(), &msgs.ConstructFileReq{Type: msgs.FILE}, &fileResp); err != nil { + if err := s.c.ShardRequest(s.log, ownerId.Shard(), &msgs.ConstructFileReq{Type: parseAmzMetaMode(r)}, &fileResp); err != nil { return fmt.Errorf("failed to construct file: %w", err) } fileId := fileResp.Id diff --git a/go/s3/s3_test.go b/go/s3/s3_test.go new file mode 100644 index 00000000..55227051 --- /dev/null +++ b/go/s3/s3_test.go @@ -0,0 +1,117 @@ +// Copyright 2025 XTX Markets Technologies Limited +// +// SPDX-License-Identifier: GPL-2.0-or-later + +package s3 + +import ( + "net/http" + "net/http/httptest" + "testing" + "xtx/ternfs/core/assert" + "xtx/ternfs/msgs" +) + +func TestParseAmzMetaMode(t *testing.T) { + cases := []struct { + name string + header string // empty means header not set at all + want msgs.InodeType + }{ + // s3fs-fuse emits decimal of full st_mode. + {"s3fs symlink (decimal 41471)", "41471", msgs.SYMLINK}, + {"s3fs regular file 0644 (decimal 33188)", "33188", msgs.FILE}, + {"s3fs directory 0755 (decimal 16877)", "16877", msgs.FILE}, + + // strconv.ParseUint with base=0 must accept octal and hex too, + // matching s3fs's strtoll(base=0) reader path. + {"octal symlink (0120777)", "0120777", msgs.SYMLINK}, + {"octal regular file (0100644)", "0100644", msgs.FILE}, + {"hex symlink (0xa1ff)", "0xa1ff", msgs.SYMLINK}, + {"hex regular file (0x81a4)", "0x81a4", msgs.FILE}, + + // Setuid/setgid/sticky bits MUST NOT be misread as a type bit. + // 36388 = S_IFREG | S_ISUID | 0644. + {"setuid regular file (36388)", "36388", msgs.FILE}, + // 17407 = S_IFDIR | S_ISVTX | 0777 — sticky-bit dir. + {"sticky directory (17407)", "17407", msgs.FILE}, + + // Type bits other than S_IFLNK map to a regular file. + {"FIFO 0010644", "0010644", msgs.FILE}, + {"socket 0140644", "0140644", msgs.FILE}, + {"char dev 0020644", "0020644", msgs.FILE}, + {"block dev 0060644", "0060644", msgs.FILE}, + + // Tolerant fallback: missing or garbage header → regular file, no error. + {"absent header", "", msgs.FILE}, + {"empty value", "", msgs.FILE}, + {"garbage", "not-a-number", msgs.FILE}, + {"trailing garbage", "41471xyz", msgs.FILE}, + + // Whitespace tolerance — clients sometimes pad headers. + {"leading and trailing space", " 41471 ", msgs.SYMLINK}, + + // Edge: zero is parseable but its type bits aren't S_IFLNK. + {"zero", "0", msgs.FILE}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPut, "/bucket/key", nil) + if tc.header != "" { + r.Header.Set("X-Amz-Meta-Mode", tc.header) + } + assert.Equal(t, tc.want, parseAmzMetaMode(r)) + }) + } +} + +func TestParseAmzMetaModeIsCaseInsensitive(t *testing.T) { + // AWS canonicalizes user-metadata header names to lowercase; s3fs always + // emits lowercase. Ensure we accept any casing of "x-amz-meta-mode". + for _, headerName := range []string{ + "x-amz-meta-mode", + "X-Amz-Meta-Mode", + "X-AMZ-META-MODE", + "X-aMz-MeTa-MoDe", + } { + t.Run(headerName, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPut, "/bucket/key", nil) + r.Header.Set(headerName, "41471") + assert.Equal(t, msgs.SYMLINK, parseAmzMetaMode(r)) + }) + } +} + +func TestEmitAmzMetaMode(t *testing.T) { + cases := []struct { + name string + typ msgs.InodeType + want string + }{ + {"symlink", msgs.SYMLINK, "41471"}, // S_IFLNK | 0777 + {"directory", msgs.DIRECTORY, "16877"}, // S_IFDIR | 0755 + {"regular file", msgs.FILE, "33188"}, // S_IFREG | 0644 + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := httptest.NewRecorder() + emitAmzMetaMode(rec, tc.typ) + assert.Equal(t, tc.want, rec.Header().Get("X-Amz-Meta-Mode")) + }) + } +} + +// Round-trip: anything we emit for a SYMLINK must be parsed back as a SYMLINK. +// This guards against future edits where someone adjusts only one side +// (e.g. switches the constants to a different value or base) and silently +// breaks s3fs-fuse interop. +func TestSymlinkModeRoundTrip(t *testing.T) { + rec := httptest.NewRecorder() + emitAmzMetaMode(rec, msgs.SYMLINK) + emitted := rec.Header().Get("X-Amz-Meta-Mode") + + r := httptest.NewRequest(http.MethodPut, "/bucket/key", nil) + r.Header.Set("X-Amz-Meta-Mode", emitted) + assert.Equal(t, msgs.SYMLINK, parseAmzMetaMode(r)) +}