Skip to content

Add extended attribute (xattr) support to TLC container format #5

@leafo

Description

@leafo

When butler pushes a macOS .app bundle, the wharf pipeline decomposes it into files tracked by the TLC container format. The TLC File message only stores path, mode, size, and offset. Extended attributes (xattrs) are silently dropped.

This breaks macOS code signing and notarization. A signed/notarized .app that works perfectly when zipped manually becomes "damaged" after a butler push/download cycle, because the code signing metadata stored in xattrs is lost. Users see:

".app is damaged and can't be opened"

This is reported in butler#234 and butler#274. The current workaround is to zip the .app manually and upload through the itch.io web UI, which bypasses the wharf pipeline entirely.

Proposed Change

Add an xattrs field to the TLC protobuf messages so extended attributes are preserved through the walk, diff, patch, and apply lifecycle.

1. Proto Schema (tlc/tlc.proto)

Add a map<string, bytes> field to File, Dir, and Symlink:

message File {
  string path = 1;
  uint32 mode = 2;
  int64 size = 3;
  int64 offset = 4;
  map<string, bytes> xattrs = 5;
}

message Dir {
  string path = 1;
  uint32 mode = 2;
  map<string, bytes> xattrs = 3;
}

message Symlink {
  string path = 1;
  uint32 mode = 2;
  string dest = 3;
  map<string, bytes> xattrs = 4;
}

This is backwards-compatible in proto3: old readers ignore unknown fields, and old patch/signature files simply won't have xattrs populated.

2. Walking (tlc/walk.go)

In WalkDir, after collecting mode and size, read xattrs from the filesystem:

xattrs, err := xattr.LList(fullPath)
if err == nil && len(xattrs) > 0 {
    file.Xattrs = make(map[string][]byte)
    for _, name := range xattrs {
        val, err := xattr.LGet(fullPath, name)
        if err == nil {
            file.Xattrs[name] = val
        }
    }
}

Same for Dir and Symlink entries. WalkZip would leave the field empty since zip archives don't carry xattrs.

A library like github.com/pkg/xattr handles cross-platform concerns. On platforms without xattr support, list/get are no-ops.

3. Applying xattrs: RestoreXattrs method on Container

A new Container.RestoreXattrs(basePath) method (alongside the existing Prepare) would walk all files, dirs, and symlinks and call xattr.LSet for each stored attribute:

func (c *Container) RestoreXattrs(basePath string) error {
    for _, f := range c.Files {
        for name, val := range f.Xattrs {
            xattr.LSet(filepath.Join(basePath, f.Path), name, val)
        }
    }
    // same for c.Dirs and c.Symlinks
}

This must NOT be called during Prepare(). Prepare creates empty/pre-allocated files before patch content is written. Setting xattrs at that point would be pointless (contents haven't been written yet) and could cause issues if the OS validates xattrs against file contents.

Instead, RestoreXattrs must be called after patch application is complete, specifically after the bowl commits. The call sites are in butler (not lake or wharf):

  • butler/cmd/apply/apply.go: after bwl.Commit() (line 193). The source container is available via p.GetSourceContainer() (line 198):

    err = bwl.Commit()
    // ...
    sourceContainer := p.GetSourceContainer()
    err = sourceContainer.RestoreXattrs(outputPath)
  • butler/cmd/operate/upgrade.go: after bowl.Commit() (line 259). The source container is available via p.GetSourceContainer() (line 264):

    err = bowl.Commit()
    // ...
    sourceContainer := p.GetSourceContainer()
    err = sourceContainer.RestoreXattrs(params.InstallFolder)

4. Wharf (diff, patch, signature): no changes needed

The wharf library serializes the TLC Container via proto.Marshal/proto.Unmarshal through wire.WriteMessage/wire.ReadMessage. The new xattr field flows through automatically in:

  • pwr/diff.go: writes both TargetContainer and SourceContainer into the patch file
  • pwr/patcher/patcher.go: reads both containers back from the patch file
  • pwr/sign.go: writes/reads the container in signature files

No code changes required in wharf.

5. Butler push: no changes needed

Butler's push logic passes the container through as-is. The xattr data will be captured during walk (step 2) and serialized into the patch automatically (step 4).

Open Questions

  • Filter xattrs? Should we store all xattrs, or filter to a known set (e.g. com.apple.*)? Storing all is simpler and more future-proof, but increases patch size for files with many xattrs.
  • Interaction with FixPermissions: FixPermissions modifies mode bits by detecting executables via magic bytes. If the original .app was signed with specific permission bits, changing them also invalidates the signature. Should FixPermissions be skipped for files inside .app bundles, or is that a separate concern?
  • Dir and Symlink xattrs: Are xattrs on directories and symlinks relevant for code signing? If so, all three message types need the field. If not, File alone may suffice.

References

  • butler#234: Original report, signed/notarized .app is "damaged" after butler push
  • butler#274: Related report about permission issues
  • itch.io macOS docs: Current guidance for .app distribution

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions