omeryanbas.com

Ömer Yanbaş

General Manager, Ticofab Yazılım

OperationsPractice

Invisible dot underscore files travelling from macOS to your server

Extended attributes become AppleDouble files inside archives made on a Mac, land in your web root and get served. How to spot, stop and clean them up.

A deploy from a laptop finishes without complaint. On the server, the web root has 824 files in it, and the build directory it came from has 412. Every index.html has a sibling called ._index.html, four kilobytes each, and a request for one of them returns 200 rather than 404. Nothing is broken enough to page anyone, which is why they sit there for months until a crawler or a security scan finds them.

What actually happens

macOS keeps extended attributes next to a file: Finder information, colour tags, the quarantine flag that lands on anything downloaded through a browser, and in older material a resource fork. The filesystem stores them natively. Most other filesystems do not, and neither do most archive formats.

When the Mac writes a file into a format that cannot carry attributes inline, it does not drop them. It splits the file in two using the copy mechanism built into the system: the data goes in under the original name, and the attributes go in under the same name with ._ in front. That second file is in AppleDouble format, a container from the era of resource forks, and it is a perfectly ordinary file as far as anything else is concerned.

The asymmetry is what hides the problem:

  • Extracting the archive on a Mac recombines the pair silently and shows one file. A round trip on your own machine looks clean.
  • Extracting on Linux does not recombine anything. Both files land on disk, and the ._ one stays.
  • Finder hides files beginning with a dot, so even looking at the build directory with your own eyes does not show them.

Once they are on the server, the web server treats them by extension like anything else. A request for ._index.html can be answered with a content type of text/html and a body of binary metadata. There is rarely anything secret inside, but it is a published file you never reviewed, and a few tools will do worse things with it than serve it. A static site generator that walks a content directory will cheerfully render ._post.md as a post, and a file count in a monitoring check stops meaning anything.

.DS_Store travels the same route with a worse payload, because it describes the directory it sits in: names of files, including ones that never got deployed, and how the folder was arranged. zip has its own version of the same behaviour, writing a __MACOSX/ directory full of the same ._ entries, and the Finder's own Compress command always produces one.

How to see it

Look inside the archive before it leaves the machine:

tar -tzf site.tgz | grep -c '/\._'
# 412
tar -tzf site.tgz | grep '/\._' | head -3
# ./assets/._logo.svg
# ./assets/._app.css
# ./._index.html

Then find out where they came from. The @ in a long listing marks a file that carries attributes, and xattr prints them:

ls -l@ assets/logo.svg
# -rw-r--r--@ 1 user staff 18244 12 Nov 09:41 assets/logo.svg
#     com.apple.quarantine    57

xattr -l assets/logo.svg
# com.apple.quarantine: 0083;690d4f1a;Safari;

That quarantine attribute is the usual culprit. An icon downloaded through a browser and dragged into the project carries it for the rest of its life, and every archive built from that directory grows a twin for it.

On the server, count and compare rather than browse:

find /var/www/site -name '._*' | wc -l
# 412
find /var/www/site -name '.DS_Store' -o -name '__MACOSX' | head
# /var/www/site/assets/.DS_Store

curl -sI https://example.com/._index.html | head -2
# HTTP/2 200
# content-type: text/html

A 200 on that last command is the part to take seriously, because it means the file is not merely present, it is public.

The fix

Strip the attributes from the build output, exclude the metadata files, and tell the archiver not to split anything:

xattr -cr build
find build \( -name '.DS_Store' -o -name '._*' \) -delete
COPYFILE_DISABLE=1 tar --no-xattrs -czf site.tgz -C build .

xattr -cr clears attributes recursively, COPYFILE_DISABLE=1 is the environment variable the system archiver reads to turn the splitting off, and --no-xattrs says the same thing as a flag. Use both, because which one applies depends on which tar is first in the path. Run the strip step against the build output only, never against the repository, so you are not clearing quarantine flags on files that should keep them.

If the deploy uses zip, the equivalent is to ask for a copy with no attributes and no resource forks:

ditto -c -k --norsrc --noextattr --sequesterRsrc build site.zip

The better fix is to stop shipping an archive at all. A mirroring sync never invokes the copy mechanism, because it does not transfer extended attributes unless you ask for them, and its deletion pass is the only thing that will clear the strays already sitting on the server. An archive deploy overwrites files and leaves everything else exactly where it is, which is why these pile up release after release:

rsync -rlptD --delete \
  --exclude '.DS_Store' --exclude '._*' --exclude '__MACOSX/' \
  build/ deploy@host:/var/www/site/

That deletion pass is powerful enough to remove things you wanted to keep, so pair it with a protect list, for the same reason a mirroring deploy can break certificate renewal.

Then close the door at the web server, so a file copied up by hand during an incident is never served:

location ~ /\._|/\.DS_Store$|/__MACOSX/ {
    access_log off;
    return 404;
}

This sits alongside the general rule about paths beginning with a dot, which should already be unreachable from the internet. The real structural fix, for a team rather than one laptop, is to build the artefact on a Linux runner, because a machine with no extended attributes cannot produce an AppleDouble file in the first place.

How to check it worked

Count files on both sides and make the deploy fail when they disagree:

LOCAL=$(find build -type f | wc -l | tr -d ' ')
REMOTE=$(ssh deploy@host "find /var/www/site -type f | wc -l" | tr -d ' ')
[ "$LOCAL" = "$REMOTE" ] || { echo "file count mismatch: $LOCAL local, $REMOTE remote" >&2; exit 1; }
echo "$LOCAL files deployed"
# 412 files deployed

Then confirm the specific pattern is gone and stays gone:

ssh deploy@host 'find /var/www/site \( -name "._*" -o -name ".DS_Store" \) | wc -l'
# 0
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/._index.html
# 404

For anything where the exact bytes matter, a count is a weak check and a manifest is a strong one: generate checksums during the build, ship the manifest, and verify it on the server. A count tells you a file is missing or extra, a manifest tells you a file is wrong.

What to watch out for

  • The Finder's Compress command always produces the metadata directory. A release archive should never be made by right clicking a folder, only by a command you can read.
  • xattr -cr on a source tree removes quarantine flags from files that may deserve them and touches the change time of everything it visits. Run it on the build output, not the repository.
  • The strays only disappear when something deletes them. An archive deploy, a copy, or a sync without the deletion pass will leave every one of them in place forever.
  • Staging a file through a shared temp path on the way up adds a second problem on top of this one, which is why a generic name in /tmp is a trap.
  • A file count check breaks the day the deploy legitimately removes files and the comparison is made against an old build. Compare the build directory you just produced, never a previous one.

The habit worth keeping from this is smaller than the bug. After a deploy, count what arrived and compare it to what you sent, because an archive is an opaque object and the only honest report on it comes from the other end. Every tool in the chain is allowed to add something you did not write: attributes, metadata files, directory descriptions, compression artefacts. A build that produces 412 files and a server that holds 412 files is a statement you can check in one second, and it catches a whole family of surprises that no test suite is looking for.

Questions and answers

What are the ._ files on my server?
They are AppleDouble files: the extended attributes of a file, split out into a separate file because the archive format could not carry them inline. A Mac creates them when it writes a tar or a zip, and any system that is not a Mac sees them as ordinary files with strange names. They contain metadata such as Finder information and quarantine flags rather than your content, but they are still files you published without meaning to.
Why do they appear on the server but not on my machine?
Because macOS recombines them on extraction and hides them in Finder, so a round trip on the same machine looks clean. On Linux the pair is never merged, so both files stay on disk. That asymmetry is why the problem is always discovered on the server and never during testing.
Is a .DS_Store file worse than a ._ file?
Yes, in terms of what it tells a stranger. It lists the contents of the directory it sits in, including names of files you did not deploy, along with view settings. Both should be blocked at the web server and excluded from the deploy, but a directory listing leaking is the one worth fixing first.
Does building on a Linux runner fix this permanently?
It removes the source of the problem for anything the runner produces, because there are no extended attributes to split out. It does not clean up what is already on the server, and it does not help with a file someone copies up by hand from a laptop during an incident. Keep the block rule in the web server and the count check in the deploy anyway.