<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Mustafa ERBAY</title>
    <description>The latest articles on DEV Community by Mustafa ERBAY (@merbayerp).</description>
    <link>https://dev.to/merbayerp</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3921203%2Fe3a198a1-49a0-466f-99e6-74bdf202a867.png</url>
      <title>DEV Community: Mustafa ERBAY</title>
      <link>https://dev.to/merbayerp</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/merbayerp"/>
    <language>en</language>
    <item>
      <title>The 204,800 Everyone Copies Costs 295 MiB of Kernel Memory</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 25 Oct 2026 14:30:41 +0000</pubDate>
      <link>https://dev.to/merbayerp/the-204800-everyone-copies-costs-295-mib-of-kernel-memory-2ao7</link>
      <guid>https://dev.to/merbayerp/the-204800-everyone-copies-costs-295-mib-of-kernel-memory-2ao7</guid>
      <description>&lt;p&gt;When a file watcher dies with "No space left on device", the internet's answer is one line: set &lt;code&gt;fs.inotify.max_user_watches&lt;/code&gt; to 204800. Syncthing's own documentation recommends that number. You paste the line, the watcher starts, the case closes.&lt;/p&gt;

&lt;p&gt;What nobody mentions is that the number is a memory budget. The cost per watch, on the bench: around &lt;strong&gt;1.5 KiB&lt;/strong&gt; on ext4, &lt;strong&gt;2.45 KiB&lt;/strong&gt; on a container's own layer. Actually installing 204,800 watches therefore means &lt;strong&gt;295 MiB&lt;/strong&gt; of kernel memory — and &lt;code&gt;drop_caches&lt;/code&gt; will not give it back. Raising the limit is free; what is free is the permission, not the bill.&lt;/p&gt;

&lt;p&gt;Error messages that lie are well-trodden ground on this blog: &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/df-sifir-dedi-root-yazmaya-devam-etti/" rel="noopener noreferrer"&gt;&lt;code&gt;df&lt;/code&gt; reported zero while root kept writing&lt;/a&gt;, and &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/fork-retry-dort-tavan-tek-hata-mesaji/" rel="noopener noreferrer"&gt;a single &lt;code&gt;fork&lt;/code&gt; error message hid four separate ceilings&lt;/a&gt;. Both inotify limits belong to that family, so I will pass through them quickly and get to the real question: if you use what you were permitted, what do you pay?&lt;/p&gt;

&lt;h2&gt;
  
  
  The rig
&lt;/h2&gt;

&lt;p&gt;Measurements come from Docker Desktop's Linux virtual machine on my own Mac: kernel &lt;code&gt;6.10.14-linuxkit&lt;/code&gt;, 14 vCPUs, &lt;code&gt;MemTotal: 5036592 kB&lt;/code&gt; (4.8 GiB). Because the experiments write to host sysctls and call &lt;code&gt;drop_caches&lt;/code&gt;, no customer server and not this blog's own server took part.&lt;/p&gt;

&lt;p&gt;The containers ran like this — budget tests in an ordinary container, memory tests &lt;code&gt;--privileged&lt;/code&gt;, since they write to &lt;code&gt;drop_caches&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# budget test: ordinary container&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/lab:ro python:3.12-slim python3 /lab/watchbomb.py watches 20000 /tmp/w
&lt;span class="c"&gt;# memory test: privileged, on a real ext4 volume&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--privileged&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; inolab:/ext4lab &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LAB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;:/lab:ro python:3.12-slim &lt;span class="se"&gt;\&lt;/span&gt;
  python3 &lt;span class="nt"&gt;-u&lt;/span&gt; /lab/bellek_ext4.py 150000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no library on the watcher side; &lt;code&gt;ctypes&lt;/code&gt; calls libc directly, because I want to see which &lt;code&gt;errno&lt;/code&gt; stopped me without asking any wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;libc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CDLL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_library&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;c&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;use_errno&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inotify_init1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;wd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;libc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inotify_add_watch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;IN_ATTRIB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;wd&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctypes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_errno&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# raw errno, no interpretation
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every watch went on a separate inode. Add a second watch to the same inode and the kernel allocates nothing new, it updates the existing record (&lt;code&gt;inotify_update_watch()&lt;/code&gt; tries &lt;code&gt;inotify_update_existing_watch()&lt;/code&gt; first and falls through to &lt;code&gt;inotify_new_watch()&lt;/code&gt; on &lt;code&gt;-ENOENT&lt;/code&gt;). What drains the budget is not the number of files but the number of &lt;strong&gt;distinct inodes&lt;/strong&gt; — for a tool that watches directories, that means the directory count.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The lab scripts print Turkish keys; each output block below is glossed in the line that follows it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two limits, two borrowed errnos
&lt;/h2&gt;

&lt;p&gt;For the budget tests I lowered the limit to the classic field value, 8192. As root in a container, watching 20,000 files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eklenen_watch=8172
durma_errno=28 (No space left on device)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(&lt;code&gt;eklenen_watch&lt;/code&gt; = watches added; &lt;code&gt;durma_errno&lt;/code&gt; = the errno that stopped it.)&lt;/em&gt; At that moment the container's disk — the VM's thin-provisioned virtual disk, so not the Mac's real free space, but what &lt;code&gt;df&lt;/code&gt; sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;overlay        1007G   21G  936G   3% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second limit is less well known: how many &lt;code&gt;inotify&lt;/code&gt; &lt;em&gt;instances&lt;/em&gt; (descriptors) you may open. This ran under a separate uid (1002) so the bucket would be clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ulimit_n=1048576
acilan_instance=8192
durma_errno=24 (Too many open files)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(&lt;code&gt;acilan_instance&lt;/code&gt; = instances opened.)&lt;/em&gt; &lt;code&gt;EMFILE&lt;/code&gt;, "too many open files" — yet the process holds 8,192 descriptors and the descriptor limit is 1,048,576. The reflex here is to raise &lt;code&gt;ulimit -n&lt;/code&gt;; nothing changes, because the wall is &lt;code&gt;fs.inotify.max_user_instances&lt;/code&gt;. (That 1,048,576 happening to equal the ceiling of the watch limit is a coincidence; they are separate resources.) The source is explicit about both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;inc_inotify_watches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inotify_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ucounts&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;ENOSPC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inotify_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ucounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inc_ucount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_user_ns&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                                         &lt;span class="n"&gt;current_euid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                                         &lt;span class="n"&gt;UCOUNT_INOTIFY_INSTANCES&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inotify_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ucounts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ERR_PTR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;EMFILE&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kernel's own default for the instance limit is 128; my VM reports 8192, so somebody raised it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bucket is not on the "real user", it is on the euid
&lt;/h2&gt;

&lt;p&gt;The man page uses the same phrase for both limits: &lt;em&gt;per real user ID&lt;/em&gt;. The source says something else — the second argument in the call above is &lt;code&gt;current_euid()&lt;/code&gt;, the &lt;strong&gt;effective&lt;/strong&gt; user ID. The watch side uses the same bucket, because &lt;code&gt;inc_inotify_watches()&lt;/code&gt; counts against the &lt;code&gt;ucounts&lt;/code&gt; record frozen at &lt;code&gt;inotify_init&lt;/code&gt; time.&lt;/p&gt;

&lt;p&gt;That gap between documentation and code does not stay on paper. The classic privilege-dropping service pattern — start as root, call &lt;code&gt;inotify_init()&lt;/code&gt;, then &lt;code&gt;setuid()&lt;/code&gt; down — charges its watches to the &lt;strong&gt;old euid's&lt;/strong&gt; bucket, and the user you dropped to is never blamed. For the same reason, the advice to "run the watcher under its own uid" only works if the uid change happens &lt;strong&gt;before&lt;/strong&gt; the &lt;code&gt;inotify&lt;/code&gt; descriptor is opened.&lt;/p&gt;

&lt;p&gt;In my own runs the real and effective uids were identical, so the bench does not separate the two; this claim rests on the source, not on a measurement.&lt;/p&gt;

&lt;p&gt;What the per-user bucket means in practice, though, did get measured. While the first watcher held its 8,172 watches, a completely separate container started next to it — separate PID, mount and network namespaces, nothing running inside:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eklenen_watch=0
durma_errno=28 (No space left on device)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero. A spotless container, unable to watch a single file; the only thing they share is uid 0. The pattern itself is not new here — when &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/komsu-konteyner-borularimi-sekiz-kat-kuculttu/" rel="noopener noreferrer"&gt;a neighbouring container shrank my pipes eightfold&lt;/a&gt; the same thing happened to pipe buffers, and there no error came back at all.&lt;/p&gt;

&lt;p&gt;A user namespace does not rescue you either: after filling uid 1000's bucket, becoming root in a fresh namespace via &lt;code&gt;unshare -U -r&lt;/code&gt; still returned zero. &lt;code&gt;inc_ucount()&lt;/code&gt; walks up the chain and hits the parent's ceiling; the child namespace's own ceiling is &lt;code&gt;INT_MAX&lt;/code&gt;, so the refusal comes from the parent, not the child. One caveat: &lt;code&gt;unshare -r&lt;/code&gt; maps the inner 0 to the &lt;strong&gt;same&lt;/strong&gt; outer uid, which is why no fresh bucket appears. A rootless setup that maps into a genuinely different range via &lt;code&gt;/etc/subuid&lt;/code&gt; (say &lt;code&gt;podman --userns=auto&lt;/code&gt;) lands on a different kuid and does get a fresh bucket.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiaW5vdGlmeV9hZGRfd2F0Y2goZmQsIHBhdGgpIl0gLS0-IEJ7IndhdGNoIGFscmVhZHk8YnIvPm9uIHRoaXMgaW5vZGU_In0KICBCIC0tPnx5ZXN8IENbInVwZGF0ZSBleGlzdGluZyByZWNvcmQ8YnIvPm5vIGJ1ZGdldCBjaGFyZ2UiXQogIEIgLS0-fG5vfCBEWyJpbm90aWZ5X25ld193YXRjaCgpIl0KICBFWyJ0aGUgZmQncyBidWNrZXQ6PGJyLz5ucyArIGV1aWQgYXQgaW5vdGlmeV9pbml0Il0KICBEIC0tPiBFCiAgRSAtLT4gRnsiY2VpbGluZyBvZiB0aGlzPGJyLz5sZXZlbCBleGNlZWRlZD8ifQogIEYgLS0-fHllc3wgSFsicm9sbCBiYWNrIGxvd2VyIGxldmVsczxici8-RU5PU1BDIl0KICBGIC0tPnxub3wgR3siYW55IHBhcmVudDxici8-bmFtZXNwYWNlIGxlZnQ_In0KICBHIC0tPnx5ZXN8IEYKICBHIC0tPnxub3wgSVsid2F0Y2ggaW5zdGFsbGVkIl0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiaW5vdGlmeV9hZGRfd2F0Y2goZmQsIHBhdGgpIl0gLS0-IEJ7IndhdGNoIGFscmVhZHk8YnIvPm9uIHRoaXMgaW5vZGU_In0KICBCIC0tPnx5ZXN8IENbInVwZGF0ZSBleGlzdGluZyByZWNvcmQ8YnIvPm5vIGJ1ZGdldCBjaGFyZ2UiXQogIEIgLS0-fG5vfCBEWyJpbm90aWZ5X25ld193YXRjaCgpIl0KICBFWyJ0aGUgZmQncyBidWNrZXQ6PGJyLz5ucyArIGV1aWQgYXQgaW5vdGlmeV9pbml0Il0KICBEIC0tPiBFCiAgRSAtLT4gRnsiY2VpbGluZyBvZiB0aGlzPGJyLz5sZXZlbCBleGNlZWRlZD8ifQogIEYgLS0-fHllc3wgSFsicm9sbCBiYWNrIGxvd2VyIGxldmVsczxici8-RU5PU1BDIl0KICBGIC0tPnxub3wgR3siYW55IHBhcmVudDxici8-bmFtZXNwYWNlIGxlZnQ_In0KICBHIC0tPnx5ZXN8IEYKICBHIC0tPnxub3wgSVsid2F0Y2ggaW5zdGFsbGVkIl0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="494" height="1219"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Who spent the budget before me
&lt;/h2&gt;

&lt;p&gt;That 8,172 is the interesting part, because the limit was 8192. Counting the missing 20 across the whole VM, grouped by uid:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;uid=0 pid=84337 comm=python3          izleme=8172
uid=0 pid=362   comm=dockerd          izleme=1
uid=0 pid=361   comm=rpc.idmapd       izleme=1
uid=0 pid=24277 comm=containerd-shim  izleme=2
uid=0 pid=30789 comm=containerd-shim  izleme=2
uid=0 pid=797   comm=containerd-shim  izleme=2
uid=0 pid=800   comm=containerd-shim  izleme=2
uid=0 pid=803   comm=containerd-shim  izleme=2
uid=0 pid=804   comm=containerd-shim  izleme=2
uid=0 pid=83354 comm=containerd-shim  izleme=2
uid=0 pid=84318 comm=containerd-shim  izleme=2
uid=0 pid=96409 comm=containerd-shim  izleme=2
TOPLAM_IZLEME=8192
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(&lt;code&gt;izleme&lt;/code&gt; = watches held by that descriptor; &lt;code&gt;TOPLAM_IZLEME&lt;/code&gt; = total watches.)&lt;/em&gt; Nine &lt;code&gt;containerd-shim&lt;/code&gt; processes at two each, &lt;code&gt;dockerd&lt;/code&gt; and &lt;code&gt;rpc.idmapd&lt;/code&gt; at one each: 20. Add my 8,172 and the total is exactly 8192. The container infrastructure eats from the same bucket. At a limit of 8192, twenty watches are noise; a capacity plan that budgets 650 watches for a 650-directory tree fails by a hair.&lt;/p&gt;

&lt;p&gt;Here is the counting script, with two caveats:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;tot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; /proc/[0-9]&lt;span class="k"&gt;*&lt;/span&gt;/fdinfo/&lt;span class="k"&gt;*&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^inotify /{c++} END{print c+0}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0            &lt;span class="c"&gt;# the process may vanish mid-loop&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f3&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;uid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/^Uid:/{print $2}'&lt;/span&gt; &lt;span class="s2"&gt;"/proc/&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;/status"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"uid=&lt;/span&gt;&lt;span class="nv"&gt;$uid&lt;/span&gt;&lt;span class="s2"&gt; pid=&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt; comm=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/&lt;span class="nv"&gt;$pid&lt;/span&gt;/comm 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; watches=&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nv"&gt;tot&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;tot+n&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"TOTAL_WATCHES=&lt;/span&gt;&lt;span class="nv"&gt;$tot&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First: because the budget is per user, the lines have to be summed &lt;strong&gt;per uid&lt;/strong&gt;; a raw total mixes every user on the machine. Second: after a &lt;code&gt;fork&lt;/code&gt; the same &lt;code&gt;fsnotify_group&lt;/code&gt; shows up under two descriptors — the kernel counts it once, this loop counts it twice. On a machine running containers the loop belongs on the host; from inside a container you cannot see the neighbours you share the bucket with.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one watch costs
&lt;/h2&gt;

&lt;p&gt;This was the real question. First the watch record itself: create the files, &lt;code&gt;stat&lt;/code&gt; them all (so inodes and dentries are already cached), read &lt;code&gt;Slab&lt;/code&gt;, then add the watches and read again. For 150,000 watches the increase was 15.8 MiB, or &lt;strong&gt;110.3 bytes per watch&lt;/strong&gt; — and almost all of it in &lt;code&gt;SUnreclaim&lt;/code&gt;, the class that cannot be reclaimed under memory pressure. Noise stayed at 20 kB. The kernel's own object size for that record agrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ cat /sys/kernel/slab/inotify_inode_mark/object_size
80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An 80-byte object against 110 bytes on the bench; the gap is the connector structure allocated per watched inode, plus slab overhead.&lt;/p&gt;

&lt;p&gt;In real life, though, the inodes of the files you watch are not sitting ready. The second rig creates the files, flushes the cache with &lt;code&gt;drop_caches&lt;/code&gt;, takes a cold baseline, and only then adds the watches. On a genuine ext4 volume, 150,000 watches, &lt;strong&gt;three runs&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Slab increase&lt;/th&gt;
&lt;th&gt;Per watch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;244.5 MiB&lt;/td&gt;
&lt;td&gt;1,709.5 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;215.1 MiB&lt;/td&gt;
&lt;td&gt;1,503.8 bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;216.1 MiB&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;1,510.8 bytes&lt;/strong&gt; (median)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Runs two and three land within 0.5% of each other; the first is the outlier. The number to work with is &lt;strong&gt;~1.5 KiB per watch&lt;/strong&gt;. On the container's own writable layer (overlay) the same bench gave 2,450.6 and 2,458.4 bytes across two runs — a watch there is roughly 1.6× more expensive.&lt;/p&gt;

&lt;p&gt;Where does the difference come from? A watch holds on to the inode it watches. The kernel's own comment says so: &lt;em&gt;"An inotify watch requires allocating an inotify_inode_mark structure as well as pinning the watched inode."&lt;/em&gt; The object sizes on this machine complete the arithmetic: &lt;code&gt;ext4_inode_cache&lt;/code&gt; 1,168 bytes, &lt;code&gt;dentry&lt;/code&gt; 192, &lt;code&gt;inotify_inode_mark&lt;/code&gt; 80.&lt;/p&gt;

&lt;p&gt;So is that memory truly held, or would pressure reclaim it, as its place in &lt;code&gt;KReclaimable&lt;/code&gt; suggests? Calling &lt;code&gt;drop_caches&lt;/code&gt; with the watches still in hand, across three runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Increase&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;drop_caches&lt;/code&gt; returned&lt;/th&gt;
&lt;th&gt;On closing the fd&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;250,412 kB&lt;/td&gt;
&lt;td&gt;48,076 kB (19.2%)&lt;/td&gt;
&lt;td&gt;all returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;220,288 kB&lt;/td&gt;
&lt;td&gt;0 (Slab &lt;strong&gt;grew&lt;/strong&gt; by 3,972 kB)&lt;/td&gt;
&lt;td&gt;all returned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;221,304 kB&lt;/td&gt;
&lt;td&gt;28,564 kB (12.9%)&lt;/td&gt;
&lt;td&gt;all returned&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;At best the kernel handed back a fifth; in one run it handed back nothing. The remainder stayed for as long as the watcher lived, and in all three runs the whole lot was freed the instant the descriptor closed. The optimism in the &lt;code&gt;KReclaimable&lt;/code&gt; column does not survive contact with a live watch.&lt;/p&gt;

&lt;p&gt;One caveat: 1.5 KiB is the &lt;strong&gt;upper bound&lt;/strong&gt;, for a watcher that never touches the files. In a tool that opens and reads what it watches — a compiler's watch mode, a log shipper — those inodes would be resident anyway, and the marginal cost of a watch approaches 110 bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about the limit
&lt;/h2&gt;

&lt;p&gt;Now the multiplication from the opening. At the median unit cost, 204,800 watches come to &lt;strong&gt;295.1 MiB&lt;/strong&gt; (333.9 MiB with the outlier run; roughly 480 MiB on a container layer). Filling the 1,048,576 my VM reports would be &lt;strong&gt;1.48 GiB&lt;/strong&gt; — 30.7% of a 4.8 GiB machine.&lt;/p&gt;

&lt;p&gt;That 1,048,576 is a story of its own. The kernel computes it at boot from RAM: 1% of memory, divided by &lt;code&gt;INOTIFY_WATCH_COST&lt;/code&gt;, clamped to &lt;code&gt;[8192, 1048576]&lt;/code&gt;. The cost constant in the source is one record plus &lt;em&gt;two&lt;/em&gt; inodes, which on this machine's object sizes is 80 + 2×624 = &lt;strong&gt;1,328 bytes&lt;/strong&gt;. Since 1% of 4.8 GiB is 51,572,736 bytes, the number the kernel would arrive at is &lt;strong&gt;38,834&lt;/strong&gt;. The reported value is twenty-seven times that, so it did not come from the arithmetic — it was written. I checked &lt;code&gt;/etc/sysctl.conf&lt;/code&gt;, &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; and &lt;code&gt;/lib/sysctl.d/&lt;/code&gt; in the VM (&lt;code&gt;/run&lt;/code&gt;, &lt;code&gt;/usr/lib&lt;/code&gt; and &lt;code&gt;/usr/local/lib&lt;/code&gt; have no such directory in this image) and found no inotify line. The author stays unidentified — though a privileged container overwriting a host sysctl has happened to me on this very machine before: &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/fs-file-maxi-kim-degistirdi-iz-konteynerde/" rel="noopener noreferrer"&gt;when I went looking for who changed &lt;code&gt;fs.file-max&lt;/code&gt;, the trail led into a container&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;My decision framework:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Count before you raise.&lt;/strong&gt; The loop above gives current consumption broken down by uid. A watcher that dies at 8192 rarely needs 204,800; the number it needs is usually your tree's directory count plus the few dozen the infrastructure eats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do the multiplication.&lt;/strong&gt; Target watches × ~1.5 KiB (≈2.45 KiB on a container layer) = kernel memory that will not be reclaimed while the watcher lives. Decide there whether that is acceptable against the machine's RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot give a container its own budget.&lt;/strong&gt; &lt;code&gt;fs.inotify.*&lt;/code&gt; is not namespaced: &lt;code&gt;/proc/sys&lt;/code&gt; is read-only inside the container, and &lt;code&gt;docker run --sysctl fs.inotify.max_user_watches=...&lt;/code&gt; refuses outright (&lt;em&gt;"sysctl ... is not allowed"&lt;/em&gt;). The only addresses are the node's own sysctl, or a different uid.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A separate uid, but before the descriptor.&lt;/strong&gt; Because the bucket is charged to the euid at &lt;code&gt;inotify_init&lt;/code&gt; time, ordering matters in privilege-dropping services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the tree is large, change the instrument.&lt;/strong&gt; inotify is the wrong one for hundreds of thousands of directories; &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/fanotify-dosya-erisimini-izlemek-ve-durdurmak/" rel="noopener noreferrer"&gt;fanotify watches an entire mount point with a single mark&lt;/a&gt;, at the price of &lt;code&gt;CAP_SYS_ADMIN&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I did not prove
&lt;/h2&gt;

&lt;p&gt;The measurements come from a single kernel (&lt;code&gt;6.10.14-linuxkit&lt;/code&gt;) on a single machine. Since the size of &lt;code&gt;struct inode&lt;/code&gt; varies with build options, both the 1.5 KiB figure and the 1,328-byte cost constant belong to this machine and should be re-read elsewhere via &lt;code&gt;/sys/kernel/slab/.../object_size&lt;/code&gt;. The 1.48 GiB figure was extrapolated linearly from 150,000 watches; a million were never actually installed. The 110-byte figure rests on a single run. The &lt;code&gt;max_queued_events&lt;/code&gt; limit and the &lt;code&gt;IN_Q_OVERFLOW&lt;/code&gt; event that arrives on overflow went untested. The euid claim rests on the source; no experiment here separates real from effective uid. And one more: my first "ext4" bench was silently running on overlay because a bind mount had failed, and I was about to publish the claim that both filesystems cost the same — repeating it with a named volume is what exposed the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;inotify's limits are not badly documented; what is bad is mistaking them for a capacity measurement. The number in &lt;code&gt;max_user_watches&lt;/code&gt; is not how many watches your machine can carry, it is a permission somebody granted — and filling that permission has a price measured in gigabytes, one the kernel will not hand back under pressure.&lt;/p&gt;

&lt;p&gt;The habit worth taking from this: before raising a sysctl, measure its unit cost and multiply by your target. It is two lines of work, and more often than not the answer comes back "don't raise it, lower it".&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/torvalds/linux/blob/0c3836482481200ead7b416ca80c68a29cfdaabd/fs/notify/inotify/inotify_user.c" rel="noopener noreferrer"&gt;fs/notify/inotify/inotify_user.c (Linux, the commit behind tag v6.10)&lt;/a&gt; — where &lt;code&gt;ENOSPC&lt;/code&gt;/&lt;code&gt;EMFILE&lt;/code&gt; are returned, &lt;code&gt;current_euid()&lt;/code&gt;, &lt;code&gt;INOTIFY_WATCH_COST&lt;/code&gt;, and how the defaults are computed&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/torvalds/linux/blob/0c3836482481200ead7b416ca80c68a29cfdaabd/kernel/ucount.c" rel="noopener noreferrer"&gt;kernel/ucount.c (Linux, the commit behind tag v6.10)&lt;/a&gt; — &lt;code&gt;inc_ucount()&lt;/code&gt; walking the namespace chain, and its rollback loop&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.syncthing.net/users/faq.html" rel="noopener noreferrer"&gt;Syncthing FAQ — inotify limits&lt;/a&gt; — the 8192 limit in the field and the 204800 recommendation&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>cekirdek</category>
      <category>container</category>
      <category>olcum</category>
    </item>
    <item>
      <title>My Runs Took 19 Hours, My Measurement Counted 58,530</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:31:16 +0000</pubDate>
      <link>https://dev.to/merbayerp/my-runs-took-19-hours-my-measurement-counted-58530-245o</link>
      <guid>https://dev.to/merbayerp/my-runs-took-19-hours-my-measurement-counted-58530-245o</guid>
      <description>&lt;p&gt;Two weeks ago I audited the claims in this blog's agent instructions one by one&lt;br&gt;
against the code. &lt;a href="https://mustafaerbay.com.tr/en/blog/career/recetemin-yedi-iddiasi-kodu-yanlis-anlatiyor/" rel="noopener noreferrer"&gt;The one that mattered most in that audit was deploy duration&lt;/a&gt;:&lt;br&gt;
the docs said "about 12 minutes" in five separate places, and measuring twenty-four&lt;br&gt;
runs gave a median of 3.3 minutes. That was one workflow. This morning the question&lt;br&gt;
was wider: how much machine time do &lt;strong&gt;all&lt;/strong&gt; of the repository's workflows consume?&lt;/p&gt;

&lt;p&gt;Measuring looked easy. The GitHub Actions API gives timestamps for every run;&lt;br&gt;
subtract &lt;code&gt;created_at&lt;/code&gt; from &lt;code&gt;updated_at&lt;/code&gt; and you have the duration. One line of&lt;br&gt;
Python, one coffee. I pulled the last thousand runs, summed the differences, and the&lt;br&gt;
screen told me: &lt;strong&gt;58,530 hours&lt;/strong&gt;. Six and a half years. Across fifteen days.&lt;/p&gt;

&lt;p&gt;The number was so absurd that it saved me. I wish every bad measurement were wrong&lt;br&gt;
that loudly; most of them look plausible, settle into a dashboard and quietly bend&lt;br&gt;
decisions. The uncomfortable part wasn't that the measurement was wrong — it was that&lt;br&gt;
I turned out to be the culprit.&lt;/p&gt;

&lt;h2&gt;
  
  
  A thousand runs — but why a thousand?
&lt;/h2&gt;

&lt;p&gt;I didn't choose the window; my query did. While pulling the runs I had set a&lt;br&gt;
&lt;code&gt;created=&amp;gt;=2026-09-25&lt;/code&gt; filter, and when the API returned nothing after the tenth page&lt;br&gt;
I assumed that was all of it. It wasn't. The repository's run list reports a&lt;br&gt;
&lt;code&gt;total_count&lt;/code&gt; of &lt;strong&gt;4,240&lt;/strong&gt;, and when I removed the filter and paged again, all&lt;br&gt;
forty-three pages came back full — every one of the 4,240 runs, reaching back to&lt;br&gt;
14 September.&lt;/p&gt;

&lt;p&gt;The ceiling is real, just not where I thought it was: GitHub documents that this&lt;br&gt;
endpoint returns at most 1,000 results for searches that use &lt;strong&gt;filters&lt;/strong&gt; such as&lt;br&gt;
actor, branch, event, status or creation date. An unfiltered listing has no such&lt;br&gt;
limit. So what cut my dataset down to 1,000 runs wasn't the repository's history. It&lt;br&gt;
was my query.&lt;/p&gt;

&lt;p&gt;I kept the measurement on those thousand runs anyway — 10 October 08:45 UTC through&lt;br&gt;
25 October 08:09 UTC, fifteen days. But I'm now writing that down as a choice rather&lt;br&gt;
than a finding. The rest of this article is about a bigger version of the same&lt;br&gt;
mistake: the shape of the query decides the shape of the answer.&lt;/p&gt;

&lt;p&gt;The second surprise came from inside the dataset. I also pulled the job records for&lt;br&gt;
each of those thousand runs, because I wanted the real duration from there. Only&lt;br&gt;
&lt;strong&gt;801&lt;/strong&gt; of the 1,000 yield a real duration; the remaining 199 consist entirely of&lt;br&gt;
&lt;code&gt;skipped&lt;/code&gt; jobs, and curiously all of them belong to a single workflow (I'll come back&lt;br&gt;
to that). A skipped job has a &lt;code&gt;completed_at&lt;/code&gt;, and its &lt;code&gt;started_at&lt;/code&gt; doesn't mark a&lt;br&gt;
real beginning, so a naive "end minus start" produces negative durations on those&lt;br&gt;
rows. My first pass produced exactly that. This is where I always lose the most time&lt;br&gt;
when measuring: the data contains rows that have nothing to do with the question, and&lt;br&gt;
averaging them without filtering makes you look foolish.&lt;/p&gt;

&lt;p&gt;So now I have two durations for 801 runs: the &lt;strong&gt;naive&lt;/strong&gt; one from the API field, and&lt;br&gt;
the &lt;strong&gt;real&lt;/strong&gt; one from the jobs' own timestamps.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Measurement&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;th&gt;Median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;updated_at&lt;/code&gt; − &lt;code&gt;created_at&lt;/code&gt; (naive)&lt;/td&gt;
&lt;td&gt;58,530 hours&lt;/td&gt;
&lt;td&gt;87.4 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Job timestamps (real)&lt;/td&gt;
&lt;td&gt;19.2 hours&lt;/td&gt;
&lt;td&gt;73 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three thousand and fifty times off. In the median, the gap between 87 hours and 73&lt;br&gt;
seconds is the difference between "it ran for four days" and "it ran for a minute and&lt;br&gt;
a half."&lt;/p&gt;

&lt;h2&gt;
  
  
  If the error depends on age, the calendar is the suspect
&lt;/h2&gt;

&lt;p&gt;My first instinct was to blame the API. Then I sorted the rows by date and the&lt;br&gt;
pattern stared back at me: for today's and yesterday's runs, the naive duration is&lt;br&gt;
correct. Across the 147 intact runs the median difference between naive and real&lt;br&gt;
is &lt;strong&gt;4 seconds&lt;/strong&gt; — the method works. The error lives only in rows older than three&lt;br&gt;
days, and there it has no exceptions: &lt;strong&gt;654&lt;/strong&gt; of the 801 runs (81.6%) show a naive&lt;br&gt;
duration more than an hour above reality.&lt;/p&gt;

&lt;p&gt;An age-dependent error doesn't tell you the arithmetic is broken. It tells you the&lt;br&gt;
&lt;strong&gt;data was modified after the fact&lt;/strong&gt;. If a run's duration grew days after the run&lt;br&gt;
finished, somebody touched the record and walked away.&lt;/p&gt;

&lt;p&gt;The age at corruption came out of the same data: of the 654 broken rows, the fifth&lt;br&gt;
percentile is 3.30 days, the median is &lt;strong&gt;3.75 days&lt;/strong&gt;, and the highest is 4.23 days.&lt;br&gt;
Every record rots about three and a half to four days after birth. That was oddly&lt;br&gt;
close to a number I had written myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The culprit's signature: 05:43
&lt;/h2&gt;

&lt;p&gt;In the repository's &lt;code&gt;.github/workflows/&lt;/code&gt; directory sits a workflow called&lt;br&gt;
&lt;code&gt;cleanup-actions-storage.yml&lt;/code&gt;. I wrote it. Its purpose is clear: delete old run logs&lt;br&gt;
every day so the Actions storage quota never fills. Its default &lt;code&gt;keep_days&lt;/code&gt; is &lt;strong&gt;3&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I tied it down with two measurements. First: on the morning of 25 October the cleanup&lt;br&gt;
run started at 05:41:56 and finished at 05:45:02. The new timestamps on the records&lt;br&gt;
that rotted that morning are spread across four separate seconds, from 05:43:05 to&lt;br&gt;
05:43:08 — the middle of the three minutes the janitor was awake.&lt;/p&gt;

&lt;p&gt;The second is more decisive. I checked whether the logs of the broken rows still&lt;br&gt;
exist, probing the log endpoint for twenty runs spread across the window:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run age&lt;/th&gt;
&lt;th&gt;Log HTTP&lt;/th&gt;
&lt;th&gt;Naive duration&lt;/th&gt;
&lt;th&gt;State&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0–3 days (4 runs)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;0.00–0.35 hours&lt;/td&gt;
&lt;td&gt;intact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3+ days (16 runs)&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;78–101 hours&lt;/td&gt;
&lt;td&gt;broken&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twenty runs, twenty hits. Not one record with surviving logs has an inflated&lt;br&gt;
duration; not one record with deleted logs has a correct one. The script's artifact&lt;br&gt;
and cache deletion steps aren't filtered by age — whatever they delete, they delete&lt;br&gt;
every night — so they can't produce a signature that lands on a three-day threshold.&lt;br&gt;
That leaves the one step that does look at age: log deletion.&lt;/p&gt;

&lt;p&gt;There's also this: the cleanup job's own run records meet the same fate. The record&lt;br&gt;
of the janitor that ran on the morning of 21 October was overwritten at 05:43:08 on&lt;br&gt;
25 October — by the colleague that came four days later. I watched an automation eat&lt;br&gt;
its own tail and, I'll admit, I rather enjoyed it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtSdW4gaXMgY3JlYXRlZDxici8-Y3JlYXRlZF9hdCA9IDIxIE9jdCAxNDo1OV0gLS0-IEJbSm9iIHJ1bnMgYW5kIGZpbmlzaGVzPGJyLz5yZWFsIGR1cmF0aW9uOiAzIG1pbiAyNCBzXQogIEIgLS0-IENbdXBkYXRlZF9hdCA9IDE1OjAyPGJyLz5uYWl2ZSBtYXRoIGlzIENPUlJFQ1RdCiAgQyAtLT4gRFt3YWl0cyAzLjMtNC4yIGRheXNdCiAgRCAtLT4gRVtjbGVhbnVwIGpvYjogREVMRVRFIC9sb2dzPGJyLz4yNSBPY3QgMDU6NDNdCiAgRSAtLT4gRlt1cGRhdGVkX2F0ID0gMjUgT2N0IDA1OjQzPGJyLz5yZWNvcmQgYWxpdmUsIHN0YW1wIG92ZXJ3cml0dGVuXQogIEYgLS0-IEdbbmFpdmUgbWF0aDogODYuNyBob3Vyczxici8-MSw1MzF4IHJlYWxpdHld%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVtSdW4gaXMgY3JlYXRlZDxici8-Y3JlYXRlZF9hdCA9IDIxIE9jdCAxNDo1OV0gLS0-IEJbSm9iIHJ1bnMgYW5kIGZpbmlzaGVzPGJyLz5yZWFsIGR1cmF0aW9uOiAzIG1pbiAyNCBzXQogIEIgLS0-IENbdXBkYXRlZF9hdCA9IDE1OjAyPGJyLz5uYWl2ZSBtYXRoIGlzIENPUlJFQ1RdCiAgQyAtLT4gRFt3YWl0cyAzLjMtNC4yIGRheXNdCiAgRCAtLT4gRVtjbGVhbnVwIGpvYjogREVMRVRFIC9sb2dzPGJyLz4yNSBPY3QgMDU6NDNdCiAgRSAtLT4gRlt1cGRhdGVkX2F0ID0gMjUgT2N0IDA1OjQzPGJyLz5yZWNvcmQgYWxpdmUsIHN0YW1wIG92ZXJ3cml0dGVuXQogIEYgLS0-IEdbbmFpdmUgbWF0aDogODYuNyBob3Vyczxici8-MSw1MzF4IHJlYWxpdHld%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="276" height="862"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;My intent wasn't careless; I even wrote the comment at the top of the workflow&lt;br&gt;
myself: run records (success/failure status) are preserved, only the detailed log&lt;br&gt;
files are deleted. The sentence is true. The run record is still there, its status is&lt;br&gt;
there, its commit is there. The one thing that never crossed my mind was that the&lt;br&gt;
record's &lt;strong&gt;timestamp&lt;/strong&gt; is data too. The log deletion call pulls &lt;code&gt;updated_at&lt;/code&gt; to the&lt;br&gt;
current hour, and the record looks like it was breathing for four days.&lt;/p&gt;

&lt;p&gt;One field does survive, and the data shows it: in all 654 broken rows&lt;br&gt;
&lt;code&gt;run_started_at&lt;/code&gt; still matches &lt;code&gt;created_at&lt;/code&gt; to the second. The janitor doesn't touch&lt;br&gt;
it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every sweep touches exactly one day
&lt;/h2&gt;

&lt;p&gt;A small detail, but it sits at the heart of the mistake I repeat most often in my&lt;br&gt;
career: &lt;code&gt;keep_days&lt;/code&gt; says 3 and the measurement says 3.75 days. Two reasons.&lt;/p&gt;

&lt;p&gt;First, the cleanup script builds its cutoff at day resolution. The line is right&lt;br&gt;
there in the 25 October run's own log: &lt;code&gt;Cutoff date: 2026-10-22&lt;/code&gt;, with the query&lt;br&gt;
&lt;code&gt;created=&amp;lt;2026-10-22&lt;/code&gt;. That sweep therefore excludes all of 22 October and touches&lt;br&gt;
&lt;strong&gt;only&lt;/strong&gt; runs created on the 21st. The data says the same: all 37 records overwritten&lt;br&gt;
that morning were created on 21 October, aged between 3.30 and 4.17 days. A job that&lt;br&gt;
ran just after midnight sees four days; one that ran in the late afternoon sees three&lt;br&gt;
and a half. A single calendar day becomes an age band a day wide.&lt;/p&gt;

&lt;p&gt;Second, and sneakier: the cron time doesn't hold. The workflow says &lt;code&gt;0 1 * * *&lt;/code&gt;,&lt;br&gt;
i.e. 01:00 UTC. Across the last thirty scheduled runs the median delay is &lt;strong&gt;16&lt;br&gt;
minutes&lt;/strong&gt; and the worst is &lt;strong&gt;286 minutes&lt;/strong&gt;. The distribution splits in two: runs up&lt;br&gt;
to 13 October started 9–16 minutes late, and every run after 14 October was 259–286&lt;br&gt;
minutes late. That looks less like load jitter than a regime change; GitHub's docs&lt;br&gt;
say the &lt;code&gt;schedule&lt;/code&gt; event can be delayed under load, but I didn't measure the cause of&lt;br&gt;
this step and I don't know it. The practical effect: my "runs at one in the morning"&lt;br&gt;
cleanup actually runs around 05:30, pushing the age band a day and a half forward.&lt;/p&gt;

&lt;p&gt;In fairness: &lt;strong&gt;2&lt;/strong&gt; of the 654 broken rows don't fit this pattern. Both were updated&lt;br&gt;
75–79 minutes after their run, on the evening of 22 October, and both are CodeQL&lt;br&gt;
analysis runs. It's plausible that code-scanning results touched the record late, but&lt;br&gt;
I couldn't verify it. The arithmetic works for 652 of them and not for two; I'm&lt;br&gt;
writing that down rather than deleting it, because a table that hides the limits of&lt;br&gt;
its measurement lies to the reader.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hunting for the right field: defined and undefined
&lt;/h2&gt;

&lt;p&gt;So which field should I trust? I downloaded GitHub's own OpenAPI description and&lt;br&gt;
looked at the &lt;code&gt;workflow-run&lt;/code&gt; schema. The schema has &lt;strong&gt;36 fields&lt;/strong&gt;, and &lt;strong&gt;15&lt;/strong&gt; of them&lt;br&gt;
carry no description at all — including &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt;. Both&lt;br&gt;
fields my metric rests on are typed but undefined: "string, date-time", with nothing&lt;br&gt;
said about which moment they capture.&lt;/p&gt;

&lt;p&gt;One field does carry a definition, and it's precisely the one I needed:&lt;br&gt;
&lt;code&gt;run_started_at&lt;/code&gt; is documented as "The start time of the latest run. Resets on&lt;br&gt;
re-run." One sentence, with a warning inside it — it resets on a re-run. My window&lt;br&gt;
contains no re-runs (every &lt;code&gt;run_attempt&lt;/code&gt; is 1), so I can use it; in another&lt;br&gt;
repository I couldn't use it so comfortably.&lt;/p&gt;

&lt;p&gt;That leaves two ways to get the real duration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;/timing&lt;/code&gt; endpoint&lt;/strong&gt; — it returns &lt;code&gt;run_duration_ms&lt;/code&gt; per run. On my sample run
it reported 224,000 ms, while the value I computed from job timestamps was 204
seconds. The two sources disagree by 10%, and both say "three and a half minutes."
I don't know which is more accurate, and closing that gap isn't this article's job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;/jobs&lt;/code&gt; endpoint&lt;/strong&gt; — the jobs' own start and finish timestamps. It survives
log deletion, which makes it the one duration source the janitor doesn't touch.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's an irony here. &lt;code&gt;/timing&lt;/code&gt; carries this warning in GitHub's documentation:&lt;br&gt;
"This endpoint is in the process of closing down." The endpoint that gives the right&lt;br&gt;
number is closing; the field that feeds the wrong arithmetic stays put. And the&lt;br&gt;
warning lives in prose only: the machine-readable &lt;code&gt;deprecated&lt;/code&gt; flag in the schema is&lt;br&gt;
not set. If you scan your endpoints programmatically for deprecation, you won't see&lt;br&gt;
this one — I wouldn't have either, had I not read the page with my eyes.&lt;/p&gt;

&lt;p&gt;My decision: build the duration metric on &lt;code&gt;/jobs&lt;/code&gt;, and read &lt;code&gt;updated_at&lt;/code&gt; only as&lt;br&gt;
"when was this record last touched." The field's name was saying that all along. I&lt;br&gt;
was the one asking it a different question.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real numbers
&lt;/h2&gt;

&lt;p&gt;Once the measurement was fixed, the table turned boring — in the good sense. In&lt;br&gt;
fifteen days, 801 measurable runs, &lt;strong&gt;19.2 hours&lt;/strong&gt; of work in total. Median 73&lt;br&gt;
seconds, mean 86 seconds, longest run 23.4 minutes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Workflow&lt;/th&gt;
&lt;th&gt;Total runs&lt;/th&gt;
&lt;th&gt;Measurable&lt;/th&gt;
&lt;th&gt;Real median&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Workflow Failure Alert&lt;/td&gt;
&lt;td&gt;327&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;td&gt;7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Social Media Post&lt;/td&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;td&gt;184&lt;/td&gt;
&lt;td&gt;76 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily publish pace check&lt;/td&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;td&gt;127&lt;/td&gt;
&lt;td&gt;7 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code Quality: Push on main&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;84 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push on main&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;td&gt;197 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Veo Prompt (Daily Short)&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;49&lt;/td&gt;
&lt;td&gt;68 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy Only (Code, No Content)&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;172 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FAQ Backfill (TR + EN)&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;70 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first row explains the 199 runs I mentioned earlier: &lt;strong&gt;every&lt;/strong&gt; run made up&lt;br&gt;
entirely of skipped jobs belongs to Workflow Failure Alert. That sentry ran 327 times&lt;br&gt;
in the window, and in 199 of them no job ran at all — it looked at whether something&lt;br&gt;
had broken, said no, and closed.&lt;/p&gt;

&lt;p&gt;Two weeks ago I counted&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/career/nobetcilerimi-denetledim-1973-kosu-defterde-15-satir/" rel="noopener noreferrer"&gt;what my watchman workflows give and what they cost&lt;/a&gt;:&lt;br&gt;
two sentries that produce nothing were eating three out of every five runs. That&lt;br&gt;
article asked "how many times did it run?", and the answer is the &lt;strong&gt;Total runs&lt;/strong&gt;&lt;br&gt;
column: 327 and 127. This table puts duration next to them: 7 seconds. Their bill is&lt;br&gt;
heavy in run count and almost nothing in machine time. Reading the same data with two&lt;br&gt;
different questions produces two different decisions — and both are correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two wrong numbers, two different causes
&lt;/h2&gt;

&lt;p&gt;Back to where I started. For the Deploy Only workflow, the median across 44 runs is&lt;br&gt;
&lt;strong&gt;2.9 minutes&lt;/strong&gt;, the 90th percentile is 5.1 minutes (by linear interpolation), and&lt;br&gt;
the worst is 23.4 minutes. Two weeks ago the same workflow showed a median of 3.3&lt;br&gt;
minutes over twenty-four runs; the two measurements agree. The number in the&lt;br&gt;
docs is still &lt;strong&gt;~12 min&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In that earlier article I also found where the 12 came from: it lives in two code&lt;br&gt;
comments, left over from a period when the build genuinely took 8–12 minutes. It was&lt;br&gt;
correct the day it was written. The build got faster; the number stayed.&lt;/p&gt;

&lt;p&gt;That's the interesting pair. The number that went wrong by never being updated is&lt;br&gt;
four times reality; the number I found by measuring is three thousand times reality.&lt;br&gt;
The bad measurement produced a worse outcome than the stale guess — because the guess&lt;br&gt;
carries an "approximately" next to it and binds nobody, whereas nobody would have&lt;br&gt;
questioned the measurement had I put it in a table. Attach a method to a number and&lt;br&gt;
the right of objection closes. That, I think, is the most expensive side effect of&lt;br&gt;
measurement culture: we grant trust to the costume rather than to the figure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five questions I now ask before choosing a field
&lt;/h2&gt;

&lt;p&gt;I wrote myself a short list after this run. Before standing up a new metric:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Does the field have a definition?&lt;/strong&gt; If I'm deriving meaning from a field with no
description in the schema, I may have invented that meaning. (15 of 36 fields were
undefined.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who else modifies this record?&lt;/strong&gt; Cleanup jobs, retention sweeps, re-runs. My own
automations belong on that list too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the error age-dependent?&lt;/strong&gt; Run the same query separately against today's rows,
yesterday's, and rows from a week back. Inconsistency means the data changes after
the fact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is my query truncating the data?&lt;/strong&gt; The silent ceiling on a filtered search had
cut my 4,240 runs down to 1,000, and it took me half a day to notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long will the correct source live?&lt;/strong&gt; If I'm wiring a metric to an endpoint
that is closing down, I need to note that today — otherwise in six months I'm left
with a dashboard that quietly returns nothing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The second item gave me the most trouble, because it required writing my own name in&lt;br&gt;
there. The same thing happened when I wrote about&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/career/bes-todom-160-gundur-duruyor-kapanan-iki-isareti-flutter-yazmis/" rel="noopener noreferrer"&gt;five TODOs that had been sitting for months&lt;/a&gt;:&lt;br&gt;
the hardest component in my inventory keeps turning out to be me.&lt;/p&gt;

&lt;h2&gt;
  
  
  So what do I do about the janitor?
&lt;/h2&gt;

&lt;p&gt;Closing the article on "lesson learned" would be easy, but it leaves the reader&lt;br&gt;
empty-handed. I have three options, and I'm weighing them against the data.&lt;/p&gt;

&lt;p&gt;The first is to stop deleting logs altogether and hand retention to the repository's&lt;br&gt;
own setting. GitHub lets you set log and artifact retention between 1 and 90 days in&lt;br&gt;
repository settings; that turns my job into changing a number rather than maintaining&lt;br&gt;
a workflow. The open end: I don't know whether the platform's own sweep overwrites&lt;br&gt;
&lt;code&gt;updated_at&lt;/code&gt; — I didn't measure it, and I won't write what I haven't measured.&lt;/p&gt;

&lt;p&gt;The second is to keep the cleanup and make the duration durable instead: write each&lt;br&gt;
run's real duration into my own ledger when it finishes. Then an overwritten stamp&lt;br&gt;
stops mattering.&lt;/p&gt;

&lt;p&gt;The third is to leave the script alone and move the metric to &lt;code&gt;/jobs&lt;/code&gt; — which is what&lt;br&gt;
I did today. I also noted something on the side: every night the script re-sends the&lt;br&gt;
DELETE call for hundreds of runs whose logs are already gone (its log says "100 runs&lt;br&gt;
to clean" on all five pages). The wasted API calls cost close to nothing, but that&lt;br&gt;
behaviour is exactly what keeps overwriting the stamps night after night.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measuring is not an innocent act
&lt;/h2&gt;

&lt;p&gt;For a long time I thought of measuring as passive work: the system runs, I watch from&lt;br&gt;
the side, I write the number down. This run showed me the opposite. In my own&lt;br&gt;
infrastructure, the ground my measurement stood on was being erased by another&lt;br&gt;
automation of mine. I wrote both, both were doing their jobs correctly, and the place&lt;br&gt;
where they intersect had no owner.&lt;/p&gt;

&lt;p&gt;If nobody re-derives a metric, it rots slowly. And you only notice the rot on the day&lt;br&gt;
it hands you an absurd result — I was lucky enough to get a result as absurd as six&lt;br&gt;
and a half years. A milder error, say twice reality, would have sat on a dashboard&lt;br&gt;
for months and produced a meeting about how "the deploy is getting slower."&lt;/p&gt;

&lt;p&gt;What I did today isn't even two lines of code: leave one field for another, question&lt;br&gt;
the filter on a query, note the side effect of a cleanup job. But the real gain was a&lt;br&gt;
single sentence — I have to count my own automations as part of the system I measure,&lt;br&gt;
not outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/rest/actions/workflow-runs" rel="noopener noreferrer"&gt;REST API endpoints for workflow runs — GitHub Docs&lt;/a&gt;: the fields of the run object, the 1,000-result limit on filtered searches, the closing-down notice on the &lt;code&gt;/timing&lt;/code&gt; endpoint, and the definition of the log-deletion call.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows" rel="noopener noreferrer"&gt;Events that trigger workflows — GitHub Docs&lt;/a&gt;: that the &lt;code&gt;schedule&lt;/code&gt; event can be delayed under load, and that cron runs in UTC by default.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/github/rest-api-description" rel="noopener noreferrer"&gt;github/rest-api-description&lt;/a&gt;: the OpenAPI description of GitHub's REST API; the field descriptions in the &lt;code&gt;workflow-run&lt;/code&gt; schema (and the fields that have none) can be verified here.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/enabling-features-for-your-repository/managing-github-actions-settings-for-a-repository" rel="noopener noreferrer"&gt;Managing GitHub Actions settings for a repository — GitHub Docs&lt;/a&gt;: retention settings for run logs and artifacts.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>softwarecareer</category>
      <category>measurement</category>
      <category>automation</category>
      <category>cicd</category>
    </item>
    <item>
      <title>My Notification List Has 94 Rows, 25 Have No App Path</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 24 Oct 2026 14:13:46 +0000</pubDate>
      <link>https://dev.to/merbayerp/my-notification-list-has-94-rows-25-have-no-app-path-n2n</link>
      <guid>https://dev.to/merbayerp/my-notification-list-has-94-rows-25-have-no-app-path-n2n</guid>
      <description>&lt;p&gt;Turning a notification off in the settings pane is easy. What I actually&lt;br&gt;
wondered was something else: when I turn it off, does the thing leave the list,&lt;br&gt;
or does it just go quiet? The question looks innocent, but the answer isn't in&lt;br&gt;
the pane, because the pane shows you the switch, not the ledger.&lt;/p&gt;

&lt;p&gt;So I opened the ledger. On my laptop running macOS 26.6.2, notification&lt;br&gt;
permissions live in &lt;code&gt;~/Library/Preferences/com.apple.ncprefs.plist&lt;/code&gt;. I found 94&lt;br&gt;
rows in it. My first reaction on seeing that number was "so I've granted&lt;br&gt;
permission to 94 apps." Wrong reaction. Twenty-five of those rows don't even&lt;br&gt;
carry an app path, and three point to a file that no longer exists on disk.&lt;/p&gt;

&lt;p&gt;The real finding isn't in the numbers. It's that this ledger is an&lt;br&gt;
&lt;strong&gt;append-only&lt;/strong&gt; one. Everything that asks for permission leaves a row behind;&lt;br&gt;
nothing ever removes one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Counting the file is not counting the pane
&lt;/h2&gt;

&lt;p&gt;First I asked the same question two different ways, because on macOS&lt;br&gt;
preferences aren't written straight to disk — a daemon called &lt;code&gt;cfprefsd&lt;/code&gt; sits&lt;br&gt;
in between, and the file can lag behind the in-memory state. If the two&lt;br&gt;
answers disagreed, I needed to know which one to trust.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# (1) via cfprefsd: the state the operating system sees&lt;/span&gt;
defaults &lt;span class="nb"&gt;export &lt;/span&gt;com.apple.ncprefs - &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/nc.plist

&lt;span class="c"&gt;# (2) the file on disk, directly&lt;/span&gt;
python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import plistlib,os;d=plistlib.load(open(os.path.expanduser(
'~/Library/Preferences/com.apple.ncprefs.plist'),'rb'));print(len(d['apps']))"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both said 94. Better yet, the sets of &lt;code&gt;bundle-id&lt;/code&gt; values from the two sources&lt;br&gt;
matched exactly: nothing extra, nothing missing. So the file on disk isn't&lt;br&gt;
stale; this is the living state.&lt;/p&gt;

&lt;p&gt;Then I looked at the file itself, and that's where it got interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"mtime=%Sm size=%z"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"%Y-%m-%d %H:%M:%S"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ~/Library/Preferences/com.apple.ncprefs.plist
&lt;span class="c"&gt;# mtime=2025-09-23 16:22:05 size=30324&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file hasn't been rewritten in thirteen months. Ask &lt;code&gt;file&lt;/code&gt; what it is and&lt;br&gt;
it answers "Apple binary property list": thirty kilobytes of binary record,&lt;br&gt;
sitting perfectly still for thirteen months, and still today's effective&lt;br&gt;
setting. Black boxes tend to spend their quietest stretches without telling&lt;br&gt;
anybody.&lt;/p&gt;
&lt;h2&gt;
  
  
  25 of the 94 rows have no app path
&lt;/h2&gt;

&lt;p&gt;When I split the rows by the prefix on their &lt;code&gt;bundle-id&lt;/code&gt;, the picture came out&lt;br&gt;
like this:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Row type&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;th&gt;What it means&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_SYSTEM_CENTER_:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;The system's own alert sources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_WEB_CENTER_:&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;A permission granted to a website&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordinary &lt;code&gt;bundle-id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;69&lt;/td&gt;
&lt;td&gt;Actual applications&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;None of the twenty-four &lt;code&gt;_SYSTEM_CENTER_&lt;/code&gt; rows has a &lt;code&gt;path&lt;/code&gt; field. Their names&lt;br&gt;
give them away:&lt;br&gt;
&lt;code&gt;com.apple.diskspaced&lt;/code&gt; (the disk-is-filling-up warning),&lt;br&gt;
&lt;code&gt;com.apple.SoftwareUpdateNotification&lt;/code&gt;, &lt;code&gt;com.apple.BluetoothUIServer&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;com.apple.security.keychain-circle-notification&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;com.apple.DiskArbitration.DiskArbitrationAgent&lt;/code&gt; (the warning you get for&lt;br&gt;
yanking a drive without ejecting it), &lt;code&gt;com.apple.EscrowSecurityAlert&lt;/code&gt;. Four of&lt;br&gt;
them are purely Wi‑Fi business: diagnostics, joining a network, personal&lt;br&gt;
hotspot, peer-to-peer.&lt;/p&gt;

&lt;p&gt;A distinction is needed here: "no path" and "no code behind it" are not the&lt;br&gt;
same thing. Twenty of these twenty-four rows carry the signature field I'll get&lt;br&gt;
to shortly, and it names a real signed binary. One of them even corresponds to&lt;br&gt;
an app outright: &lt;code&gt;_SYSTEM_CENTER_:com.apple.FaceTime&lt;/code&gt;. FaceTime is recorded&lt;br&gt;
twice in the ledger, once as a system alert source with no path, once as an app&lt;br&gt;
with the path &lt;code&gt;/System/Applications/FaceTime.app&lt;/code&gt;. The measurement here is the&lt;br&gt;
absence of a path; saying "no app" would claim more than the data shows.&lt;/p&gt;

&lt;p&gt;These rows still aren't permissions I granted: there's no app doing the asking,&lt;br&gt;
and you wouldn't expect a keychain alert to petition you. But in the ledger they sit in exactly the same shape as the apps,&lt;br&gt;
with the same fields. This is where reading the row count as "I granted&lt;br&gt;
permission to 94 apps" starts going wrong.&lt;/p&gt;

&lt;p&gt;Of the remaining 69 rows, 67 have a &lt;code&gt;path&lt;/code&gt;. Two don't: &lt;code&gt;com.apple.iChat&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;com.apple.TelephonyUtilities&lt;/code&gt;. The first is the Messages app's old identifier, a name abandoned&lt;br&gt;
years ago, still holding its own row in the ledger. With no path, I couldn't&lt;br&gt;
tell at first glance what these two rows belong to, but it can be worked out:&lt;br&gt;
the signature field I'll open shortly names them both. The &lt;code&gt;com.apple.iChat&lt;/code&gt;&lt;br&gt;
row points at &lt;code&gt;com.apple.identityservicesd&lt;/code&gt;, and the&lt;br&gt;
&lt;code&gt;com.apple.TelephonyUtilities&lt;/code&gt; row at &lt;code&gt;com.apple.imagent&lt;/code&gt;. Both are system&lt;br&gt;
services, so these two rows belong not to an app but to the plumbing underneath&lt;br&gt;
Messages.&lt;/p&gt;

&lt;p&gt;Fourteen rows also carry a &lt;code&gt;universal-app-id&lt;/code&gt; field, the one that unifies the&lt;br&gt;
iPhone and Mac versions of the same app under a single identity. So the ledger&lt;br&gt;
tracks not just "which app" but "which app family."&lt;/p&gt;

&lt;p&gt;The spread of the paths says something too:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;41 rows point under &lt;code&gt;/System/&lt;/code&gt; (the apps that ship with the operating system),&lt;/li&gt;
&lt;li&gt;25 rows under &lt;code&gt;/Applications/&lt;/code&gt;,&lt;/li&gt;
&lt;li&gt;1 row at an updater under &lt;code&gt;/Library/Application Support/&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  A notification permission granted to localhost
&lt;/h2&gt;

&lt;p&gt;The single &lt;code&gt;_WEB_CENTER_&lt;/code&gt; row stopped me. It's a notification permission&lt;br&gt;
granted to a website, and the site's address is &lt;code&gt;web.&amp;lt;project&amp;gt;.localhost&lt;/code&gt;, a&lt;br&gt;
development server running on my own machine. The row is the record of my&lt;br&gt;
having tried notifications at that address once: the browser asked, permission&lt;br&gt;
was given.&lt;/p&gt;

&lt;p&gt;That address doesn't lead anywhere now. The name doesn't resolve, the request&lt;br&gt;
goes unanswered, nothing on the machine is listening:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;host web.&amp;lt;project&amp;gt;.localhost
&lt;span class="c"&gt;# Host web.&amp;lt;project&amp;gt;.localhost not found: 3(NXDOMAIN)&lt;/span&gt;

curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--max-time&lt;/span&gt; 5 http://web.&amp;lt;project&amp;gt;.localhost/
&lt;span class="c"&gt;# 000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server is gone. The permission stayed.&lt;/p&gt;

&lt;p&gt;There's an interesting asymmetry here. On the web side, permission is granted&lt;br&gt;
&lt;strong&gt;per origin&lt;/strong&gt;. MDN's page for &lt;code&gt;Notification.requestPermission()&lt;/code&gt; states&lt;br&gt;
plainly that permission is requested "for the current origin." The W3C Push API&lt;br&gt;
specification goes further. When permission is revoked, the user agent must&lt;br&gt;
deactivate the affected subscriptions and delete the stored details. So the&lt;br&gt;
web's own model has a revocation mechanism defined.&lt;/p&gt;

&lt;p&gt;But when that origin is a &lt;code&gt;localhost&lt;/code&gt; address, there's no site owner to revoke&lt;br&gt;
anything and no app to uninstall. The row outlives the thing it points at.&lt;/p&gt;

&lt;p&gt;I also found a &lt;code&gt;push&lt;/code&gt; section on eight rows: the ones registered for remote&lt;br&gt;
notifications. One of them held a real 32-byte device token. I'm not printing&lt;br&gt;
its value here, but I am printing where it lives: the file you assumed held&lt;br&gt;
permissions also holds tokens.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three paths that aren't on disk
&lt;/h2&gt;

&lt;p&gt;I checked how many of the 67 paths actually point at a file. Three don't:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import plistlib, os
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
for a in plistlib.load(open(p, 'rb'))['apps']:
    if 'path' in a and not os.path.exists(a['path']):
        print(a['bundle-id'], '-&amp;gt;', a['path'])
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All three end up in the same place, but they got there by different routes, and&lt;br&gt;
the difference matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Minecraft&lt;/strong&gt; is the simple case: &lt;code&gt;/Applications/Minecraft.app&lt;/code&gt; isn't there.&lt;br&gt;
The app is gone from the machine; the row stayed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chrome and Opera&lt;/strong&gt; are more roundabout. Both applications are installed and&lt;br&gt;
working on my machine. But the notification row doesn't point at the app — it&lt;br&gt;
points at a helper program inside the framework, with the &lt;strong&gt;version number&lt;br&gt;
baked into the path&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Browser&lt;/th&gt;
&lt;th&gt;Version in the row&lt;/th&gt;
&lt;th&gt;Versions on disk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Chrome&lt;/td&gt;
&lt;td&gt;&lt;code&gt;140.0.7339.185&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;154.0.8037.98&lt;/code&gt;, &lt;code&gt;155.0.8059.85&lt;/code&gt;, &lt;code&gt;155.0.8059.86&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opera&lt;/td&gt;
&lt;td&gt;&lt;code&gt;120.0.5543.161&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;136.0.6008.80&lt;/code&gt;, &lt;code&gt;137.0.6036.63&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Chrome's own version today is 155.0.8059.86. So my notification ledger knows&lt;br&gt;
Chrome fifteen major versions back and Opera seventeen. Each update creates a&lt;br&gt;
new version directory and removes the old one; the path in the ledger points&lt;br&gt;
into empty space.&lt;/p&gt;

&lt;p&gt;And now the file's frozen timestamp means something: the row was written while&lt;br&gt;
Chrome was at 140, and nobody has touched it since September 2025. The browser&lt;br&gt;
advanced fifteen major versions. The ledger was never written again.&lt;/p&gt;

&lt;p&gt;Which raises a question. If the permission still works, and there's no sign&lt;br&gt;
that it broke, then the path in the ledger can't be what makes it work. So&lt;br&gt;
what carries the identity?&lt;/p&gt;
&lt;h2&gt;
  
  
  The row is bound to a signature, not a path
&lt;/h2&gt;

&lt;p&gt;The answer is in another field. 88 of the 94 rows have a field called &lt;code&gt;src&lt;/code&gt;,&lt;br&gt;
holding 89 blobs in total. Every single one of them starts with the same four&lt;br&gt;
bytes: &lt;code&gt;fade0c00&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import plistlib, os, collections
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
m = collections.Counter()
for a in plistlib.load(open(p, 'rb'))['apps']:
    for s in a.get('src', []):
        if 'req' in s:
            m[s['req'][:4].hex()] += 1
print(dict(m))   # {'fade0c00': 89}
&lt;/span&gt;&lt;span class="no"&gt;PY
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I found where that number comes from in Apple's own open-source Security&lt;br&gt;
distribution. In &lt;code&gt;requirement.h&lt;/code&gt; there's this line:&lt;br&gt;
&lt;code&gt;class Requirement: public Blob&amp;lt;Requirement, 0xfade0c00&amp;gt;&lt;/code&gt;. So these blobs are&lt;br&gt;
code signing &lt;strong&gt;requirement&lt;/strong&gt; blobs.&lt;/p&gt;

&lt;p&gt;Searching inside them for readable strings confirmed it: in 40 of the 89 blobs,&lt;br&gt;
the row's own &lt;code&gt;bundle-id&lt;/code&gt; appears as plain text. The blob on the Mail row holds&lt;br&gt;
a single string: &lt;code&gt;com.apple.mail&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Chrome confirms it exactly. The identity in the row is&lt;br&gt;
&lt;code&gt;com.google.Chrome.framework.AlertNotificationService&lt;/code&gt;; when I open today's&lt;br&gt;
version of the helper and read its identifier, the same string comes back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;defaults &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
Framework.framework/Versions/155.0.8059.86/Helpers/Google Chrome Helper (Alerts).app/Contents/Info.plist"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  CFBundleIdentifier
&lt;span class="c"&gt;# com.google.Chrome.framework.AlertNotificationService&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path is stale, the identity is live. That is precisely why the row kept&lt;br&gt;
working across fifteen versions.&lt;/p&gt;

&lt;p&gt;So the ledger's logic is this: the permission is bound to a &lt;strong&gt;signed code&lt;br&gt;
identity&lt;/strong&gt;, not to a path on disk. The &lt;code&gt;path&lt;/code&gt; field is a convenience, a hint,&lt;br&gt;
maybe a way to find the icon for the pane. The thing that carries identity is&lt;br&gt;
the signing requirement.&lt;/p&gt;

&lt;p&gt;That's news in two directions. The good direction: because it doesn't work by&lt;br&gt;
path, another program can't take over &lt;code&gt;/Applications/Minecraft.app&lt;/code&gt; and inherit&lt;br&gt;
Minecraft's permission; if the signature doesn't match, the row is useless to&lt;br&gt;
it. The bad direction: &lt;strong&gt;nothing checks whether the thing a row points at still&lt;br&gt;
exists&lt;/strong&gt;, because looking at the path was never needed in the first place.&lt;br&gt;
Nobody comes to repair what isn't broken.&lt;/p&gt;
&lt;h2&gt;
  
  
  I can't read the state in the file
&lt;/h2&gt;

&lt;p&gt;Here's a boundary I need to be honest about. Every row has a &lt;code&gt;flags&lt;/code&gt; field,&lt;br&gt;
most have &lt;code&gt;auth&lt;/code&gt;, all have &lt;code&gt;content_visibility&lt;/code&gt;. What the numeric values of&lt;br&gt;
those fields mean isn't documented by Apple, and I'm not going to guess.&lt;/p&gt;

&lt;p&gt;What I can say is that the values &lt;strong&gt;don't fit&lt;/strong&gt; the documented model. Apple's&lt;br&gt;
&lt;code&gt;UNAuthorizationStatus&lt;/code&gt; documentation defines exactly five constants for&lt;br&gt;
permission state: &lt;code&gt;notDetermined&lt;/code&gt;, &lt;code&gt;denied&lt;/code&gt;, &lt;code&gt;authorized&lt;/code&gt;, &lt;code&gt;provisional&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;ephemeral&lt;/code&gt;. The &lt;code&gt;auth&lt;/code&gt; field in my file contains these values: 0, 1, 4, 6, 7,&lt;br&gt;
38, 47, 54, 71, 102, 263, 271, 326, 327, 775, 791. Twenty-four rows have no&lt;br&gt;
&lt;code&gt;auth&lt;/code&gt; field at all.&lt;/p&gt;

&lt;p&gt;Those twenty-four are &lt;strong&gt;not the same set&lt;/strong&gt; as the twenty-four system rows from&lt;br&gt;
earlier. Five come from elsewhere: FaceTime, iChat, TelephonyUtilities,&lt;br&gt;
Freeform and that &lt;code&gt;localhost&lt;/code&gt; row. In exchange, five system rows do have an&lt;br&gt;
&lt;code&gt;auth&lt;/code&gt; field (the software-update notification and four Wi‑Fi rows). The two&lt;br&gt;
numbers matching here is a coincidence; they aren't counting the same thing.&lt;/p&gt;

&lt;p&gt;263 or 791 cannot be a value of a five-case enumeration. The field is probably&lt;br&gt;
a bit set packing the state together with the options, but "probably" is no&lt;br&gt;
basis for a technical claim. The practical upshot: &lt;strong&gt;the file is not an audit&lt;br&gt;
surface.&lt;/strong&gt; I can count the rows and verify the paths; I cannot read the state.&lt;/p&gt;

&lt;p&gt;Let me say why I'm drawing that line so firmly. If I set out to guess the bit&lt;br&gt;
meanings, I could look at a number and write "this app is allowed to show&lt;br&gt;
notifications on the lock screen." It would sound competent. And if it were&lt;br&gt;
wrong, nobody would catch it; no reader can read their own &lt;code&gt;auth&lt;/code&gt; value and&lt;br&gt;
correct me, because there's no document to check it against. That's exactly why&lt;br&gt;
I'm not writing it: an unverifiable claim looks more convincing than a&lt;br&gt;
verifiable one.&lt;/p&gt;

&lt;p&gt;Instead of filling in an undocumented field with guesses, I go back to the&lt;br&gt;
measurable question: what does this row point at, and does that thing exist?&lt;br&gt;
The file answers both plainly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;content_visibility&lt;/code&gt; field reads 0 on 63 rows and 3 on 31. The &lt;code&gt;grouping&lt;/code&gt;&lt;br&gt;
field is 0 on all 94. I don't know which pane option those numbers correspond&lt;br&gt;
to either.&lt;/p&gt;

&lt;p&gt;The only documented surface is the pane itself. Apple's macOS user guide lists&lt;br&gt;
what's on offer for an app: "Allow notifications," alert style, critical alerts,&lt;br&gt;
time-sensitive alerts, badge, sound, previews, grouping. Eight controls. The one thing missing from that list is the thing I was&lt;br&gt;
looking for: there is no option that &lt;strong&gt;removes&lt;/strong&gt; a row. The guide describes no&lt;br&gt;
such control, because there isn't one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiU29tZXRoaW5nIGFza3MgZm9yIHBlcm1pc3Npb24iXSAtLT4gQlsiQSByb3cgaXMgd3JpdHRlbjxici8-dG8gdGhlIGxlZGdlciJdCiAgQiAtLT4gQ1siSSB0dXJuIHRoZSBzd2l0Y2ggb2ZmPGJyLz5pbiB0aGUgcGFuZSJdCiAgQyAtLT4gRFsiTm8gbm90aWZpY2F0aW9ucyBhcnJpdmUsPGJyLz50aGUgcm93IHN0YXlzIHB1dCJdCiAgQiAtLT4gRVsiSSBkZWxldGUgdGhlIGFwcCJdCiAgRSAtLT4gRAogIEIgLS0-IEZbIlRoZSBvcmlnaW4gd2FzIGxvY2FsaG9zdCw8YnIvPnRoZSBzZXJ2ZXIgc2h1dCBkb3duIl0KICBGIC0tPiBECiAgRCAtLT4gR1siV2hvIGNsZWFucyB0aGUgbGVkZ2VyPyJdCiAgRyAtLT4gSFsiTm8gZG9jdW1lbnRlZDxici8-d2F5IHRvIHJlbW92ZSBhIHJvdyJd%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiU29tZXRoaW5nIGFza3MgZm9yIHBlcm1pc3Npb24iXSAtLT4gQlsiQSByb3cgaXMgd3JpdHRlbjxici8-dG8gdGhlIGxlZGdlciJdCiAgQiAtLT4gQ1siSSB0dXJuIHRoZSBzd2l0Y2ggb2ZmPGJyLz5pbiB0aGUgcGFuZSJdCiAgQyAtLT4gRFsiTm8gbm90aWZpY2F0aW9ucyBhcnJpdmUsPGJyLz50aGUgcm93IHN0YXlzIHB1dCJdCiAgQiAtLT4gRVsiSSBkZWxldGUgdGhlIGFwcCJdCiAgRSAtLT4gRAogIEIgLS0-IEZbIlRoZSBvcmlnaW4gd2FzIGxvY2FsaG9zdCw8YnIvPnRoZSBzZXJ2ZXIgc2h1dCBkb3duIl0KICBGIC0tPiBECiAgRCAtLT4gR1siV2hvIGNsZWFucyB0aGUgbGVkZ2VyPyJdCiAgRyAtLT4gSFsiTm8gZG9jdW1lbnRlZDxici8-d2F5IHRvIHJlbW92ZSBhIHJvdyJd%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="716" height="710"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The other direction: 44 of my 64 installed apps aren't listed
&lt;/h2&gt;

&lt;p&gt;Seeing this many accumulated rows, you might assume the ledger covers "every&lt;br&gt;
app." The opposite is true. I have 64 application bundles under&lt;br&gt;
&lt;code&gt;/Applications&lt;/code&gt;; only 20 of them appear in the notification ledger. Forty-four&lt;br&gt;
don't appear at all.&lt;/p&gt;

&lt;p&gt;Once it clicks, it's simple: this isn't an inventory of what's installed, it's&lt;br&gt;
&lt;strong&gt;a record of requests&lt;/strong&gt;. Only the things that asked once get in. Forty-four&lt;br&gt;
apps never asked: either they don't send notifications, or they haven't tried&lt;br&gt;
yet.&lt;/p&gt;

&lt;p&gt;Which is why reading the list as "what's on my machine" gives you a wrong&lt;br&gt;
answer in both directions: things that aren't there are listed, and most of the&lt;br&gt;
things that are there aren't. The two sets aren't even nested; they just&lt;br&gt;
overlap.&lt;/p&gt;

&lt;p&gt;One more detail: of the 67 rows carrying a &lt;code&gt;path&lt;/code&gt;, 41 point under &lt;code&gt;/System/&lt;/code&gt;, the&lt;br&gt;
apps that ship with the operating system. Only 25 sit under &lt;code&gt;/Applications/&lt;/code&gt;,&lt;br&gt;
among the apps I installed. So 41 of the 67 rows carrying a path, more than&lt;br&gt;
three-fifths, aren't my decisions at all; they're the machine's own defaults. Most of what I thought of as "my permission&lt;br&gt;
list" was never mine. That's also the discovery that most quickly drains your&lt;br&gt;
enthusiasm for reviewing it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Running the same measurement on your own machine
&lt;/h2&gt;

&lt;p&gt;Four commands are enough. None of them changes anything; they all just read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1) How many rows?&lt;/span&gt;
defaults &lt;span class="nb"&gt;export &lt;/span&gt;com.apple.ncprefs - | plutil &lt;span class="nt"&gt;-extract&lt;/span&gt; apps raw -

&lt;span class="c"&gt;# 2) Split the rows by type&lt;/span&gt;
python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import plistlib, os, collections
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
apps = plistlib.load(open(p, 'rb'))['apps']
t = collections.Counter(
    '_SYSTEM_CENTER_' if a['bundle-id'].startswith('_SYSTEM_CENTER_:')
    else '_WEB_CENTER_' if a['bundle-id'].startswith('_WEB_CENTER_:')
    else 'app' for a in apps)
print(len(apps), dict(t))
&lt;/span&gt;&lt;span class="no"&gt;PY

&lt;/span&gt;&lt;span class="c"&gt;# 3) Paths that aren't on disk&lt;/span&gt;
python3 - &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import plistlib, os
p = os.path.expanduser('~/Library/Preferences/com.apple.ncprefs.plist')
for a in plistlib.load(open(p, 'rb'))['apps']:
    if 'path' in a and not os.path.exists(a['path']):
        print('ORPHAN:', a['bundle-id'], '-&amp;gt;', a['path'])
&lt;/span&gt;&lt;span class="no"&gt;PY

&lt;/span&gt;&lt;span class="c"&gt;# 4) When was the ledger last written?&lt;/span&gt;
&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"%Sm"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="s2"&gt;"%Y-%m-%d %H:%M"&lt;/span&gt; ~/Library/Preferences/com.apple.ncprefs.plist
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fourth command's output tells you the most, I think. If the date is recent,&lt;br&gt;
your machine is still accumulating new permissions. If it's very old, thirteen&lt;br&gt;
months ago like mine, the ledger has frozen, but it's still carrying your old&lt;br&gt;
decisions where it froze.&lt;/p&gt;

&lt;p&gt;A warning: this file is not a documented interface, it's an implementation&lt;br&gt;
detail. Its structure can change in any macOS release. Reading it is safe, but&lt;br&gt;
don't &lt;strong&gt;write&lt;/strong&gt; to it by hand; &lt;code&gt;cfprefsd&lt;/code&gt; will overwrite from its in-memory&lt;br&gt;
state and you'll be left with an inconsistent setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm going to do about it
&lt;/h2&gt;

&lt;p&gt;The honest answer: very little, and I'm choosing that deliberately.&lt;/p&gt;

&lt;p&gt;There's nothing to be done about the three unremovable rows: the pane offers no&lt;br&gt;
delete, and touching the file by hand isn't a good idea. The Minecraft row will&lt;br&gt;
stay where it is. It does no harm either: with no app behind it, it can't&lt;br&gt;
produce a notification. With one caveat: since the permission is bound to a&lt;br&gt;
signature, if I ever reinstall a signed Minecraft I won't be asked again. That&lt;br&gt;
row isn't harmless, it's dormant.&lt;/p&gt;

&lt;p&gt;Two things are real work for me. The first is that &lt;code&gt;localhost&lt;/code&gt; permission: next&lt;br&gt;
time I'm testing notifications on a development server, I'll revoke the&lt;br&gt;
permission from the browser's site settings when the test is done. The&lt;br&gt;
revocation mechanism exists there, the Push API defines it; I just never used&lt;br&gt;
it. The second is that device token: knowing that tokens sit next to permissions&lt;br&gt;
is reason enough for me to think about where this file goes in my backups.&lt;/p&gt;

&lt;p&gt;Beyond that, this measurement left me with a habit. When I grant permission I&lt;br&gt;
think "I'll turn this off later" — but turning it off doesn't delete the row. I&lt;br&gt;
ran into the same thing with data left on disk after&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/18-uygulamayi-sildim-661-mib-veri-diskte-kaldi/" rel="noopener noreferrer"&gt;deleting apps&lt;/a&gt;:&lt;br&gt;
I removed eighteen apps and 661 MiB of data stayed behind. Background jobs look&lt;br&gt;
similar:&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/technology/launchd-21881-kez-denedi-ben-bir-kez-bakmadim/" rel="noopener noreferrer"&gt;launchd has been retrying for years&lt;/a&gt;&lt;br&gt;
and I never once watched it.&lt;/p&gt;

&lt;p&gt;Same pattern in all three: removal removes the visible part and leaves the&lt;br&gt;
record.&lt;/p&gt;

&lt;p&gt;It would be easy to present that as a defect, but I don't think it is one.&lt;br&gt;
Binding the row to a signature is the right design decision, and not checking&lt;br&gt;
the path is the consistent consequence of it. Nobody wrote a garbage collector&lt;br&gt;
that sweeps up notification rows for deleted apps, because that row harms&lt;br&gt;
nobody. From the engineering side there's nothing to object to.&lt;/p&gt;

&lt;p&gt;What bothers me isn't the engineering, it's the bookkeeping. When I flip a&lt;br&gt;
switch off in the pane, I think "right, that's settled." It isn't: the decision&lt;br&gt;
is recorded, the record is permanent, and the record itself is never shown to&lt;br&gt;
me. Using a machine for years means carrying an archive of decisions you don't&lt;br&gt;
remember making. Nobody shows you the archive; nobody promised to, either.&lt;/p&gt;

&lt;p&gt;The lesson I'm taking: being careful when you grant is cheaper than revoking&lt;br&gt;
later. The revocation mechanism often doesn't exist, and when it does, as with&lt;br&gt;
web permissions, using it is left to you. The ledger itself is thirty&lt;br&gt;
kilobytes, written thirteen months ago, still in force. And it'll stay that way until&lt;br&gt;
something writes it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/usernotifications/unauthorizationstatus" rel="noopener noreferrer"&gt;Apple — UNAuthorizationStatus&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/Security/blob/main/OSX/libsecurity_codesigning/lib/requirement.h" rel="noopener noreferrer"&gt;Apple OSS — Security, requirement.h (0xfade0c00)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/usernotifications/unnotificationsettings" rel="noopener noreferrer"&gt;Apple — UNNotificationSettings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/mac-help/change-notifications-settings-mh40583/mac" rel="noopener noreferrer"&gt;Apple Support — Change notifications settings on Mac&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.w3.org/TR/push-api/" rel="noopener noreferrer"&gt;W3C — Push API&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.mozilla.org/en-US/docs/Web/API/Notification/requestPermission_static" rel="noopener noreferrer"&gt;MDN — Notification.requestPermission()&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>notification</category>
      <category>mahremiyet</category>
      <category>olcum</category>
    </item>
    <item>
      <title>54 of My Apps Carry Intel Code; Two of Them Run It</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 24 Oct 2026 11:38:43 +0000</pubDate>
      <link>https://dev.to/merbayerp/54-of-my-apps-carry-intel-code-two-of-them-run-it-1n7c</link>
      <guid>https://dev.to/merbayerp/54-of-my-apps-carry-intel-code-two-of-them-run-it-1n7c</guid>
      <description>&lt;p&gt;"Is this app Intel?" has a ready answer in macOS: select the app in Finder, hit&lt;br&gt;
Get Info, read the "Kind" line. It will say "Application (Universal)", or&lt;br&gt;
"Application (Intel)", or "Application (Apple silicon)". A good enough answer&lt;br&gt;
for a user. My question was a little different: &lt;strong&gt;how many bytes&lt;/strong&gt; of Intel code&lt;br&gt;
sit inside that bundle? The Get Info window doesn't say.&lt;/p&gt;

&lt;p&gt;So I sat down to count. Across the 64 app bundles under &lt;code&gt;/Applications&lt;/code&gt; I walked&lt;br&gt;
601,358 files, 6,149 of which turned out to be Mach-O binaries, and I read the&lt;br&gt;
header of every one of them byte by byte. Here is the tally: 54 app bundles&lt;br&gt;
contain x86_64 code, &lt;strong&gt;6.95 GiB&lt;/strong&gt; of it in total. This machine is an Apple M4&lt;br&gt;
Pro, so it cannot execute Intel code natively. With two exceptions.&lt;/p&gt;

&lt;p&gt;I didn't start out to reclaim disk space, but by the end I had something more&lt;br&gt;
interesting than a cleanup plan: the thing we call "the transition is done" is&lt;br&gt;
not done inside the binaries. And the largest share of it isn't where I can&lt;br&gt;
delete it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Anatomy of a fat file
&lt;/h2&gt;

&lt;p&gt;A universal binary is one file that holds the complete code of more than one&lt;br&gt;
architecture. Apple's transition guide says so plainly, and it asks developers&lt;br&gt;
to turn not only apps but "all of your compiled code" universal: plug-ins,&lt;br&gt;
custom frameworks, dynamic libraries, command-line tools, daemons. That is why&lt;br&gt;
2,286 of the 2,533 fat files on my disk hold both an Intel and an ARM slice.&lt;br&gt;
(In that count ARM means &lt;code&gt;arm64&lt;/code&gt; and &lt;code&gt;arm64e&lt;/code&gt; only; two old Xcode archives that&lt;br&gt;
carry &lt;code&gt;arm&lt;/code&gt; plus &lt;code&gt;i386&lt;/code&gt; plus &lt;code&gt;x86_64&lt;/code&gt; fall outside it.)&lt;/p&gt;

&lt;p&gt;The format is defined in &lt;code&gt;fat.h&lt;/code&gt; inside &lt;code&gt;cctools&lt;/code&gt;. The file opens with the magic&lt;br&gt;
number &lt;code&gt;0xcafebabe&lt;/code&gt;, followed by a count of slices, then one record per slice&lt;br&gt;
with its architecture code, its offset in the file and its length. One note in&lt;br&gt;
that header matters: these structures are &lt;strong&gt;always&lt;/strong&gt; written big-endian, so if&lt;br&gt;
you read them in your machine's own byte order you get nonsense.&lt;/p&gt;

&lt;p&gt;The same header describes a second format: when a slice is larger than 4 MB&lt;br&gt;
&lt;strong&gt;or&lt;/strong&gt; a slice's offset within the file passes 4 MB, the 64-bit layout with&lt;br&gt;
&lt;code&gt;0xcafebabf&lt;/code&gt; takes over. My script recognizes both magic numbers. It found not a&lt;br&gt;
single one of the second kind: all 2,533 fat files on this disk are&lt;br&gt;
&lt;code&gt;0xcafebabe&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why that is, &lt;code&gt;fat.h&lt;/code&gt; says in its own words — the 64-bit format is flagged as "a&lt;br&gt;
work in progress and not yet fully supported in all the Apple Developer Tools".&lt;br&gt;
The condition is written down and not applied. The counter-example is in this&lt;br&gt;
article's own diagram:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgQVtmYXRfaGVhZGVyPGJyLz5jYWZlYmFiZTxici8-MiBzbGljZXNdIC0tPiBCW3NsaWNlIDA8YnIvPng4Nl82NDxici8-MjcxLDE2NCw5MTIgYnl0ZXNdCiAgQSAtLT4gQ1tzbGljZSAxPGJyLz5hcm02NDxici8-MjQ4LDM4OCw3NTIgYnl0ZXNdCiAgQiAtLT4gRFtNNCBQcm8gbmV2ZXI8YnIvPmV4ZWN1dGVzIHRoaXNdCiAgQyAtLT4gRVtNNCBQcm8gcnVuczxici8-dGhpcyBvbmVd%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IExSCiAgQVtmYXRfaGVhZGVyPGJyLz5jYWZlYmFiZTxici8-MiBzbGljZXNdIC0tPiBCW3NsaWNlIDA8YnIvPng4Nl82NDxici8-MjcxLDE2NCw5MTIgYnl0ZXNdCiAgQSAtLT4gQ1tzbGljZSAxPGJyLz5hcm02NDxici8-MjQ4LDM4OCw3NTIgYnl0ZXNdCiAgQiAtLT4gRFtNNCBQcm8gbmV2ZXI8YnIvPmV4ZWN1dGVzIHRoaXNdCiAgQyAtLT4gRVtNNCBQcm8gcnVuczxici8-dGhpcyBvbmVd%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="600" height="270"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Those numbers come from Google Chrome's framework file. Of 519,576,720 bytes,&lt;br&gt;
271,164,912 — 52.2 percent — is Intel. The second slice starts at byte&lt;br&gt;
271,187,968, so both the size and the offset are sixty times the threshold, and&lt;br&gt;
the layout is still 32-bit. More than half of Chrome's framework is code that&lt;br&gt;
will never run on this machine.&lt;/p&gt;

&lt;p&gt;The inspection command in Apple's documentation is this one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;% lipo &lt;span class="nt"&gt;-archs&lt;/span&gt; /System/Applications/Mail.app/Contents/MacOS/Mail
x86_64 arm64
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the same command on this machine today gives a slightly different&lt;br&gt;
answer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;% lipo &lt;span class="nt"&gt;-archs&lt;/span&gt; /System/Applications/Mail.app/Contents/MacOS/Mail
x86_64 arm64e
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;arm64e&lt;/code&gt; is the ARM slice with pointer authentication enabled; Apple compiles&lt;br&gt;
its own system binaries that way. The doc prints &lt;code&gt;arm64&lt;/code&gt;, the machine says&lt;br&gt;
&lt;code&gt;arm64e&lt;/code&gt;. Same mechanism, mismatched label. I can't claim the document was once&lt;br&gt;
right and went stale — no record of mine shows that; all I can say is that today&lt;br&gt;
the example and the machine disagree. Which is a fair warning about every sample&lt;br&gt;
output you copy and paste.&lt;/p&gt;
&lt;h2&gt;
  
  
  The tally
&lt;/h2&gt;

&lt;p&gt;I added up the slices of all 6,149 Mach-O files by architecture. The slices come&lt;br&gt;
to 23.78 GiB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Architecture&lt;/th&gt;
&lt;th&gt;Uncompressed&lt;/th&gt;
&lt;th&gt;Compressed&lt;/th&gt;
&lt;th&gt;Total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;arm64&lt;/td&gt;
&lt;td&gt;12,242.8 MiB&lt;/td&gt;
&lt;td&gt;4,750.6 MiB&lt;/td&gt;
&lt;td&gt;16,993.4 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;x86_64&lt;/td&gt;
&lt;td&gt;5,769.6 MiB&lt;/td&gt;
&lt;td&gt;1,349.3 MiB&lt;/td&gt;
&lt;td&gt;7,118.9 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;arm64e&lt;/td&gt;
&lt;td&gt;0.1 MiB&lt;/td&gt;
&lt;td&gt;175.5 MiB&lt;/td&gt;
&lt;td&gt;175.6 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;arm64_32&lt;/td&gt;
&lt;td&gt;0.0 MiB&lt;/td&gt;
&lt;td&gt;62.5 MiB&lt;/td&gt;
&lt;td&gt;62.5 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;arm&lt;/td&gt;
&lt;td&gt;0.0 MiB&lt;/td&gt;
&lt;td&gt;2.2 MiB&lt;/td&gt;
&lt;td&gt;2.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;i386&lt;/td&gt;
&lt;td&gt;0.0 MiB&lt;/td&gt;
&lt;td&gt;0.45 MiB&lt;/td&gt;
&lt;td&gt;0.45 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One row is missing from that table: a 0.9 MiB entry whose architecture number my&lt;br&gt;
script did not recognize. It gets its own section below. The &lt;code&gt;arm64_32&lt;/code&gt; row was&lt;br&gt;
in the same state — the script printed &lt;code&gt;cpu33554444&lt;/code&gt; and I named it by hand: the&lt;br&gt;
watchOS architecture, and all 62.5 MiB of it comes from inside Xcode. I own no&lt;br&gt;
watch, but I do own a watch development environment. &lt;code&gt;arm&lt;/code&gt; is 32-bit ARM: 2.2 MiB&lt;br&gt;
of an older world, still here.&lt;/p&gt;

&lt;p&gt;The Intel share (x86_64 plus i386) is 6.95 GiB, 29.2 percent of all slice bytes.&lt;br&gt;
Here is the denominator too: the whole &lt;code&gt;/Applications&lt;/code&gt; folder is 54.29 GiB, so&lt;br&gt;
Intel code is 12.8 percent of my applications. One byte in eight.&lt;/p&gt;

&lt;p&gt;Split by main executable, the picture is this: of 64 apps, 24 are ARM-only, 38&lt;br&gt;
carry both ARM and Intel, and 2 are Intel-only. Look at the whole bundle and the&lt;br&gt;
number climbs to 54, because an app with a clean main binary can still ship a fat&lt;br&gt;
helper library inside. Only 10 apps are entirely free of Intel code.&lt;/p&gt;

&lt;p&gt;The eight heaviest Intel loads:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;App&lt;/th&gt;
&lt;th&gt;Intel slices&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Google Chrome&lt;/td&gt;
&lt;td&gt;965.9 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OneDrive&lt;/td&gt;
&lt;td&gt;685.0 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Outlook&lt;/td&gt;
&lt;td&gt;510.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Teams&lt;/td&gt;
&lt;td&gt;499.7 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xcode&lt;/td&gt;
&lt;td&gt;448.9 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Word&lt;/td&gt;
&lt;td&gt;441.1 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft PowerPoint&lt;/td&gt;
&lt;td&gt;427.9 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Excel&lt;/td&gt;
&lt;td&gt;392.4 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Chrome's figure is inflated by one more thing: three separate versions of its&lt;br&gt;
framework sit on disk (154.0.8037.98, 155.0.8059.85, 155.0.8059.86), each around&lt;br&gt;
495 MiB. It is reasonable for a browser update not to delete the old version&lt;br&gt;
immediately — you may need to roll back. Still, three versions times half-Intel&lt;br&gt;
gives you pause.&lt;/p&gt;
&lt;h2&gt;
  
  
  First correction: what the file says is not what the disk holds
&lt;/h2&gt;

&lt;p&gt;At this point it was tempting to write "6.95 GiB wasted" and move on. I'm glad I&lt;br&gt;
ran one more check: a file's logical size and the blocks actually allocated to it&lt;br&gt;
are different numbers. The logical total of those 6,149 Mach-O files is 23.84&lt;br&gt;
GiB; the allocated blocks add up to 19.93 GiB. (The 56 MiB gap against the slice&lt;br&gt;
total is fat headers plus the alignment padding between slices; &lt;code&gt;fat.h&lt;/code&gt; notes&lt;br&gt;
that padding may be stored as holes where the filesystem supports them.) So&lt;br&gt;
where did the missing 3.9 GiB go?&lt;/p&gt;

&lt;p&gt;Into filesystem compression. 2,333 files have the &lt;code&gt;UF_COMPRESSED&lt;/code&gt; flag set, and&lt;br&gt;
their 6.21 GiB of logical content occupies 2.29 GiB of blocks on disk. So the&lt;br&gt;
accurate sentence is this: &lt;strong&gt;5.63 GiB&lt;/strong&gt; of the Intel slices live in uncompressed&lt;br&gt;
files, byte for byte on disk; the remaining 1.32 GiB sit inside compressed files&lt;br&gt;
and take up less room there. Applying the compression ratio from above to that&lt;br&gt;
1.32 GiB gives roughly 0.5 GiB — a derived figure, not a counted one, and I'm&lt;br&gt;
flagging it as such.&lt;/p&gt;

&lt;p&gt;I've fallen into this trap before: asking "how much free space is there" five&lt;br&gt;
different ways on the same disk produced a&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/diskimde-ne-kadar-yer-var-bes-cevap-13-gib-fark/" rel="noopener noreferrer"&gt;13.5 GiB spread&lt;/a&gt;.&lt;br&gt;
When someone quotes me a single number about a machine now, my reflex is to ask&lt;br&gt;
what that number is counting.&lt;/p&gt;
&lt;h2&gt;
  
  
  Second correction: cafebabe doesn't belong to Mach-O alone
&lt;/h2&gt;

&lt;p&gt;The table also had a row labelled &lt;code&gt;cputype 16777239&lt;/code&gt; — 0.93 MiB, apparently&lt;br&gt;
irrelevant. I chased it anyway, because writing off something your tool doesn't&lt;br&gt;
understand means writing off the other places where the tool is wrong.&lt;/p&gt;

&lt;p&gt;The files turned out to be &lt;code&gt;.metallib&lt;/code&gt; bundles inside Xcode: GPU shader&lt;br&gt;
libraries. And here is the interesting part — &lt;code&gt;lipo&lt;/code&gt; refuses to call them&lt;br&gt;
Mach-O, while &lt;code&gt;file&lt;/code&gt; does not.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;% xxd &lt;span class="nt"&gt;-l&lt;/span&gt; 16 .../CoreImage.metallib
00000000: cafe babe 0000 0002 0100 0017 0000 0008

% lipo &lt;span class="nt"&gt;-archs&lt;/span&gt; .../CoreImage.metallib
lipo: not a mach-o &lt;span class="s1"&gt;'.../CoreImage.metallib'&lt;/span&gt;

% file .../CoreImage.metallib      &lt;span class="c"&gt;# shortened&lt;/span&gt;
Mach-O universal binary with 2 architectures: &lt;span class="o"&gt;[&lt;/span&gt;air64_v24:...] &lt;span class="o"&gt;[&lt;/span&gt;air64:...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same file, same magic number, two tools with two answers. The fat container is&lt;br&gt;
not reserved for CPU code; Apple uses it for GPU code too, and &lt;code&gt;air64&lt;/code&gt; slices&lt;br&gt;
live under the same header. My byte-level script counted a file the tool had&lt;br&gt;
rejected. The error is 0.93 MiB, so the table survives — but it taught me not to&lt;br&gt;
see &lt;code&gt;0xcafebabe&lt;/code&gt; and conclude "Mach-O" on the next pass. That same magic number&lt;br&gt;
is also the signature of Java class files; OpenJDK's class file parser&lt;br&gt;
identifies a file by those exact four bytes. I found no &lt;code&gt;.class&lt;/code&gt; file in these&lt;br&gt;
bundles, so that wasn't what polluted my table this time. It could have been.&lt;/p&gt;
&lt;h2&gt;
  
  
  Apple's own share, and a surprise of scale
&lt;/h2&gt;

&lt;p&gt;Everything so far might read like a choice third-party developers made. So I ran&lt;br&gt;
the same count under &lt;code&gt;/System/Applications&lt;/code&gt;, across Apple's own 46 apps. 11,221&lt;br&gt;
files, 201 binaries, 0.63 GiB of slices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;arm64e&lt;/code&gt;: 330.2 MiB (51.1 percent)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;x86_64&lt;/code&gt;: 315.4 MiB (48.9 percent)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;45 of the 46 apps carry Intel code. Mail, Notes, Music, Preview, Terminal — all&lt;br&gt;
of them &lt;code&gt;x86_64 arm64e&lt;/code&gt;. The heaviest are Books (46.3 MiB of Intel), Freeform&lt;br&gt;
(45.5 MiB) and Music (36.7 MiB). Which also means the 54 in the title is scoped&lt;br&gt;
to &lt;code&gt;/Applications&lt;/code&gt;: the number of apps on this machine carrying Intel code is&lt;br&gt;
really 99.&lt;/p&gt;

&lt;p&gt;But calling 315 MiB "Apple's share" got the scale badly wrong, because system&lt;br&gt;
apps are thin shells: the code mass lives in the shared cache. So I looked&lt;br&gt;
there:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File set&lt;/th&gt;
&lt;th&gt;Logical&lt;/th&gt;
&lt;th&gt;Blocks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dyld_shared_cache_x86_64*&lt;/code&gt; (9 files)&lt;/td&gt;
&lt;td&gt;5.35 GiB&lt;/td&gt;
&lt;td&gt;2.08 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;dyld_shared_cache_arm64e*&lt;/code&gt; (15 files)&lt;/td&gt;
&lt;td&gt;5.42 GiB&lt;/td&gt;
&lt;td&gt;1.91 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aot_shared_cache.0..6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5.73 GiB&lt;/td&gt;
&lt;td&gt;2.17 GiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That directory shows up under two paths, but &lt;code&gt;stat&lt;/code&gt; reports the same device and&lt;br&gt;
inode for both: one copy, two doors. And the root volume is sealed —&lt;br&gt;
&lt;code&gt;diskutil apfs list&lt;/code&gt; says &lt;code&gt;Sealed: Yes&lt;/code&gt;, and &lt;code&gt;mount&lt;/code&gt; reports &lt;code&gt;/dev/disk3s1s1 on&lt;br&gt;
/ (apfs, sealed, local, read-only, journaled)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Add it up and the picture inverts. Intel code I could delete: 6.95 GiB. Intel&lt;br&gt;
code I cannot: 315.4 MiB plus 5.35 GiB, or &lt;strong&gt;5.66 GiB&lt;/strong&gt; logical, around 2.4 GiB&lt;br&gt;
in blocks. The two are nearly the same weight. I came into this with a thesis&lt;br&gt;
about third-party developers bloating my disk; the count talked me out of it.&lt;br&gt;
This isn't one developer being careless, it's the platform's distribution model.&lt;/p&gt;
&lt;h2&gt;
  
  
  Can it be removed? An experiment
&lt;/h2&gt;

&lt;p&gt;Curiosity turned practical: what happens to a bundle's signature if I thin it?&lt;br&gt;
Apple's &lt;code&gt;ditto&lt;/code&gt; has a documented flag for exactly this, and the man page puts it&lt;br&gt;
in one line: "Thin Universal binaries to the specified architecture."&lt;/p&gt;

&lt;p&gt;I ran the experiment on a small notarized app (Shottr, 6,780 KiB):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;% ditto &lt;span class="nt"&gt;--arch&lt;/span&gt; arm64 /Applications/Shottr.app ./Shottr-thin.app
% &lt;span class="nb"&gt;du&lt;/span&gt; &lt;span class="nt"&gt;-sk&lt;/span&gt; ./Shottr-thin.app
3852    ./Shottr-thin.app

% lipo &lt;span class="nt"&gt;-archs&lt;/span&gt; ./Shottr-thin.app/Contents/MacOS/Shottr
arm64

% codesign &lt;span class="nt"&gt;-v&lt;/span&gt; ./Shottr-thin.app &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0

% spctl &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="nt"&gt;-vv&lt;/span&gt; ./Shottr-thin.app
./Shottr-thin.app: accepted
&lt;span class="nb"&gt;source&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Notarized Developer ID
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bundle went from 6,780 KiB to 3,852 KiB, 43.2 percent smaller. The signature&lt;br&gt;
verified, Gatekeeper accepted it, notarization held. So each architecture slice&lt;br&gt;
carries its own signature, and dropping one does not invalidate the other.&lt;/p&gt;

&lt;p&gt;Let me state the limits of that experiment, because it is a narrow one: I never&lt;br&gt;
&lt;strong&gt;launched&lt;/strong&gt; the thinned copy, I ran &lt;code&gt;codesign&lt;/code&gt; without &lt;code&gt;--deep --strict&lt;/code&gt;, and I&lt;br&gt;
never tried a large bundle with frameworks of its own inside. What it proves is&lt;br&gt;
that one notarized bundle's signature chain survived thinning — not that this&lt;br&gt;
works everywhere.&lt;/p&gt;

&lt;p&gt;Beyond that, I'm not putting it forward as a way to reclaim disk space, and I&lt;br&gt;
haven't kept it in place. The first update brings the universal bundle right&lt;br&gt;
back, so the space you won is rented. What an app's own updater does with a&lt;br&gt;
thinned bundle is a separate question mark — a tool that ships delta updates may&lt;br&gt;
not recognize it. And the thinnable part isn't actually 6.95 GiB: 6,673.1 MiB of&lt;br&gt;
those Intel bytes live in fat files, but 446.2 MiB sit in 533 single-architecture&lt;br&gt;
files, where dropping the code means dropping the app. The two biggest of those&lt;br&gt;
are the two Intel-only apps themselves.&lt;/p&gt;

&lt;p&gt;In short, on a 460 GiB disk with 125 GiB free, the prize doesn't earn a weekend.&lt;br&gt;
Counting was instructive; cleaning wouldn't have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  The date that actually matters
&lt;/h2&gt;

&lt;p&gt;I said "with two exceptions" at the top. Those two — an old build of a media&lt;br&gt;
player (VLC 3.0.23) and a serial-port tool — carry Intel code only, so when they&lt;br&gt;
launch they run inside the Rosetta translation environment.&lt;/p&gt;

&lt;p&gt;Let me be honest here: I never watched a running process. My evidence is a&lt;br&gt;
property of the binaries — these two bundles have no ARM slice, so they cannot&lt;br&gt;
run without translation. And "two" isn't a property of the machine, only the&lt;br&gt;
count under the defaults: per Apple's documentation a user can force any&lt;br&gt;
universal app to open under Rosetta from the Get Info window, and a developer can&lt;br&gt;
reorder the preference with the &lt;code&gt;LSArchitecturePriority&lt;/code&gt; key. The Intel slice in&lt;br&gt;
those 38 universal apps isn't dead; it's asleep.&lt;/p&gt;

&lt;p&gt;Rosetta is installed (&lt;code&gt;/Library/Apple/usr/libexec/oah&lt;/code&gt; is in place) and &lt;code&gt;pkgutil&lt;/code&gt;&lt;br&gt;
dates the package's install time to 1 September 2026 — an OS update can refresh&lt;br&gt;
that stamp too, so I'm not reading it as "the day I installed it by hand".&lt;/p&gt;

&lt;p&gt;How the translation actually works is in Apple's platform security&lt;br&gt;
documentation, and reading it made me correct my first draft. There are two&lt;br&gt;
paths. At runtime, the kernel spots an x86_64 Mach object and hands control to a&lt;br&gt;
Rosetta translation stub rather than to the dynamic linker, and pages are&lt;br&gt;
translated inside the process. On the ahead-of-time path, every Mach object goes&lt;br&gt;
through the AOT pipeline while macOS is being built — but what lands in the trust&lt;br&gt;
cache is not translated code, it is a &lt;strong&gt;hash&lt;/strong&gt;: three code directory hashes per&lt;br&gt;
object (arm64, x86_64, and the AOT translation of x86_64). Apple states outright&lt;br&gt;
that "the actual translated products don't ship with the operating system";&lt;br&gt;
translation is deterministic, so the products are reconstituted on demand, signed&lt;br&gt;
with a device-specific key managed by the Secure Enclave and kept in a vault only&lt;br&gt;
the Rosetta service can reach.&lt;/p&gt;

&lt;p&gt;The table above has an &lt;code&gt;aot_shared_cache&lt;/code&gt; row: 5.73 GiB logical, 2.17 GiB in&lt;br&gt;
blocks, with file dates matching the OS build. From the outside I can't tell&lt;br&gt;
whether it arrived with the installer or was produced during installation; what I&lt;br&gt;
can tell is that it is on my disk right now.&lt;/p&gt;

&lt;p&gt;And this is where the calendar arrives. Apple announced on 1 September 2026:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;macOS 27: Final release to support Rosetta — Intel-only apps will no longer&lt;br&gt;
run on Mac computers with Apple silicon after this update.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The support page says the same thing in user language: Rosetta is available on&lt;br&gt;
macOS 27 and earlier; from the next major release it remains only for certain&lt;br&gt;
older, unmaintained games that rely on Intel-based frameworks. Note the timing:&lt;br&gt;
macOS 27 Golden Gate shipped on 14 September 2026 and supports Apple silicon Macs&lt;br&gt;
only. So the &lt;strong&gt;last&lt;/strong&gt; release to carry Rosetta is already out; this machine is on&lt;br&gt;
26.6.2, one major release behind. My two Intel-only apps don't have a distant&lt;br&gt;
expiry date — it is one release away.&lt;/p&gt;

&lt;p&gt;What strikes me is that the two numbers pull in opposite directions. The code&lt;br&gt;
under calendar pressure amounts to &lt;strong&gt;2 apps&lt;/strong&gt;. The code taking up disk space&lt;br&gt;
lives inside &lt;strong&gt;54 apps&lt;/strong&gt; and weighs 6.95 GiB. The second never runs, so Rosetta's&lt;br&gt;
retirement won't touch it; it will simply stay there. The troublesome part is&lt;br&gt;
small, and the heavy part travels quietly.&lt;/p&gt;

&lt;p&gt;A natural question follows, and I don't know the answer: when Rosetta goes, do&lt;br&gt;
the x86_64 slices go too? Will macOS 28 drop the x86_64 shared cache from the&lt;br&gt;
sealed volume, will developers stop building universal, or will this 6.95 GiB be&lt;br&gt;
dragged along for years? All Apple has said so far is that the translation ends;&lt;br&gt;
nothing has been promised about what the binaries contain. The carve-out for&lt;br&gt;
games even suggests some x86_64 plumbing stays in place.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to count your own machine
&lt;/h2&gt;

&lt;p&gt;The Get Info window is a fine start, but it says nothing about the helper&lt;br&gt;
binaries inside a bundle. If you plan to go deeper, mind four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't stop at the main binary.&lt;/strong&gt; The file under &lt;code&gt;Contents/MacOS/&lt;/code&gt; may be
clean; the weight sits in frameworks, plug-ins and helper processes. In my
count the gap was between 38 and 54.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate logical size from block count.&lt;/strong&gt; &lt;code&gt;stat&lt;/code&gt; hands you both numbers; if
you don't know which one you're reporting, a 16 percent error follows you
around.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't treat every &lt;code&gt;0xcafebabe&lt;/code&gt; file as Mach-O.&lt;/strong&gt; Compare &lt;code&gt;lipo -archs&lt;/code&gt;
against &lt;code&gt;file&lt;/code&gt;; when one refuses, look at what the other says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't stop at the applications folder.&lt;/strong&gt; The real mass is in the shared
cache and that volume is sealed; a number without its scope points at the
wrong culprit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Finally, ask this: is anything actually running this code? For me the answer was&lt;br&gt;
two. What sits in the other fifty-four bundles isn't load, it's luggage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's left
&lt;/h2&gt;

&lt;p&gt;On the user's side the Apple silicon transition started in 2020 and looks long&lt;br&gt;
finished; apps open, they run fast, nobody asks about architecture. Look inside&lt;br&gt;
the binaries, though, and the transition hasn't ended, it has only gone&lt;br&gt;
invisible. Carrying two architectures in one file is a clever compatibility&lt;br&gt;
answer; the bill for that answer is split across every user's disk, and nobody&lt;br&gt;
itemizes it.&lt;/p&gt;

&lt;p&gt;The thing that stayed with me isn't the 6.95 GiB. It's how many times I reached&lt;br&gt;
that number from the wrong place: the first count gave what the file says, the&lt;br&gt;
second what the disk holds, the third what the tool got wrong, and the fourth&lt;br&gt;
showed the real mass was never where I was looking. Someone quoting a single&lt;br&gt;
number about their own machine usually doesn't know which question that number&lt;br&gt;
answers. When I counted&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/158-kok-sertifikaya-guveniyorum-ucunu-kendim-ekledim/" rel="noopener noreferrer"&gt;the root certificate store&lt;/a&gt;&lt;br&gt;
I ended up in the same place: once you start measuring, the work stops being&lt;br&gt;
about finding the number and becomes about finding out what the number counts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/apple-silicon/building-a-universal-macos-binary" rel="noopener noreferrer"&gt;Apple — Building a universal macOS binary&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/news/?id=w5ngl9k2" rel="noopener noreferrer"&gt;Apple Developer News — Upcoming changes to Rosetta support for Intel-based macOS apps&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/en-us/102527" rel="noopener noreferrer"&gt;Apple Support — Using Intel-based apps on a Mac with Apple silicon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/security/rosetta-2-on-a-mac-with-apple-silicon-secebb113be1/web" rel="noopener noreferrer"&gt;Apple Platform Security — Rosetta 2 on a Mac with Apple silicon&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/apple-oss-distributions/cctools/blob/main/include/mach-o/fat.h" rel="noopener noreferrer"&gt;Apple Open Source — cctools, mach-o/fat.h&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openjdk/jdk/blob/master/src/hotspot/share/classfile/classFileParser.cpp" rel="noopener noreferrer"&gt;OpenJDK — classFileParser.cpp&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>applesilicon</category>
      <category>olcum</category>
      <category>disk</category>
    </item>
    <item>
      <title>My Path Check Caught All Six Escapes, Then Let 24,378 Through</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Sat, 24 Oct 2026 06:14:27 +0000</pubDate>
      <link>https://dev.to/merbayerp/my-path-check-caught-all-six-escapes-then-let-24378-through-49eo</link>
      <guid>https://dev.to/merbayerp/my-path-check-caught-all-six-escapes-then-let-24378-through-49eo</guid>
      <description>&lt;p&gt;Taking a filename from a user and opening it inside your own directory is one of the oldest jobs in software. Archive extractors do it, file servers do it, even template engines do it. And nearly everyone writes the same two lines: join the path onto the root, resolve it with &lt;code&gt;realpath&lt;/code&gt;, verify the result starts with the root. If it doesn't, reject.&lt;/p&gt;

&lt;p&gt;In mid-October I went through &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/ilgisiz-bir-yeniden-adlandirma-bes-acisimdan-birini-dusurdu/" rel="noopener noreferrer"&gt;the behaviour of &lt;code&gt;openat2&lt;/code&gt; itself&lt;/a&gt; in a separate article, and I ran this race there too: the classic pattern that calls &lt;code&gt;fstatat&lt;/code&gt; and then &lt;code&gt;openat&lt;/code&gt; returned the attacker's file on 30.7% of its successful opens inside a 50 µs window. So I already know "check then open" is broken — that part is not new.&lt;/p&gt;

&lt;p&gt;This article asks something else: &lt;strong&gt;which check actually gets written in the field, and what does it do?&lt;/strong&gt; Few people write the &lt;code&gt;fstatat&lt;/code&gt; pattern by hand; what everyone writes is &lt;code&gt;realpath&lt;/code&gt; plus a prefix comparison. And that check looks spotless in a static test — which is probably why nobody replaces it.&lt;/p&gt;

&lt;p&gt;I tried six defenses against nine paths. In the static table &lt;code&gt;realpath&lt;/code&gt; came out spotless — it stopped all six escapes. Then I ran the same check while the directory structure moved underneath it: on &lt;strong&gt;24,378&lt;/strong&gt; of 200,000 turns it read the file outside the cage. In that same race, &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt; returned zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;Measurements were taken on this blog's own server: Ubuntu 24.04, kernel 6.8.0-147-generic, 18 vCPUs, gcc 13.3.0. All of it as an unprivileged user inside a temporary directory from &lt;code&gt;mktemp -d&lt;/code&gt;; nothing persistent on the server was touched.&lt;/p&gt;

&lt;p&gt;The cage: &lt;code&gt;kok/&lt;/code&gt; is the area we permit, &lt;code&gt;disari/&lt;/code&gt; is what must not be read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kok/ok.txt          -&amp;gt; &lt;span class="s2"&gt;"KAFES-ICI"&lt;/span&gt;
kok/sub/ok2.txt     -&amp;gt; &lt;span class="s2"&gt;"KAFES-ICI2"&lt;/span&gt;
kok/ici      -&amp;gt; sub/ok2.txt        &lt;span class="c"&gt;# symlink that stays inside (legitimate)&lt;/span&gt;
kok/mutlak   -&amp;gt; /etc/hostname      &lt;span class="c"&gt;# absolute symlink&lt;/span&gt;
kok/goreli   -&amp;gt; ../disari/sir.txt  &lt;span class="c"&gt;# relative escape&lt;/span&gt;
kok/atla     -&amp;gt; ../disari          &lt;span class="c"&gt;# escape to a directory&lt;/span&gt;
kok/sihir    -&amp;gt; /proc/self/root    &lt;span class="c"&gt;# magic link&lt;/span&gt;
disari/sir.txt      -&amp;gt; &lt;span class="s2"&gt;"DISARIDAKI-SIR"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of the nine paths are &lt;strong&gt;legitimate&lt;/strong&gt; — they have to be accepted. I put that distinction in deliberately, because how many escapes a defense stops says nothing on its own; a function that rejects everything also stops all six. The real question is whether it does both jobs.&lt;/p&gt;

&lt;p&gt;The legitimate three: &lt;code&gt;ok.txt&lt;/code&gt; (plain file), &lt;code&gt;sub/../ok.txt&lt;/code&gt; (a &lt;code&gt;..&lt;/code&gt; that turns back inside), &lt;code&gt;ici&lt;/code&gt; (a symlink that stays in the cage). The six escapes: &lt;code&gt;../disari/sir.txt&lt;/code&gt;, &lt;code&gt;/etc/hostname&lt;/code&gt;, &lt;code&gt;mutlak&lt;/code&gt;, &lt;code&gt;goreli&lt;/code&gt;, &lt;code&gt;atla/sir.txt&lt;/code&gt;, &lt;code&gt;sihir/etc/hostname&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six defenses
&lt;/h2&gt;

&lt;p&gt;First, the filter everyone writes once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* D1: reject if it contains ".." */&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strstr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".."&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;errno&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EACCES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;openat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dfd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;O_RDONLY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, the check serious code writes — the real defendant here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* D2: realpath + prefix comparison */&lt;/span&gt;
&lt;span class="n"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"%s/%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;realpath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;size_t&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strncmp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ROOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'/'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;rl&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sc"&gt;'\0'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;errno&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EACCES&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resolved&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;O_RDONLY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;resolved[rl]&lt;/code&gt; test matters: without it, &lt;code&gt;/srv/data-gizli&lt;/code&gt; passes a &lt;code&gt;/srv/data&lt;/code&gt; prefix. A common bug, but not this article's subject; the version above is the correct one.&lt;/p&gt;

&lt;p&gt;Third, the route taken by people who say "I turned symlinks off": &lt;code&gt;openat(dfd, path, O_RDONLY | O_NOFOLLOW)&lt;/code&gt;. The remaining three use &lt;code&gt;openat2&lt;/code&gt;: &lt;code&gt;RESOLVE_BENEATH&lt;/code&gt;, &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;, and &lt;code&gt;RESOLVE_IN_ROOT | RESOLVE_NO_MAGICLINKS | RESOLVE_NO_XDEV&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The static table
&lt;/h2&gt;

&lt;p&gt;With the directory structure held still, 6 × 9 = 54 opens:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;th&gt;Escapes blocked&lt;/th&gt;
&lt;th&gt;Legitimate opened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;D1 &lt;code&gt;..&lt;/code&gt; filter&lt;/td&gt;
&lt;td&gt;1/6&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D2 realpath + prefix&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3/3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D3 &lt;code&gt;openat&lt;/code&gt; + &lt;code&gt;O_NOFOLLOW&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2/6&lt;/td&gt;
&lt;td&gt;2/3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D4 &lt;code&gt;RESOLVE_BENEATH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3/3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D5 &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3/3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D6 &lt;code&gt;IN_ROOT + NOMAGIC + NOXDEV&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6/6&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3/3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Since a summary can mislead, here is the raw table too — which error code came from where is about to matter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ok.txt&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;sub/../ok.txt&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;ici&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;../disari/sir.txt&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;/etc/hostname&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;mutlak&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;goreli&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;atla/sir.txt&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;sihir/...&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;D1 &lt;code&gt;..&lt;/code&gt; filter&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D2 realpath + prefix&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EACCES&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D3 &lt;code&gt;O_NOFOLLOW&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ELOOP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ELOOP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ELOOP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OPEN&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D4 &lt;code&gt;BENEATH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXDEV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXDEV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXDEV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXDEV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXDEV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXDEV&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D5 &lt;code&gt;IN_ROOT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D6 &lt;code&gt;IN_ROOT+NOMAGIC+NOXDEV&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;OPEN&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENOENT&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every bold &lt;code&gt;OPEN&lt;/code&gt; is a leak: in that cell the program read the contents of a file outside the cage.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;..&lt;/code&gt; filter loses at both ends. It stops only one of six escapes — because there is no &lt;code&gt;..&lt;/code&gt; inside a symlink, and none in the absolute path &lt;code&gt;/etc/hostname&lt;/code&gt; either. Meanwhile it rejects the legitimate &lt;code&gt;sub/../ok.txt&lt;/code&gt; with &lt;code&gt;EACCES&lt;/code&gt;. So it lets the attacker through and annoys the user. Managing that combination is genuinely hard.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;O_NOFOLLOW&lt;/code&gt; keeps its promise, but the promise is small: it protects &lt;strong&gt;only the final component&lt;/strong&gt;. &lt;code&gt;mutlak&lt;/code&gt; and &lt;code&gt;goreli&lt;/code&gt; are symlinks themselves, so they get &lt;code&gt;ELOOP&lt;/code&gt; — but &lt;code&gt;atla/sir.txt&lt;/code&gt;, where &lt;code&gt;atla&lt;/code&gt; is a symlink to a directory, opens cleanly and reads the secret outside. &lt;code&gt;O_NOFOLLOW&lt;/code&gt; is not a scoping flag; once again, something the manual never hides, yet it keeps getting used as scoping in real code. On top of that it drops the legitimate &lt;code&gt;ici&lt;/code&gt; link with &lt;code&gt;ELOOP&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That the last two rows look alike but return different error codes is no accident either — &lt;code&gt;BENEATH&lt;/code&gt; says &lt;code&gt;EXDEV&lt;/code&gt;, &lt;code&gt;IN_ROOT&lt;/code&gt; says &lt;code&gt;ENOENT&lt;/code&gt;. I will come back to that difference shortly, because it is the most misleading part of the table.&lt;/p&gt;

&lt;p&gt;And here is the discomfort in the middle of it: &lt;strong&gt;D2 produces exactly the same result as the &lt;code&gt;openat2&lt;/code&gt; flags.&lt;/strong&gt; Six escapes closed, three legitimate paths open. Anyone reading this far would say "so &lt;code&gt;realpath&lt;/code&gt; is enough," and they would not be unreasonable. The table says they are right.&lt;/p&gt;

&lt;p&gt;Because the table is incomplete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real test
&lt;/h2&gt;

&lt;p&gt;All 54 opens above happened in a directory tree where nothing moved. In production, directory trees move. Your archive extractor gets another request into the same directory while writing a file; a backup process relocates something; or none of that happens and the person on the other side moves the directory on purpose.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;realpath&lt;/code&gt; and &lt;code&gt;open&lt;/code&gt; are two separate calls. There is a moment between them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fc2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBVIGFzIEF0dGFja2VyCiAgICBwYXJ0aWNpcGFudCBQIGFzIFByb2dyYW0KICAgIHBhcnRpY2lwYW50IEsgYXMgS2VybmVsCiAgICBQLT4-SzogcmVhbHBhdGgoImtvay9hbGFuL2hlZGVmLnR4dCIpCiAgICBLLS0-PlA6ICIva29rL2FsYW4vaGVkZWYudHh0IgogICAgUC0-PlA6IHByZWZpeCBjaGVjayBQQVNTRUQKICAgIFUtPj5LOiByZW5hbWVhdDIoYWxhbiwgLnllbSwgUkVOQU1FX0VYQ0hBTkdFKQogICAgTm90ZSBvdmVyIFUsSzogImFsYW4iIGlzIG5vdyBhIHN5bWxpbmsgdG8gLi4vZGlzYXJpCiAgICBQLT4-Szogb3BlbigiL2tvay9hbGFuL2hlZGVmLnR4dCIpCiAgICBLLS0-PlA6IHRoZSBmaWxlIG91dHNpZGU%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2Fc2VxdWVuY2VEaWFncmFtCiAgICBwYXJ0aWNpcGFudCBVIGFzIEF0dGFja2VyCiAgICBwYXJ0aWNpcGFudCBQIGFzIFByb2dyYW0KICAgIHBhcnRpY2lwYW50IEsgYXMgS2VybmVsCiAgICBQLT4-SzogcmVhbHBhdGgoImtvay9hbGFuL2hlZGVmLnR4dCIpCiAgICBLLS0-PlA6ICIva29rL2FsYW4vaGVkZWYudHh0IgogICAgUC0-PlA6IHByZWZpeCBjaGVjayBQQVNTRUQKICAgIFUtPj5LOiByZW5hbWVhdDIoYWxhbiwgLnllbSwgUkVOQU1FX0VYQ0hBTkdFKQogICAgTm90ZSBvdmVyIFUsSzogImFsYW4iIGlzIG5vdyBhIHN5bWxpbmsgdG8gLi4vZGlzYXJpCiAgICBQLT4-Szogb3BlbigiL2tvay9hbGFuL2hlZGVmLnR4dCIpCiAgICBLLS0-PlA6IHRoZSBmaWxlIG91dHNpZGU%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="711" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To measure that window I wrote a second probe and a separate layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kok/alan/hedef.txt  -&amp;gt; &lt;span class="s2"&gt;"KAFES-ICI-HEDEF"&lt;/span&gt;
kok/.yem     -&amp;gt; ../disari       &lt;span class="c"&gt;# the symlink to be swapped in&lt;/span&gt;
disari/hedef.txt    -&amp;gt; &lt;span class="s2"&gt;"DISARIDAKI-SIR"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main thread keeps trying to open the same path; a side thread keeps &lt;strong&gt;atomically&lt;/strong&gt; swapping the &lt;code&gt;alan&lt;/code&gt; directory with a symlink pointing outside, using &lt;code&gt;renameat2(RENAME_EXCHANGE)&lt;/code&gt;. The atomic swap is critical here: there is no half-finished state, so what the numbers below show is not an implementation bug but a structural hole in the check itself.&lt;/p&gt;

&lt;p&gt;The whole rig is this much — the part you would copy to try it in your own code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* side thread: atomically swap the directory with a symlink */&lt;/span&gt;
&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="nf"&gt;takasci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;dur&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__NR_renameat2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dfd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"alan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;dfd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;".yem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RENAME_EXCHANGE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;takas&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="cm"&gt;/* main loop: open the same path with both defenses, read the content */&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"alan/hedef.txt"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="cm"&gt;/* realpath + prefix */&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;a2&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizdi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;s2&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;h2&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"alan/hedef.txt"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="cm"&gt;/* RESOLVE_IN_ROOT */&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;a5&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sizdi&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="n"&gt;s5&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;h5&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sizdi()&lt;/code&gt; reads from the opened descriptor and checks whether the content starts with &lt;code&gt;DISARIDAKI&lt;/code&gt;. So the counter is not counting "the open succeeded" but &lt;strong&gt;the wrong file's contents actually reached my hands&lt;/strong&gt;. My first probe did not draw that distinction and reported the rate higher than it was.&lt;/p&gt;

&lt;p&gt;I ran both D2 and D5 in the same run, 200,000 turns:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Defense&lt;/th&gt;
&lt;th&gt;Opened&lt;/th&gt;
&lt;th&gt;Errors&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Leaked outside&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;D2 realpath + prefix&lt;/td&gt;
&lt;td&gt;90,901&lt;/td&gt;
&lt;td&gt;109,099&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;24,378&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D5 &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;88,565&lt;/td&gt;
&lt;td&gt;111,435&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The swap counter read 314,729 over the same period.&lt;/p&gt;

&lt;p&gt;24,378 is 12.2% of all attempts. But that is not the ratio to look at: of the 90,901 files D2 &lt;strong&gt;successfully opened&lt;/strong&gt;, 26.8% were outside the cage. So after the check said "fine, this path is safe," roughly one open in four returned the wrong file.&lt;/p&gt;

&lt;p&gt;This is not an estimate or a theoretical window; I read the contents, and they said &lt;code&gt;DISARIDAKI-SIR&lt;/code&gt;. 24,378 times.&lt;/p&gt;

&lt;p&gt;On the &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt; side the number is zero. The reason is simple, and that is exactly why it holds: in &lt;code&gt;openat2&lt;/code&gt; the check and the open are not separate steps. The kernel validates the scope while it resolves the path, and never hands control back to you in between. There is no window to miss.&lt;/p&gt;

&lt;p&gt;Look at D5's 111,435 errors as well — more than D2's 109,099. There is a cost here and no point hiding it: opens that land on the swap moment return errors, and some of them get &lt;code&gt;EAGAIN&lt;/code&gt; from scope ambiguity. I did not break the errors down, so I cannot say how many of those 111,435 were &lt;code&gt;ENOENT&lt;/code&gt; and how many &lt;code&gt;EAGAIN&lt;/code&gt;. &lt;code&gt;EAGAIN&lt;/code&gt; got its own measurement in the October piece: under a synthetic load generating thousands of renames per second it reached one open in five — that figure is not a ceiling, and it is not a rate measured on a real server either. Either way, moving to &lt;code&gt;openat2&lt;/code&gt; is not free security but a trade that requires you to write a bounded retry budget. On the other side of that trade sit 24,378 leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  IN_ROOT does not reject, it remaps
&lt;/h2&gt;

&lt;p&gt;The October article's table has a row where opening &lt;code&gt;/etc/hostname&lt;/code&gt; with &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt; returned &lt;code&gt;ENOENT&lt;/code&gt;. I recorded the result but never showed &lt;strong&gt;why&lt;/strong&gt; it was &lt;code&gt;ENOENT&lt;/code&gt; — and that is exactly where the two flags part ways.&lt;/p&gt;

&lt;p&gt;It was &lt;code&gt;ENOENT&lt;/code&gt; because there was no directory named &lt;code&gt;etc&lt;/code&gt; inside the cage. That was the easy case.&lt;/p&gt;

&lt;p&gt;I put &lt;code&gt;etc/hostname&lt;/code&gt; in the cage and tried again. The server's real &lt;code&gt;/etc/hostname&lt;/code&gt; reads &lt;code&gt;mustafaerbay-vps3&lt;/code&gt;; into the fake copy inside the cage I wrote &lt;code&gt;SAHTE-HOSTNAME-KAFESTEN&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESOLVE_BENEATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/etc/hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DENIED &lt;code&gt;EXDEV&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/etc/hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;OPEN&lt;/strong&gt; → &lt;code&gt;SAHTE-HOSTNAME-KAFESTEN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESOLVE_BENEATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;../../etc/hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DENIED &lt;code&gt;EXDEV&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;../../etc/hostname&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;OPEN&lt;/strong&gt; → &lt;code&gt;SAHTE-HOSTNAME-KAFESTEN&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two flags do not do the same job. &lt;code&gt;BENEATH&lt;/code&gt;, in the manual's words, does not permit resolution to leave the directory given by &lt;code&gt;dirfd&lt;/code&gt;, and treats the attempt as an &lt;strong&gt;error&lt;/strong&gt;. &lt;code&gt;IN_ROOT&lt;/code&gt; treats &lt;code&gt;dirfd&lt;/code&gt; as the root — so it does not reject the escape, it &lt;strong&gt;remaps it inward&lt;/strong&gt;. Whatever &lt;code&gt;chroot(2)&lt;/code&gt; does, but for a single open.&lt;/p&gt;

&lt;p&gt;So that October &lt;code&gt;ENOENT&lt;/code&gt; did not mean "you cannot leave"; the kernel was saying it looked for &lt;code&gt;kok/etc/hostname&lt;/code&gt; and did not find it. The moment you put that file in the cage, the same call returns a file instead of an error.&lt;/p&gt;

&lt;p&gt;In practice this means the following. If you are writing an archive extractor, &lt;code&gt;IN_ROOT&lt;/code&gt; is exactly what you want: a &lt;code&gt;/etc/passwd&lt;/code&gt; entry inside a tar quietly becomes &lt;code&gt;kok/etc/passwd&lt;/code&gt;, which is how container images work. But if your case is "the user gives me a path inside my data directory," &lt;code&gt;IN_ROOT&lt;/code&gt; is dangerously quiet: when the user asks for &lt;code&gt;/etc/hostname&lt;/code&gt; they do not get an error, they get &lt;strong&gt;a different file&lt;/strong&gt;. If an attacker can drop files into the cage — which is precisely what they can do in an archive extractor — they can redirect any path they like to their own content.&lt;/p&gt;

&lt;p&gt;My preference: &lt;code&gt;BENEATH&lt;/code&gt; for APIs that take a path from the user. I want the error. Not a silently different file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cage does not know what it holds
&lt;/h2&gt;

&lt;p&gt;There is also a dimension scoping takes no interest in. I put a FIFO into the cage with &lt;code&gt;mkfifo&lt;/code&gt; and opened it with &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RESOLVE_IN_ROOT   boru     -&amp;gt; OPEN (type=FIFO)
RESOLVE_IN_ROOT   ok.txt   -&amp;gt; OPEN (type=regular file)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cage held — the pipe really is inside it. But had I not written &lt;code&gt;O_NONBLOCK&lt;/code&gt;, the program would be hanging there waiting for a writer. Scoping flags check &lt;strong&gt;where&lt;/strong&gt; you went, not &lt;strong&gt;what&lt;/strong&gt; you opened. Confining the path to the cage does not mean you opened a regular file.&lt;/p&gt;

&lt;p&gt;The kernel has an answer for this, a new one: &lt;code&gt;OPENAT2_REGULAR&lt;/code&gt;. The patch written by Dorjoy Chowdhury and taken by Christian Brauner on 21 May 2026 returns &lt;code&gt;EFTYPE&lt;/code&gt; when the path is not a regular file. I could not find the flag in v7.1's header; it is there in v7.2 — and today's mainline is v7.3. On my own 6.8 I got &lt;code&gt;EINVAL&lt;/code&gt;, as expected: the old kernel does not know the flag and says so rather than ignoring it silently. That distinction and the full list of error codes got their own section in the October piece; I am not repeating it here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and what to do below 5.6
&lt;/h2&gt;

&lt;p&gt;I expected the safe option to be the expensive one. The numbers said the opposite. I opened a four-level path (&lt;code&gt;a/b/c/d/f.txt&lt;/code&gt;) 300,000 times, after a warm-up round, in two separate runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Run 1&lt;/th&gt;
&lt;th&gt;Run 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openat&lt;/code&gt; (unscoped)&lt;/td&gt;
&lt;td&gt;7,311 ns&lt;/td&gt;
&lt;td&gt;7,131 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;realpath + prefix&lt;/td&gt;
&lt;td&gt;32,579 ns&lt;/td&gt;
&lt;td&gt;33,244 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESOLVE_BENEATH&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7,705 ns&lt;/td&gt;
&lt;td&gt;7,833 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7,720 ns&lt;/td&gt;
&lt;td&gt;7,307 ns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;realpath&lt;/code&gt; is roughly &lt;strong&gt;4.5 times&lt;/strong&gt; more expensive, and the reason is unsurprising: it resolves the path fully once, then makes the kernel resolve the same path a second time. Two full path resolutions plus an &lt;code&gt;lstat&lt;/code&gt; per component.&lt;/p&gt;

&lt;p&gt;Quoting one number for the scoping flags would be wrong, because the two flags do not sit in the same place. Unscoped &lt;code&gt;openat&lt;/code&gt; wanders between 7,131 and 7,311. &lt;code&gt;IN_ROOT&lt;/code&gt; wanders between 7,307 and 7,720 — the ranges overlap by four nanoseconds, so I cannot claim a difference there. &lt;code&gt;BENEATH&lt;/code&gt;, though, runs 7,705 to 7,833: both runs sit above the unscoped ceiling, roughly 7%. That is a consistent gap, but I have two runs; it is not enough data to write "&lt;code&gt;BENEATH&lt;/code&gt; is seven percent slower." What I can say: &lt;strong&gt;the cost of the scoping flags is at most a few percent, and in this measurement &lt;code&gt;BENEATH&lt;/code&gt; looks slightly more expensive than &lt;code&gt;IN_ROOT&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Either way, the trade was not "security or speed" as I assumed. The leaky defense turned out to be the expensive one by a wide margin.&lt;/p&gt;

&lt;p&gt;On portability I have to be straight: &lt;code&gt;openat2&lt;/code&gt; arrived in 5.6, that is March 2020. On kernels below it — or outside Linux — these flags do not exist, and you have to walk the path component by component with &lt;code&gt;O_NOFOLLOW&lt;/code&gt; at every step; that is what userspace libraries do. I did not measure that route, so I will not claim it gives equivalent protection.&lt;/p&gt;

&lt;p&gt;There is a trap here that is easy to skip: the kernel version is not enough. If you run inside a container, the &lt;strong&gt;seccomp profile&lt;/strong&gt; may not know &lt;code&gt;openat2&lt;/code&gt; — a known situation on older Docker and libseccomp versions — and the call comes back &lt;code&gt;EPERM&lt;/code&gt; or &lt;code&gt;ENOSYS&lt;/code&gt;. So base your capability detection on a real probe at startup rather than a version number: call it once, fall back to emulation on &lt;code&gt;ENOSYS&lt;/code&gt;/&lt;code&gt;EPERM&lt;/code&gt;, and make that fallback increment a counter rather than happen silently. That is the only way you notice the machine with a new kernel and an old profile.&lt;/p&gt;

&lt;p&gt;You do not have to write the emulation yourself either: &lt;code&gt;os.Root&lt;/code&gt; in Go's standard library and &lt;code&gt;filepath-securejoin&lt;/code&gt; cover it on the Go side, &lt;code&gt;libpathrs&lt;/code&gt; on the C and Rust side. Whichever you pick, if you provide scoping through emulation in your own code, run the race probe above against that emulation too. My &lt;code&gt;realpath&lt;/code&gt; check also looked correct when it was written.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code outside the cage
&lt;/h2&gt;

&lt;p&gt;Finally, something none of this article's measurements cover: the path you open with &lt;code&gt;openat2&lt;/code&gt; is safe, but your program does not only open files.&lt;/p&gt;

&lt;p&gt;There is a live example. &lt;code&gt;CVE-2026-41579&lt;/code&gt; (CVSS 3.3, low), closed in runc in June 2026, is exactly this: the advisory says the mount-target lookup "correctly scopes the lookup inside the container." What opens the hole is not the lookup but the &lt;code&gt;os.Remove&lt;/code&gt; and &lt;code&gt;os.Symlink&lt;/code&gt; calls in &lt;code&gt;setupPtmx&lt;/code&gt; and &lt;code&gt;setupDevSymlinks&lt;/code&gt;, made on strings built with &lt;code&gt;filepath.Join&lt;/code&gt;. If the image's &lt;code&gt;/dev&lt;/code&gt; is a symlink, files named &lt;code&gt;ptmx&lt;/code&gt; on the host can be deleted — and on the second arm, six fixed-name symlinks (&lt;code&gt;core&lt;/code&gt;, &lt;code&gt;fd&lt;/code&gt;, &lt;code&gt;ptmx&lt;/code&gt;, &lt;code&gt;stdin&lt;/code&gt;, &lt;code&gt;stdout&lt;/code&gt;, &lt;code&gt;stderr&lt;/code&gt;) can be created in an existing host directory. Docker is not affected because it masks &lt;code&gt;/dev&lt;/code&gt; with a read-only layer; podman and containerd are. Fixed in 1.3.6, 1.4.3 and 1.5.0-rc.3.&lt;/p&gt;

&lt;p&gt;So a scoped open is a component, not a policy. You need to resolve the path once with &lt;code&gt;openat2&lt;/code&gt; and then continue &lt;strong&gt;through the descriptor&lt;/strong&gt;: &lt;code&gt;unlinkat&lt;/code&gt;, &lt;code&gt;symlinkat&lt;/code&gt;, &lt;code&gt;fstatat&lt;/code&gt; all take a &lt;code&gt;dirfd&lt;/code&gt;. Every line that joins strings and returns to a path leaves the guarantee you earned right there. The title of the runc fix says as much: make the &lt;code&gt;/dev&lt;/code&gt; setup descriptor-based.&lt;/p&gt;

&lt;p&gt;In practice that means this transformation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/* BEFORE: the open is scoped, what follows is not */&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys_openat2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dfd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;how&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;      &lt;span class="cm"&gt;/* safe */&lt;/span&gt;
&lt;span class="n"&gt;snprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;sizeof&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"%s/%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;unlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;                                  &lt;span class="cm"&gt;/* NOT safe */&lt;/span&gt;

&lt;span class="cm"&gt;/* AFTER: continue through the descriptor */&lt;/span&gt;
&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;dir&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sys_openat2&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dfd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dirname_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;how_dir&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="cm"&gt;/* O_PATH|O_DIRECTORY */&lt;/span&gt;
&lt;span class="n"&gt;unlinkat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;basename_of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the second version there is no second path resolution; &lt;code&gt;unlinkat&lt;/code&gt; starts from a descriptor whose scope is already validated. Reduced to one sentence: &lt;strong&gt;resolve the path once, then never return to the path.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  A decision frame
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Do not write a &lt;code&gt;..&lt;/code&gt; filter.&lt;/strong&gt; In the measurement it stopped one escape of six and rejected a legitimate path. It cannot do either job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not mistake &lt;code&gt;O_NOFOLLOW&lt;/code&gt; for scoping.&lt;/strong&gt; It protects only the final component; everything under a symlink to a directory walks right through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;realpath&lt;/code&gt; + prefix is correct on a static tree, not in a race.&lt;/strong&gt; If you are certain the tree is yours and nobody moves it, it can stay. If you are not certain, the measured rate is 26.8%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Taking a path from the user? &lt;code&gt;RESOLVE_BENEATH&lt;/code&gt;.&lt;/strong&gt; You want the escape as an error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extracting an archive or image? &lt;code&gt;RESOLVE_IN_ROOT&lt;/code&gt;.&lt;/strong&gt; You want the escape remapped inward — but know that this is a deliberate choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not pass both scoping flags&lt;/strong&gt;, you will get &lt;code&gt;EINVAL&lt;/code&gt;. Close magic links separately with &lt;code&gt;RESOLVE_NO_MAGICLINKS&lt;/code&gt;; the manual says the scoping flags do this for now and that it may change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write a bounded budget for &lt;code&gt;EAGAIN&lt;/code&gt;&lt;/strong&gt; — three to five attempts — and if it runs out, reject the request and record it in a separate counter. Never retrying produces spurious failures when a neighbour is busy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the file type separately.&lt;/strong&gt; Before v7.2 with &lt;code&gt;fstat&lt;/code&gt;; from v7.2 on with &lt;code&gt;OPENAT2_REGULAR&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not return to strings after the open.&lt;/strong&gt; Use the &lt;code&gt;*at&lt;/code&gt; family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hold your root descriptor somewhere and think about its lifetime.&lt;/strong&gt; Opening a root per request burns descriptors; holding one long-lived means the root itself can be &lt;code&gt;rename&lt;/code&gt;d or &lt;code&gt;umount&lt;/code&gt;ed from under you. I prefer keeping the root as a long-lived &lt;code&gt;O_PATH&lt;/code&gt; descriptor and checking periodically that it is still the object I expect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not use cost as the excuse.&lt;/strong&gt; In the measurement the scoping flags differ by at most a few percent; the expensive one is the &lt;code&gt;realpath&lt;/code&gt; version (4.5 times).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;In October I showed that &lt;code&gt;openat2&lt;/code&gt; closes the window between the check and the open. What this measurement taught me is where that window hides best: &lt;code&gt;realpath&lt;/code&gt; plus a prefix comparison gave the right answer on all 54 opens. A static test suite will never go red on it — a single-threaded test assumes the world holds still while it runs. And the defense that looked best in the table turned out to be the most expensive one.&lt;/p&gt;

&lt;p&gt;The second lesson is about &lt;code&gt;IN_ROOT&lt;/code&gt;, and it struck me as the quieter one: it does not stop the escape, it remaps it inward. For anyone who can drop files into the cage, that means getting the file they want instead of an error. In code that takes a path from the user, what I want is the &lt;code&gt;EXDEV&lt;/code&gt; that &lt;code&gt;BENEATH&lt;/code&gt; gives me.&lt;/p&gt;

&lt;p&gt;Put together, what I am left with is this: a check returning the right answer is not enough — there has to be nobody between the check and the action. What &lt;code&gt;openat2&lt;/code&gt; gives you is not a stricter rule; it is the absence of that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/plain/include/uapi/linux/openat2.h" rel="noopener noreferrer"&gt;include/uapi/linux/openat2.h — RESOLVE_* and OPENAT2_REGULAR definitions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=8b82cacad92ebae9619872a5a69c570eba30140b" rel="noopener noreferrer"&gt;openat2: new OPENAT2_REGULAR flag support — kernel commit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mkerrisk/man-pages/blob/master/man2/openat2.2" rel="noopener noreferrer"&gt;openat2(2) manual page — RESOLVE_BENEATH and RESOLVE_IN_ROOT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/opencontainers/runc/security/advisories/GHSA-xjvp-4fhw-gc47" rel="noopener noreferrer"&gt;runc security advisory GHSA-xjvp-4fhw-gc47 (CVE-2026-41579)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mkerrisk/man-pages/blob/master/man2/open.2" rel="noopener noreferrer"&gt;open(2) manual page — O_NOFOLLOW affects only the final component&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>security</category>
      <category>openat2</category>
      <category>olcum</category>
    </item>
    <item>
      <title>I Documented 29 Keys, Three Systems Lean on One Token</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:09:06 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-documented-29-keys-three-systems-lean-on-one-token-2ekf</link>
      <guid>https://dev.to/merbayerp/i-documented-29-keys-three-systems-lean-on-one-token-2ekf</guid>
      <description>&lt;p&gt;This blog's repository contains a file called &lt;code&gt;.env.example&lt;/code&gt;. Its job is to tell anyone installing the site from scratch: fill in these keys and the rest works. Next to it sits a setup guide stating that you need, at minimum, four AI keys, both Turnstile values and an admin token. Together they make a promise: if this list is complete, the system stands up.&lt;/p&gt;

&lt;p&gt;When the file came up for an unrelated task, one line in it did not immediately ring a bell. The question that stuck was the reverse one: is every key the code reads actually written in this file?&lt;/p&gt;

&lt;p&gt;You cannot settle whether a template is incomplete by remembering. You have to count. I counted in three places and got three different numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The count: 29, 97, 14
&lt;/h2&gt;

&lt;p&gt;The first number comes from the document. &lt;code&gt;.env.example&lt;/code&gt; defines 29 distinct keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^[A-Z][A-Z0-9_]*='&lt;/span&gt; .env.example | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/=.*//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# 29&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second comes from the code itself. These are the distinct names read through &lt;code&gt;process.env&lt;/code&gt; or &lt;code&gt;import.meta.env&lt;/code&gt; under &lt;code&gt;src/&lt;/code&gt; and &lt;code&gt;scripts/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s1"&gt;'(process\.env|import\.meta\.env)\.[A-Z][A-Z0-9_]*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.ts'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.mjs'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.astro'&lt;/span&gt; src scripts &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'s/.*env\.//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="c"&gt;# 97&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The third comes from the live server. The process serving the blog reads &lt;code&gt;/etc/mustafaerbay.env&lt;/code&gt;, and 14 keys are set there. I did not look at any value — only the names at the start of each line.&lt;/p&gt;

&lt;p&gt;29, 97, 14. Three sources that each claim to describe the configuration of the same system, and no two of them agree.&lt;/p&gt;

&lt;h2&gt;
  
  
  97 is a floor, not a set
&lt;/h2&gt;

&lt;p&gt;An admission has to come next, because this piece argues "do not remember, count" and the counter itself is faulty. The &lt;code&gt;grep&lt;/code&gt; above only sees the form &lt;code&gt;process.env.NAME&lt;/code&gt;. If the code reads a variable any other way, it never enters the count.&lt;/p&gt;

&lt;p&gt;Two shapes escape it. The first is optional chaining: in &lt;code&gt;src/lib/runtime.ts&lt;/code&gt; the Turnstile secret is read as &lt;code&gt;process.env?.TURNSTILE_SECRET&lt;/code&gt;, and that question mark keeps it out of the pattern. The second is a name computed at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;envKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lang&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ME_VOICE_ID_TR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ME_VOICE_ID_EN&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;envKey&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;scripts/lib/elevenlabs.ts&lt;/code&gt; reads three voice IDs this way: &lt;code&gt;ASKER_VOICE_ID&lt;/code&gt;, &lt;code&gt;ME_VOICE_ID_TR&lt;/code&gt; and &lt;code&gt;ME_VOICE_ID_EN&lt;/code&gt;. None of the three is in the template, none has a fallback value, and each throws at runtime if absent. They belong to precisely the category this article cares about most, and none of them made it into the count.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;scripts/veo-prompt.ts&lt;/code&gt; picks up a provider key by the same dynamic method, but there it does no harm: the three names it reads (&lt;code&gt;GROQ_API_KEY&lt;/code&gt;, &lt;code&gt;CEREBRAS_API_KEY&lt;/code&gt;, &lt;code&gt;OPENROUTER_API_KEY&lt;/code&gt;) are both in the template and read plainly in other files, so they are already inside the count. A dynamic read is not automatically a blind spot; the blind spot appears when a dynamically read name occurs plainly nowhere else.&lt;/p&gt;

&lt;p&gt;Widening the pattern to cover the question mark turns 97 into 98. Add the three dynamically read voice IDs and it is at least 101. "At least," because finding those leaks also came down to reading by hand.&lt;/p&gt;

&lt;p&gt;Writing this off as a footnote would suit me. It cannot be: a recommendation to "derive the contract from the code" is coming later, and a check step that implements it literally would silently pass the exact class of bug it was built to catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What those 73 names actually are
&lt;/h2&gt;

&lt;p&gt;By the narrow count, 73 of the 97 names in the code are missing from &lt;code&gt;.env.example&lt;/code&gt;. Leaving the number there would have been easy — write "73 undocumented settings" and close the tab. It would also be dishonest.&lt;/p&gt;

&lt;p&gt;Four of them are not mine to write. &lt;code&gt;GITHUB_OUTPUT&lt;/code&gt; and &lt;code&gt;GITHUB_STEP_SUMMARY&lt;/code&gt; come from the workflow runner, &lt;code&gt;NODE_ENV&lt;/code&gt; from the runtime, and &lt;code&gt;ONLY_SLUG&lt;/code&gt; is the slug parameter handed to the social posting workflow. They do not belong in the list.&lt;/p&gt;

&lt;p&gt;That leaves 69 that someone could plausibly set. Most are harmless tuning knobs: &lt;code&gt;FAQ_SLEEP_MS&lt;/code&gt;, &lt;code&gt;AI_RATE_LIMIT_BREATH_MS&lt;/code&gt;, &lt;code&gt;MAX_POSTS_PER_RUN&lt;/code&gt; and friends, each with a sane default in code, and the system behaves identically if you never touch them. Leaving those out is not a gap but deliberate restraint: put 97 lines in a setup file and nobody reads any of them.&lt;/p&gt;

&lt;p&gt;Then I separated the ones shaped like credentials. A pattern matching names that end in &lt;code&gt;API_KEY&lt;/code&gt;, &lt;code&gt;TOKEN&lt;/code&gt;, &lt;code&gt;SECRET&lt;/code&gt;, &lt;code&gt;PASSWORD&lt;/code&gt; or &lt;code&gt;SALT&lt;/code&gt; returns nine. Three more I added by hand although the pattern misses them — &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; (ends in &lt;code&gt;_KEY&lt;/code&gt;), &lt;code&gt;GEMINI_API_KEYS&lt;/code&gt; (plural) and &lt;code&gt;YOUTUBE_CLIENT_ID&lt;/code&gt; (ends in &lt;code&gt;_ID&lt;/code&gt;). So the real rule is "suffix match plus judgement," and the total is 12:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BLUESKY_APP_PASSWORD&lt;/code&gt; · &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; · &lt;code&gt;VISITOR_HASH_SALT&lt;/code&gt; · &lt;code&gt;YOUTUBE_CLIENT_ID&lt;/code&gt; · &lt;code&gt;YOUTUBE_CLIENT_SECRET&lt;/code&gt; · &lt;code&gt;YOUTUBE_REFRESH_TOKEN&lt;/code&gt; · &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; · &lt;code&gt;MISTRAL_API_KEY&lt;/code&gt; · &lt;code&gt;GEMINI_API_KEYS&lt;/code&gt; · &lt;code&gt;BURNCPU_API_TOKEN&lt;/code&gt; · &lt;code&gt;GH_API_TOKEN&lt;/code&gt; · &lt;code&gt;LINKEDIN_ACCESS_TOKEN&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;These twelve do not carry equal weight. Some make noise when absent: the path that calls Mistral directly says &lt;code&gt;if (!key) throw new Error('MISTRAL_API_KEY missing')&lt;/code&gt;, and the Bluesky endpoint throws without its password. Noise is good — you find out. The dangerous ones continue in silence, and there are exactly two: &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; and &lt;code&gt;VISITOR_HASH_SALT&lt;/code&gt;. When either is missing the code falls through to another value — which one, shortly.&lt;/p&gt;

&lt;h2&gt;
  
  
  One credential, two names
&lt;/h2&gt;

&lt;p&gt;Line 89 of &lt;code&gt;.env.example&lt;/code&gt; asks for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BLUESKY_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;          &lt;span class="c"&gt;# → bsky.app → Settings → App Passwords (UYGULAMA şifresi!&lt;/span&gt;
                           &lt;span class="c"&gt;#   normal hesap şifreniz DEĞİL)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote that comment, and went out of my way to stress "app password." The social posting workflow uses the same name: &lt;code&gt;social-post.yml&lt;/code&gt; passes &lt;code&gt;BLUESKY_PASSWORD&lt;/code&gt; from GitHub Secrets on line 125, and &lt;code&gt;scripts/social-post.ts&lt;/code&gt; reads exactly that name. So far, consistent.&lt;/p&gt;

&lt;p&gt;But the site's own sharing endpoint, &lt;code&gt;/api/share-bluesky&lt;/code&gt;, is fed from a different file. Here is what &lt;code&gt;src/lib/bluesky.ts&lt;/code&gt; does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appPassword&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BLUESKY_APP_PASSWORD&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// … (the line between reads the service address)&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;appPassword&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;BLUESKY_HANDLE ve BLUESKY_APP_PASSWORD env vars eksik&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same credential, two names. Worse: the error raised when it is missing names a variable that appears nowhere in the setup file. Someone following the document to the letter puts &lt;code&gt;BLUESKY_PASSWORD&lt;/code&gt; in &lt;code&gt;.env&lt;/code&gt;, calls that endpoint, and is told to go find &lt;code&gt;BLUESKY_APP_PASSWORD&lt;/code&gt;. It is not in the file, not in the guide — it exists only inside the code.&lt;/p&gt;

&lt;p&gt;So I checked which one is actually set on the live server: &lt;code&gt;BLUESKY_APP_PASSWORD&lt;/code&gt; is set, &lt;code&gt;BLUESKY_PASSWORD&lt;/code&gt; is not there at all. That makes sense, because the process on the server serves the site and its API endpoints, while the posting script runs on the CI side with its own secrets. Two names living in two environments, and both of them work today.&lt;/p&gt;

&lt;p&gt;If this arrangement works, it is because I typed two different names into two different places by hand. Nothing anywhere records that the two are the same password. It belongs to the same family as the run where I counted &lt;a href="https://mustafaerbay.com.tr/en/blog/career/recetemin-yedi-iddiasi-kodu-yanlis-anlatiyor/" rel="noopener noreferrer"&gt;seven claims in my own recipe that described the code incorrectly&lt;/a&gt; — document and code each correct alone, wrong together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three jobs on one token
&lt;/h2&gt;

&lt;p&gt;The second silent line, &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt;, held me longer. The name says what it is for: a key used to encrypt something. It is read in exactly one place — &lt;code&gt;src/lib/secret-box.ts&lt;/code&gt;, the module that encrypts LinkedIn's OAuth tokens with AES-256-GCM before they are written to the SQLite database on disk. (The name appears a few more times in that file, but those are the module's own doc comments and one error string. A count that does not separate comment from code gets this wrong too.)&lt;/p&gt;

&lt;p&gt;That module's key derivation looks at &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; first; failing that it derives a key from &lt;code&gt;ADMIN_API_TOKEN&lt;/code&gt; via scrypt; if neither exists it throws and stops. That last step is good behaviour — encryption does not quietly proceed with a weak key.&lt;/p&gt;

&lt;p&gt;The problem is the middle step. &lt;code&gt;ADMIN_API_TOKEN&lt;/code&gt; is already a documented, mandatory key, and as the name suggests it is the admin panel's login token. Six files in the repository read it, but the work they do comes down to three jobs:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgVFtBRE1JTl9BUElfVE9LRU48YnIvPmRvY3VtZW50ZWQsIG1hbmRhdG9yeV0KICBBWzEgLSBhdXRoZW50aWNhdGlvbjxici8-YWRtaW4tYXV0aC50cyArIHRocmVlIHNoYXJpbmcgc2NyaXB0czxici8-aXRzIGRlY2xhcmVkIGpvYl0KICBWWzIgLSB2aXNpdG9yIEhNQUMgc2FsdDxici8-dmlzaXRvci50czxici8-d2hlbiBWSVNJVE9SX0hBU0hfU0FMVCBpcyB1bnNldF0KICBTWzMgLSBBRVMtMjU2LUdDTSBrZXk8YnIvPnNlY3JldC1ib3gudHM8YnIvPndoZW4gVE9LRU5fRU5DX0tFWSBpcyB1bnNldF0KICBMW2xpbmtlZGluLnRzPGJyLz5PQXV0aCB0b2tlbnMgb24gZGlza10KICBUIC0tPiBBCiAgVCAtLT4gVgogIFQgLS0-IFMKICBTIC0tPiBM%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgVFtBRE1JTl9BUElfVE9LRU48YnIvPmRvY3VtZW50ZWQsIG1hbmRhdG9yeV0KICBBWzEgLSBhdXRoZW50aWNhdGlvbjxici8-YWRtaW4tYXV0aC50cyArIHRocmVlIHNoYXJpbmcgc2NyaXB0czxici8-aXRzIGRlY2xhcmVkIGpvYl0KICBWWzIgLSB2aXNpdG9yIEhNQUMgc2FsdDxici8-dmlzaXRvci50czxici8-d2hlbiBWSVNJVE9SX0hBU0hfU0FMVCBpcyB1bnNldF0KICBTWzMgLSBBRVMtMjU2LUdDTSBrZXk8YnIvPnNlY3JldC1ib3gudHM8YnIvPndoZW4gVE9LRU5fRU5DX0tFWSBpcyB1bnNldF0KICBMW2xpbmtlZGluLnRzPGJyLz5PQXV0aCB0b2tlbnMgb24gZGlza10KICBUIC0tPiBBCiAgVCAtLT4gVgogIFQgLS0-IFMKICBTIC0tPiBM%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="896" height="422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first job is the token's declared one: &lt;code&gt;admin-auth.ts&lt;/code&gt; guards the panel, while &lt;code&gt;share-to-bluesky.ts&lt;/code&gt;, &lt;code&gt;share-to-linkedin.ts&lt;/code&gt; and &lt;code&gt;social-post.ts&lt;/code&gt; are clients carrying that same token to the &lt;code&gt;/api/share-*&lt;/code&gt; endpoints. The by-products of panel login derive from it as well — &lt;code&gt;admin-auth.ts&lt;/code&gt; uses HMAC over the same token to produce both the session cookie and the Umami embed key.&lt;/p&gt;

&lt;p&gt;The other two jobs grew out of a convenience: we already have a secret value, let us not add another one. I am not guessing at that motive — the justification is written in my own comment at the top of &lt;code&gt;secret-box.ts&lt;/code&gt;, which says it works with the existing production environment and requires no new secret.&lt;/p&gt;

&lt;p&gt;Here is the state on the server: &lt;code&gt;ADMIN_API_TOKEN&lt;/code&gt; is set, &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; is not, &lt;code&gt;VISITOR_HASH_SALT&lt;/code&gt; is not. So the convenience is not a theoretical possibility but today's configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens if it is rotated
&lt;/h2&gt;

&lt;p&gt;An admin token is the kind of thing you assume is rotatable. If it leaks, you generate a new one, put it in place and log back into the panel. Is that actually true in this setup?&lt;/p&gt;

&lt;p&gt;Rather than reason about it, I called the repository's own module from two separate processes — encrypt with one, decrypt with the other. The values are fake; no real token was involved.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1) encrypt with ADMIN_API_TOKEN=A
   v1:QaSUKyZp/exJJgkE:yIEMn9VKKQ94cuDH1yMmaw==:be/w8SroJUurxBSxzh/5KX1IJ5Hv+ZoJmas+wg==
2) decrypt, same token  → PLAIN: linkedin-sahte-erisim-jetonu
3) decrypt with token B → THROW: Error | Unsupported state or unable to authenticate data
4) no token at all      → THROW: secret-box: TOKEN_ENC_KEY veya ADMIN_API_TOKEN env ayarli olmali
                                  (token sifreleme icin)
5) non-envelope value   → PLAIN: duz-metin-eski-jeton
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Line three is the answer. The moment the admin token changes, LinkedIn tokens encrypted under a key derived from the old one become undecryptable. It does not return half the plaintext or garbage — it returns nothing: in AES-GCM, if the authentication tag does not verify, the operation fails. Node's documentation covers the missing-tag and tampered-ciphertext cases explicitly: &lt;code&gt;decipher.final()&lt;/code&gt; throws, and the ciphertext should be discarded. The wrong-key case does not appear on that page; I take it from RFC 5116's definition plus line three above. The RFC states that authenticated decryption has a single output, either a plaintext value or a special symbol &lt;code&gt;FAIL&lt;/code&gt; indicating the inputs are not authentic — and it defines authenticity relative to the key, so a ciphertext not produced under a given key is not authentic for it.&lt;/p&gt;

&lt;p&gt;Line five is the module's other face. A value that is not in envelope form comes back out of &lt;code&gt;decryptSecret&lt;/code&gt; unchanged. That is deliberate: plaintext rows written before encryption existed stay readable and get encrypted on their next write. It is an understandable migration convenience, but sitting next to "it does not quietly proceed with a weak key" it deserves naming as a branch that keeps a record readable.&lt;/p&gt;

&lt;p&gt;The loss is not permanent either. Re-authorising through &lt;code&gt;/api/linkedin-auth&lt;/code&gt; produces a fresh token, and because &lt;code&gt;linkedin.ts&lt;/code&gt; calls &lt;code&gt;encryptSecret&lt;/code&gt; when storing it, it lands under the current key. So the bill for rotating the token is not data loss: it is one LinkedIn re-authorisation, an update in two places (the single GitHub Secret named &lt;code&gt;ADMIN_API_TOKEN&lt;/code&gt; and the env file on the server), every open admin session dropped, and a changed embed key. None of that is a catastrophe — and none of it is what you have in mind while rotating a token.&lt;/p&gt;

&lt;p&gt;On the visitor side the effect is the harmless kind. Running the hash for one fixed IP and user agent across four configurations, the digest changed as soon as the token changed. As the code's own comment says: if the salt changes the hashes change, and rate limiting and duplicate detection reset once. Nothing breaks; a counter restarts.&lt;/p&gt;

&lt;p&gt;One more detail: if both variables are empty, &lt;code&gt;visitor.ts&lt;/code&gt; falls back to a string hardcoded in the repository. On the server that branch is never reached, because &lt;code&gt;ADMIN_API_TOKEN&lt;/code&gt; is set — so the privacy property holds, but what holds it up is a dependency declared nowhere.&lt;/p&gt;

&lt;p&gt;The OWASP secrets management guidance puts this in two sentences. The diagnosis: services often share the same secrets, which makes identifying the source of a compromise or leak challenging. The prescription: it should be immediately apparent what a secret is used for and where to find it. In my setup, what the token is for is apparent — for its declared job. You learn about its other two jobs by reading three files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a fresh install gets
&lt;/h2&gt;

&lt;p&gt;All of this could be dismissed as particular to me. The repository's setup script says otherwise.&lt;/p&gt;

&lt;p&gt;The header of &lt;code&gt;.env.example&lt;/code&gt; states the file's promise clearly: the values you fill in go to two places, the server's &lt;code&gt;/etc/&amp;lt;project&amp;gt;.env&lt;/code&gt; for the running site and GitHub Secrets for the automation. One list, two destinations. And when the target file does not yet exist, &lt;code&gt;setup.sh&lt;/code&gt; does exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cp&lt;/span&gt; .env &lt;span class="s2"&gt;"/etc/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;.env"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So when somebody installs from scratch and follows the guide to the letter, the secrets file on their server is nothing more than the filled-in &lt;code&gt;.env.example&lt;/code&gt; (the script only appends a &lt;code&gt;DB_PATH&lt;/code&gt; line afterwards). Which means &lt;code&gt;BLUESKY_APP_PASSWORD&lt;/code&gt; will not be there, because it is not in the template. Neither will &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt;. On my own server the first one &lt;em&gt;is&lt;/em&gt; set — I typed it in later by hand, which is the very inconsistency this piece is about. But &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; being empty on my server too is not an accident: it is the default output of the installation path.&lt;/p&gt;

&lt;p&gt;One list does not stretch across two environments, because the two environments do not want the same things. The server process brings up the site and its API endpoints; the CI run generates and publishes content. One has no need for AI provider keys, the other none for the Turnstile secret. That is where the asymmetry in the count comes from: of the 14 keys on the server, 11 are in the template and three are not — &lt;code&gt;BLUESKY_APP_PASSWORD&lt;/code&gt;, &lt;code&gt;BLUESKY_SERVICE&lt;/code&gt; and &lt;code&gt;LINKEDIN_REDIRECT_URI&lt;/code&gt;. It runs the other way too: four of the template's 29 names are never read under &lt;code&gt;src/&lt;/code&gt; or &lt;code&gt;scripts/&lt;/code&gt; at all, because they are R2 backup keys belonging to the &lt;code&gt;deploy/backup.sh&lt;/code&gt; shell script.&lt;/p&gt;

&lt;p&gt;So the file is not merely incomplete. It behaves as though there were a single runtime environment, when there are three: the service on the server, the CI run and the backup script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving up on maintaining the document by hand
&lt;/h2&gt;

&lt;p&gt;The lesson I take from this is not "keep your documentation current," because this file was never kept current. &lt;code&gt;git log&lt;/code&gt; shows two commits: 28 of its key lines were written in one go on 11 June 2026, and one more was added on 28 September. The repository is not old either — the first commit is 31 March 2026. So the template was written four months ago in a single sitting, and it started falling behind the code that same day. A hand-maintained list that does not live next to the code can only decay.&lt;/p&gt;

&lt;p&gt;The rules I now hold myself to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A credential gets one name.&lt;/strong&gt; If two environments need two names, one should fall back to the other, and that fallback should be visible on a single line of code. On the &lt;code&gt;GH_API_TOKEN&lt;/code&gt; side I got this right already: the ops page checks &lt;code&gt;GITHUB_API_TOKEN&lt;/code&gt; and falls back to &lt;code&gt;GH_API_TOKEN&lt;/code&gt;, both names sitting side by side in one place. On the Bluesky side I did not, because the two reads live in two different files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A default is a decision, not a convenience.&lt;/strong&gt; Every sentence that begins "we already have a secret value" writes an undeclared dependency into the system. If a fallback is warranted, the place where the secret is documented should say so — not the module that consumes it: this token does these three jobs, and rotating it affects all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Derive the contract from the code, but do not trust your counter.&lt;/strong&gt; The two &lt;code&gt;grep&lt;/code&gt; commands above take seconds and produce the first two numbers on their own; the third needs the line starts of a file on the running server, which a check step cannot easily reach. And the second section of this piece shows the naive pattern missing at least four names. Any such check has to cover the &lt;code&gt;?.&lt;/code&gt; form and flag dynamic reads separately. As this piece goes live that check is not yet in place — the finding is open, not fixed.&lt;/p&gt;

&lt;p&gt;If you want to run the same count on your own repository, the order is: extract the names from the template, extract the names the code reads, compare the two lists with &lt;code&gt;comm&lt;/code&gt;, then read only the &lt;strong&gt;line starts&lt;/strong&gt; of the file on the running server — you do not need the values. Then sort the difference: variables the runtime supplies itself are noise, tuning knobs with defaults are preference, anything shaped like a credential is a work list. Mine has 12 lines on it, and two are not the sort of thing you fix in one sitting; adopting &lt;code&gt;TOKEN_ENC_KEY&lt;/code&gt; on a live box means setting the key and letting existing rows re-encrypt on their next write.&lt;/p&gt;

&lt;p&gt;One of the habits that comes with experience is confusing knowing where a system is written down with knowing that it is written down correctly. The run where I counted &lt;a href="https://mustafaerbay.com.tr/en/blog/career/altmis-sekiz-yedek-biraktim-nginx-yalniz-birini-okudu/" rel="noopener noreferrer"&gt;sixty-eight backups that nginx read exactly one of&lt;/a&gt; involved a similar mistake: the existence of a file was taken as evidence that the file was being read.&lt;/p&gt;

&lt;p&gt;A system's configuration contract is not the document describing that contract. It is the set of names the code reads — and seeing that set in full is harder than you would think. In my case it grew from 29 to at least 101, and along the way it tied three jobs to one token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nodejs.org/api/crypto.html" rel="noopener noreferrer"&gt;Node.js Crypto API documentation&lt;/a&gt; — behaviour of &lt;code&gt;decipher.setAuthTag()&lt;/code&gt; and &lt;code&gt;decipher.final()&lt;/code&gt;, and the error thrown on failed authentication&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc5116" rel="noopener noreferrer"&gt;RFC 5116 — An Interface and Algorithms for Authenticated Encryption&lt;/a&gt; — §2.2 on the single output of authenticated decryption and the &lt;code&gt;FAIL&lt;/code&gt; symbol, and authenticity defined relative to the key&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" rel="noopener noreferrer"&gt;OWASP Secrets Management Cheat Sheet&lt;/a&gt; — shared secrets, making a secret's purpose apparent, and the re-encryption impact of rotating encryption keys&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>configuration</category>
      <category>secretmanagement</category>
      <category>technicaldebt</category>
      <category>documentation</category>
    </item>
    <item>
      <title>I Have 188 Packages, Brew Refuses to Read Two of Them</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:23:30 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-have-188-packages-brew-refuses-to-read-two-of-them-2ddj</link>
      <guid>https://dev.to/merbayerp/i-have-188-packages-brew-refuses-to-read-two-of-them-2ddj</guid>
      <description>&lt;p&gt;My intention was modest: free up some disk space. I typed&lt;br&gt;
&lt;code&gt;brew list --formula | wc -l&lt;/code&gt; and got 188. Add five application bundles (casks)&lt;br&gt;
to that. Then I asked "how&lt;br&gt;
many of these did I actually ask for?" and the whole thing turned on me. My&lt;br&gt;
package manager answers that question with three different commands, and they&lt;br&gt;
give three different numbers — and in the gap between those answers sit two&lt;br&gt;
packages that appear in no list at all.&lt;/p&gt;

&lt;p&gt;The space-clearing job never finished. What came out instead was a measurement&lt;br&gt;
of how much a machine remembers about its own past. I'll walk&lt;br&gt;
through all of it, because every number here has a mechanism underneath it,&lt;br&gt;
and the mechanisms turned out to be more talkative than I expected.&lt;/p&gt;
&lt;h2&gt;
  
  
  Three commands, three answers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;brew list --formula&lt;/code&gt; counts the 188 packages on disk. &lt;code&gt;brew leaves&lt;/code&gt; says 51:&lt;br&gt;
"the ones that aren't a dependency of another installed formula or cask."&lt;br&gt;
&lt;code&gt;brew autoremove&lt;br&gt;
--dry-run&lt;/code&gt; says nothing at all: the output is completely empty, there is&lt;br&gt;
nothing safe to remove. One question, three numbers: 188, 51, 0.&lt;/p&gt;

&lt;p&gt;They aren't actually asking the same question, and that's what I had failed to&lt;br&gt;
read properly. &lt;code&gt;brew list&lt;/code&gt; enumerates directories in the Cellar: what is on&lt;br&gt;
disk. &lt;code&gt;brew leaves&lt;/code&gt; collects the runtime-dependency names from the receipts of&lt;br&gt;
installed packages and prints the ones that don't appear in that set: the&lt;br&gt;
leaves of the graph. &lt;code&gt;autoremove&lt;/code&gt; asks a third question: which package is both&lt;br&gt;
a leaf &lt;em&gt;and&lt;/em&gt; carries the "I didn't ask for this" record.&lt;/p&gt;

&lt;p&gt;Putting the four numbers side by side, I finally saw what I had been asking:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Answer&lt;/th&gt;
&lt;th&gt;What it really asks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brew list --formula&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;188&lt;/td&gt;
&lt;td&gt;How many directories are in the Cellar?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;installed_on_request: true&lt;/code&gt; in receipts&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;How many installs did I request?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brew leaves&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;How many are leaves in the graph of loadable formulae?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;brew autoremove --dry-run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;How many are considered safe to delete?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Cellar totals 8.76 GiB. The 64 packages I asked for hold 5.77 GiB of that;&lt;br&gt;
the 124 that came along hold 2.99 GiB. I assumed the single largest item on my&lt;br&gt;
disk was mine: &lt;code&gt;llvm&lt;/code&gt; really is (1,797 MiB), but right behind it comes&lt;br&gt;
&lt;code&gt;llvm@21&lt;/code&gt; (1,561 MiB), which I never asked for. &lt;code&gt;zig&lt;/code&gt; and &lt;code&gt;lld@21&lt;/code&gt; pulled it&lt;br&gt;
in. The receipts say the same thing: &lt;code&gt;llvm&lt;/code&gt;'s record carries the request flag,&lt;br&gt;
&lt;code&gt;llvm@21&lt;/code&gt;'s does not. One of the two largest items on my disk is a decision of&lt;br&gt;
mine; the other is a side effect.&lt;/p&gt;
&lt;h2&gt;
  
  
  "Why are you here?" has exactly one bit of answer
&lt;/h2&gt;

&lt;p&gt;Every package carries an &lt;code&gt;INSTALL_RECEIPT.json&lt;/code&gt; in its own directory. All 188&lt;br&gt;
receipts have the &lt;code&gt;installed_on_request&lt;/code&gt; key: 64 say &lt;code&gt;true&lt;/code&gt;, 124 say &lt;code&gt;false&lt;/code&gt;.&lt;br&gt;
So my machine's memory of "you asked for this" is a single boolean per package.&lt;/p&gt;

&lt;p&gt;There used to be a second field: &lt;code&gt;installed_as_dependency&lt;/code&gt;. It is missing from&lt;br&gt;
85 of my receipts, &lt;code&gt;true&lt;/code&gt; in 74, and &lt;code&gt;false&lt;/code&gt; in 29. I found the reason in&lt;br&gt;
Homebrew's own code; the comment in &lt;code&gt;tab.rb&lt;/code&gt; refers to the field as&lt;br&gt;
"long-removed" and keeps backward compatibility so old receipts still load. So&lt;br&gt;
those 85 receipts were written by a newer brew that no longer records it. One&lt;br&gt;
bit is what's left.&lt;/p&gt;

&lt;p&gt;Up to here it's an engineering decision, and a reasonable one. What stopped me&lt;br&gt;
was looking at my own records. My shell history sits at its ceiling: the file&lt;br&gt;
holds 1,225 lines, but 225 of them end in a backslash continuation, so the&lt;br&gt;
record count is exactly 1,000 — my &lt;code&gt;SAVEHIST&lt;/code&gt; limit. The only &lt;code&gt;brew&lt;/code&gt; line in&lt;br&gt;
there is a &lt;code&gt;brew upgrade&lt;/code&gt;; there is no &lt;code&gt;brew install&lt;/code&gt; line at all.&lt;br&gt;
&lt;a href="https://mustafaerbay.com.tr/en/blog/life/her-yeni-komutum-bir-eskisini-siliyor/" rel="noopener noreferrer"&gt;Every new command of mine deletes an old&lt;br&gt;
one&lt;/a&gt;, and somewhere among&lt;br&gt;
those old ones every single install line went away. The only record of my&lt;br&gt;
intent is that bit, kept by the package manager itself.&lt;/p&gt;

&lt;p&gt;My first count was wrong, by the way: &lt;code&gt;grep -c brew ~/.zsh_history&lt;/code&gt; returned&lt;br&gt;
nothing and I wrote down "zero". The file isn't plain text — &lt;code&gt;file&lt;/code&gt; calls it&lt;br&gt;
extended ASCII, so grep treats it as binary and goes quiet. Asked with&lt;br&gt;
&lt;code&gt;grep -a&lt;/code&gt;, one line showed up. The same thing happens to me a couple more times&lt;br&gt;
in the rest of this piece.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgUVsiV2hhdCBpcyBvbiBteSBkaXNrLCBhbmQgd2h5PyJdIC0tPiBMWyJicmV3IGxpc3Q8YnIvPnJlYWRzIENlbGxhciBkaXJlY3RvcmllcyJdCiAgUSAtLT4gVlsiYnJldyBsZWF2ZXM8YnIvPmxvYWRzIGluc3RhbGxlZCBmb3JtdWxhZSw8YnIvPmJ1aWxkcyB0aGUgZ3JhcGggZnJvbSByZWNlaXB0cyJdCiAgUSAtLT4gQVsiYnJldyBhdXRvcmVtb3ZlPGJyLz5iaXQgKyBncmFwaCArIGJvdHRsZSBydWxlIl0KICBMIC0tPiBMMVsiMTg4IHBhY2thZ2VzIl0KICBWIC0tPiBHeyJDYW4gdGhlIGZvcm11bGEgYmUgbG9hZGVkPyJ9CiAgRyAtLT58Im9mZmljaWFsIG9yIHRydXN0ZWQgdGFwInwgVjFbIjUxIGxlYXZlcyJdCiAgRyAtLT58InVudHJ1c3RlZCB0YXAifCBYWyJzaWxlbnRseSBkcm9wcyBvZmYgdGhlIGxpc3QiXQogIEEgLS0-IEExWyIwIHBhY2thZ2VzIl0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgUVsiV2hhdCBpcyBvbiBteSBkaXNrLCBhbmQgd2h5PyJdIC0tPiBMWyJicmV3IGxpc3Q8YnIvPnJlYWRzIENlbGxhciBkaXJlY3RvcmllcyJdCiAgUSAtLT4gVlsiYnJldyBsZWF2ZXM8YnIvPmxvYWRzIGluc3RhbGxlZCBmb3JtdWxhZSw8YnIvPmJ1aWxkcyB0aGUgZ3JhcGggZnJvbSByZWNlaXB0cyJdCiAgUSAtLT4gQVsiYnJldyBhdXRvcmVtb3ZlPGJyLz5iaXQgKyBncmFwaCArIGJvdHRsZSBydWxlIl0KICBMIC0tPiBMMVsiMTg4IHBhY2thZ2VzIl0KICBWIC0tPiBHeyJDYW4gdGhlIGZvcm11bGEgYmUgbG9hZGVkPyJ9CiAgRyAtLT58Im9mZmljaWFsIG9yIHRydXN0ZWQgdGFwInwgVjFbIjUxIGxlYXZlcyJdCiAgRyAtLT58InVudHJ1c3RlZCB0YXAifCBYWyJzaWxlbnRseSBkcm9wcyBvZmYgdGhlIGxpc3QiXQogIEEgLS0-IEExWyIwIHBhY2thZ2VzIl0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="825" height="699"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The thirteen packages between 51 and 64
&lt;/h2&gt;

&lt;p&gt;Only 51 of the 64 packages I requested are leaves. The first 11 of the&lt;br&gt;
remaining 13 are explainable: even though I asked for them, they are now&lt;br&gt;
something else's dependency. 37 packages lean on &lt;code&gt;openssl@3&lt;/code&gt;, 20 on &lt;code&gt;libtiff&lt;/code&gt;,&lt;br&gt;
12 on &lt;code&gt;glib&lt;/code&gt;. If I tried to delete those, half of them would come straight back&lt;br&gt;
down.&lt;/p&gt;

&lt;p&gt;The last two taught me a lesson about measurement. The first time I compared&lt;br&gt;
the lists, I found a package that was "in leaves but not in my requested set":&lt;br&gt;
&lt;code&gt;powershell/tap/powershell&lt;/code&gt;. No cause for panic, because the cause wasn't real&lt;br&gt;
— &lt;code&gt;brew leaves&lt;/code&gt; prints packages by their &lt;strong&gt;full name&lt;/strong&gt; (tap prefix included),&lt;br&gt;
while the Cellar directory is just &lt;code&gt;powershell&lt;/code&gt;. The difference between the sets&lt;br&gt;
was in my text formatting, not in the world. In this kind of measurement, the&lt;br&gt;
first finding being an artifact of your own script is a common event; once I&lt;br&gt;
normalised the names, the gap closed.&lt;/p&gt;

&lt;p&gt;But two names refused to close: &lt;code&gt;sshpass&lt;/code&gt; and &lt;code&gt;supabase&lt;/code&gt;. Both are packages I&lt;br&gt;
requested, both say &lt;code&gt;installed_on_request: true&lt;/code&gt; in their receipts, and neither&lt;br&gt;
is a dependency of anything, which by definition makes them leaves. &lt;code&gt;brew&lt;br&gt;
leaves&lt;/code&gt; doesn't count them. It doesn't error. It doesn't warn. They're just&lt;br&gt;
absent.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two packages on disk that the manager won't read
&lt;/h2&gt;

&lt;p&gt;One command made the reason obvious:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ brew info sshpass
Error: Refusing to load formula hudochenkov/sshpass/sshpass from untrusted
tap hudochenkov/sshpass.
Run `brew trust --formula hudochenkov/sshpass/sshpass` or
`brew trust hudochenkov/sshpass` to trust it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Homebrew now refuses to load formulae from third-party taps unless you have&lt;br&gt;
explicitly trusted that tap. The code has a crisp two-line definition: for a&lt;br&gt;
tap to count as implicitly trusted it must be official &lt;em&gt;and&lt;/em&gt; come from its&lt;br&gt;
canonical remote (&lt;code&gt;official? &amp;amp;&amp;amp; canonical_remote?&lt;/code&gt;). Every other tap needs&lt;br&gt;
approval, and approvals live in &lt;code&gt;~/.homebrew/trust.json&lt;/code&gt;. My file is 63 bytes&lt;br&gt;
and holds a single-entry list: &lt;code&gt;powershell/tap/powershell&lt;/code&gt;. The file's&lt;br&gt;
modification time is 15 October, 09:05; the &lt;code&gt;pwsh&lt;/code&gt; symlink is dated 09:06. So I&lt;br&gt;
granted an approval in the same minute I installed PowerShell.&lt;/p&gt;

&lt;p&gt;How that approval is given matters, because there is no spontaneous "do you&lt;br&gt;
trust this?" prompt anywhere in the code: a trust record only comes from a&lt;br&gt;
hand-typed &lt;code&gt;brew trust&lt;/code&gt; command or from &lt;code&gt;brew bundle&lt;/code&gt;'s explicit flag, and&lt;br&gt;
every other path stops at a single error. The full name in the file&lt;br&gt;
(&lt;code&gt;powershell/tap/powershell&lt;/code&gt;) says the same thing: I had the tap's identity in&lt;br&gt;
front of me as I typed that line. The gate asked me for the right thing; I'm&lt;br&gt;
the one who reduced it to a formality. A one-second decision, made so as not to&lt;br&gt;
interrupt an install, turned out months later to be the only thing keeping two&lt;br&gt;
packages inside the inventory.&lt;/p&gt;

&lt;p&gt;That enforcement entered Homebrew with the &lt;code&gt;Add tap trust enforcement&lt;/code&gt; commit&lt;br&gt;
dated 30 May 2026, but the commit message itself says it was opt-in there: the&lt;br&gt;
rule only applied when &lt;code&gt;HOMEBREW_REQUIRE_TAP_TRUST&lt;/code&gt; was set. It became the&lt;br&gt;
default through another commit on 8 June, which shipped first in version 6.0.0&lt;br&gt;
(11 June 2026). So the gate has been shut on this machine for four and a half&lt;br&gt;
months; I'm on 7.0.10 today. I installed &lt;code&gt;sshpass&lt;/code&gt; on 24 February 2026 and&lt;br&gt;
&lt;code&gt;supabase&lt;/code&gt; two days later: both were inside before the gate existed, the gate&lt;br&gt;
was fitted afterwards, and both ended up on the outside of it.&lt;/p&gt;

&lt;p&gt;Now the concrete consequence: &lt;code&gt;brew list&lt;/code&gt; shows these two packages, because&lt;br&gt;
that command only looks at Cellar directories. &lt;code&gt;brew leaves&lt;/code&gt; doesn't, because&lt;br&gt;
it cannot load the formula. &lt;code&gt;brew outdated&lt;/code&gt; reports 120 outdated formulae and&lt;br&gt;
neither of them is on that list. Asked by name, it throws the error above. So&lt;br&gt;
the scope of my update scan is not 188 packages, it's 186.&lt;/p&gt;

&lt;p&gt;These two aren't dormant files either. The &lt;code&gt;/opt/homebrew/bin/sshpass&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;/opt/homebrew/bin/supabase&lt;/code&gt; links are in place, both are in my PATH, and both&lt;br&gt;
run. &lt;code&gt;sshpass -V&lt;/code&gt; prints version 1.06 and a 2011 copyright line. &lt;code&gt;supabase&lt;/code&gt; is&lt;br&gt;
2.75.0; that release went out on 2 February 2026, and the newest one upstream&lt;br&gt;
was published this morning. Eight months sit in between.&lt;/p&gt;

&lt;p&gt;The instructive part is this: my package manager didn't tell me, the package&lt;br&gt;
itself did. Run &lt;code&gt;supabase --version&lt;/code&gt; and two lines appear under the version&lt;br&gt;
number saying a newer release exists and 2.75.0 is what's installed. The update&lt;br&gt;
information is right there on the machine; the tool whose job is to collect it&lt;br&gt;
and show it to me in one list has dropped that package from its list.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;brew leaves&lt;/code&gt; being silent is, to me, the actual defect. When it can't load a&lt;br&gt;
formula it drops the package from the list and doesn't tell me; the command's&lt;br&gt;
error stream comes back completely empty. A one-line "I couldn't read these two&lt;br&gt;
packages" would have replaced my hand-rolled set comparison. What the code does&lt;br&gt;
is technically consistent — an unloadable&lt;br&gt;
formula has no place in the graph — but an inventory command should at least&lt;br&gt;
confess that its count is incomplete. If it were mine, I'd print the refusal as&lt;br&gt;
a warning rather than an error: the behaviour stays the same, and the user's&lt;br&gt;
blind spot closes.&lt;/p&gt;

&lt;p&gt;I have no objection to the approval mechanism itself, quite the opposite. A&lt;br&gt;
formula is Ruby code that runs during installation; the build steps, the&lt;br&gt;
download addresses, the post-install scripts all come from that file. An&lt;br&gt;
unaudited repository quietly updating that code amounts to permission to&lt;br&gt;
execute code on my computer. I'd put the gate in the same place. My objection&lt;br&gt;
isn't to the gate but to what happens behind it going unrecorded. Refusing to&lt;br&gt;
load a formula I haven't trusted is the right call; dropping it from the&lt;br&gt;
inventory as well erases the difference between "I won't load this" and "this&lt;br&gt;
isn't here". And these two packages don't leave the disk or the PATH, and they&lt;br&gt;
keep running: they just stop being counted.&lt;/p&gt;

&lt;p&gt;The security side turns into a pleasant irony here. The gate's purpose is&lt;br&gt;
right: don't blindly run code from a tap I haven't audited. But once the gate&lt;br&gt;
closes, whoever is left inside also falls outside of management. NIST's guide to&lt;br&gt;
enterprise patch management planning puts inventory second among its planning&lt;br&gt;
recommendations (§3.2, &lt;em&gt;Inventory Your Software and Assets&lt;/em&gt;) and says&lt;br&gt;
inventories must be constantly updated or they go stale quickly; the same&lt;br&gt;
section notes that a software bill of materials (SBOM) from a vendor can&lt;br&gt;
augment that inventory. Mine is two lines short, and noticing it required&lt;br&gt;
comparing sets by hand.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why autoremove said zero
&lt;/h2&gt;

&lt;p&gt;I had a bit of paranoia here: I didn't request 124 packages, so some of them&lt;br&gt;
must be orphans. That turned out to be wrong. The graph, assembled from the&lt;br&gt;
runtime-dependency lists in the receipts, shows that &lt;strong&gt;every one&lt;/strong&gt; of the 124&lt;br&gt;
unrequested packages has at least one requester; zero orphans. That's the real&lt;br&gt;
reason &lt;code&gt;autoremove&lt;/code&gt; came back empty.&lt;/p&gt;

&lt;p&gt;But reading the code showed me there are three separate ways for it to come&lt;br&gt;
back empty. &lt;code&gt;autoremove&lt;/code&gt; only deletes packages that pass all three filters:&lt;br&gt;
nothing in the graph depends on them, their receipt says &lt;code&gt;installed_on_request&lt;/code&gt;&lt;br&gt;
is &lt;strong&gt;explicitly&lt;/strong&gt; &lt;code&gt;false&lt;/code&gt;, and they were poured from a bottle (pre-built).&lt;br&gt;
Packages built from source never even enter the list; three of them are like&lt;br&gt;
that on this machine (&lt;code&gt;powershell&lt;/code&gt;, &lt;code&gt;sshpass&lt;/code&gt;, &lt;code&gt;supabase&lt;/code&gt;). Cask dependencies&lt;br&gt;
are separately protected as well.&lt;/p&gt;

&lt;p&gt;On this machine the first of those three filters was enough on its own: with no&lt;br&gt;
orphaned package, the list is empty before the others matter. The other two are&lt;br&gt;
waiting for the day the graph changes: the three source-built packages on this&lt;br&gt;
machine will never enter &lt;code&gt;autoremove&lt;/code&gt;'s list even if their requesters&lt;br&gt;
disappear. For&lt;br&gt;
someone who&lt;br&gt;
actually wants to free space, the useful information is elsewhere: which of my&lt;br&gt;
requests is single-handedly holding how many packages? My &lt;code&gt;poppler&lt;/code&gt; install&lt;br&gt;
holds 18 packages on its own, &lt;code&gt;ffmpeg&lt;/code&gt; and &lt;code&gt;ghostscript&lt;/code&gt; six each. Requesting&lt;br&gt;
&lt;code&gt;composer&lt;/code&gt; brings 56 packages into the closure, &lt;code&gt;php&lt;/code&gt; 55, &lt;code&gt;poppler&lt;/code&gt; 51. A&lt;br&gt;
single &lt;code&gt;brew uninstall&lt;/code&gt; decision frees 18 packages by that count — and none at&lt;br&gt;
all by &lt;code&gt;autoremove&lt;/code&gt;'s answer today.&lt;/p&gt;

&lt;p&gt;There are leftovers, too: &lt;code&gt;brew cleanup --dry-run&lt;/code&gt; says roughly 41.9 MB. Four&lt;br&gt;
packages have more than one version sitting in the Cellar; &lt;code&gt;ca-certificates&lt;/code&gt;&lt;br&gt;
has three at once (versions dated 19 March, 13 August and 25 September 2026).&lt;br&gt;
Against 8.76 GiB, 41.9 MB is a comic number for my disk-space goal, but at&lt;br&gt;
least nobody is hiding that number from me. After the measurement where I&lt;br&gt;
deleted apps and &lt;a href="https://mustafaerbay.com.tr/en/blog/life/18-uygulamayi-sildim-661-mib-veri-diskte-kaldi/" rel="noopener noreferrer"&gt;left 661 MiB of data&lt;br&gt;
behind&lt;/a&gt;, I pay&lt;br&gt;
more attention to where the residue hides than to how big it is.&lt;/p&gt;
&lt;h2&gt;
  
  
  How far I can trust the receipt
&lt;/h2&gt;

&lt;p&gt;With the measurement done, I was going to look at the dates in the receipts and&lt;br&gt;
draw an "age distribution of my packages" chart. Luckily I tripped over an odd&lt;br&gt;
line first: &lt;code&gt;git-filter-repo&lt;/code&gt; appears to have been installed on 20 October 2026,&lt;br&gt;
yet the same receipt's &lt;code&gt;homebrew_version&lt;/code&gt; field says &lt;code&gt;4.6.9-6-g24e7d8f&lt;/code&gt;. That&lt;br&gt;
tag is dated 3 September 2025, thirteen months before the install. Of the 70&lt;br&gt;
packages installed after 24 June 2026, 24 carry a receipt naming a brew version&lt;br&gt;
older than 6.0.&lt;/p&gt;

&lt;p&gt;The answer is in the source again. When installing from a bottle, brew doesn't&lt;br&gt;
write the receipt from scratch; it loads the one that came inside the bottle and&lt;br&gt;
then refreshes only specific fields — install time, &lt;code&gt;installed_on_request&lt;/code&gt;,&lt;br&gt;
architecture, source path and so on. &lt;code&gt;homebrew_version&lt;/code&gt; is not among the&lt;br&gt;
refreshed fields. So the brew version in a receipt is the version of the brew&lt;br&gt;
that &lt;strong&gt;built&lt;/strong&gt; that bottle, not the one that installed it. The chart I was&lt;br&gt;
planning to draw would have shown the history of Homebrew's build servers, not&lt;br&gt;
of my machine.&lt;/p&gt;

&lt;p&gt;The install time (&lt;code&gt;time&lt;/code&gt;) field isn't an intent date either: since every&lt;br&gt;
upgrade rewrites the receipt, that field holds "the moment this version was&lt;br&gt;
installed". My oldest receipt is 21 January 2025 (&lt;code&gt;git&lt;/code&gt;), the newest 20 October&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Between those two dates, "when did I first ask for this package?" is
written down nowhere. The intent bit surviving an upgrade is not an accident but
deliberate: the code reads the value from the existing receipt and carries it
into the new one. The only thing carried over is that bit — not the date.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Running the same measurement on your own machine
&lt;/h2&gt;

&lt;p&gt;None of the numbers above needs a special tool; they all come from reading the&lt;br&gt;
receipts in the Cellar. This single line gives you the request/dependency&lt;br&gt;
split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;r &lt;span class="k"&gt;in&lt;/span&gt; /opt/homebrew/Cellar/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/INSTALL_RECEIPT.json&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  /usr/bin/python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'import json,sys; j=json.load(open(sys.argv[1])); \
print(j.get("installed_on_request"))'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$r&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On my machine this prints &lt;code&gt;128 False&lt;/code&gt; and &lt;code&gt;65 True&lt;/code&gt;; that totals 193, not 188.&lt;br&gt;
The difference comes from four packages that keep more than one version in the&lt;br&gt;
Cellar: the command counts every version directory, while my table takes the&lt;br&gt;
newest receipt per package (64/124). If you're going to compare your number&lt;br&gt;
with somebody else's, make sure both are counting the same thing.&lt;/p&gt;

&lt;p&gt;To find the invisible ones, comparing two lists is enough, but do not forget to&lt;br&gt;
strip the tap prefixes, or you'll see the same ghost difference I saw on my&lt;br&gt;
first pass:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /opt/homebrew/Cellar | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;brew leaves | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s|.*/||'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For me this command dumps 137 names (188 minus 51), and every one of them means&lt;br&gt;
"on disk, not in the leaf list". Most are legitimate, in that they're somebody's&lt;br&gt;
dependency; &lt;code&gt;brew info &amp;lt;name&amp;gt;&lt;/code&gt; tells you in one line which ones can't be read.&lt;br&gt;
The paths differ by architecture: &lt;code&gt;/opt/homebrew&lt;/code&gt; on Apple Silicon,&lt;br&gt;
&lt;code&gt;/usr/local&lt;/code&gt; on Intel Macs.&lt;/p&gt;

&lt;p&gt;There is, however, a far shorter way to do this whole comparison, and I took&lt;br&gt;
the long road until I found it. First, a single flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew list &lt;span class="nt"&gt;--formula&lt;/span&gt; &lt;span class="nt"&gt;--full-name&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prints every package coming from a third-party tap by its full name — for&lt;br&gt;
me, exactly three lines: the list of candidates for becoming invisible. Second,&lt;br&gt;
open the gate for the length of one command and measure the difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;HOMEBREW_NO_REQUIRE_TAP_TRUST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 brew leaves | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;      &lt;span class="c"&gt;# 53 for me, normally 51&lt;/span&gt;
&lt;span class="nv"&gt;HOMEBREW_NO_REQUIRE_TAP_TRUST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 brew outdated &lt;span class="nt"&gt;--formula&lt;/span&gt;  &lt;span class="c"&gt;# 121 for me, normally 120&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The difference is exactly the two packages I expected, and the only one added&lt;br&gt;
to the update list is &lt;code&gt;supabase&lt;/code&gt;: so of the two that fall out of scope, one&lt;br&gt;
really is behind, and the other (&lt;code&gt;sshpass&lt;/code&gt; 1.06) is the newest version in its&lt;br&gt;
own tap. Homebrew marks this variable as one that will be removed in a later&lt;br&gt;
release, so it isn't a permanent fix; but for a once-a-year inventory audit&lt;br&gt;
it's exactly what I was looking for. Your numbers will obviously come out&lt;br&gt;
different; the value of the measurement isn't in the number, it's in how far&lt;br&gt;
the four answers drift apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'll do tonight
&lt;/h2&gt;

&lt;p&gt;At the end of the measurement my to-do list got shorter but clearer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Settle the two invisible packages.&lt;/strong&gt; Do I need &lt;code&gt;sshpass&lt;/code&gt;? The cost of
keeping a binary with a 2011 copyright in my PATH is higher than either
making it visible with &lt;code&gt;brew trust&lt;/code&gt; or removing it. Before deciding, I'll
scan the scripts I use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Move &lt;code&gt;supabase&lt;/code&gt; to where the manager can see it.&lt;/strong&gt; There's no defensible
reason to keep a CLI that's eight months behind outside the patch scan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep my intent somewhere other than brew.&lt;/strong&gt; A 64-line &lt;code&gt;Brewfile&lt;/code&gt; is a
better memory than one bit per package; my shell history clearly doesn't
remember.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the tap inventory once a year.&lt;/strong&gt; I have four taps, three of them
third-party. Adding a line to the trust file took me a second; reading it
takes a second too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no new tool on that list, because the missing piece was never a tool.&lt;br&gt;
My package manager answers three questions from three different places, and I&lt;br&gt;
had been treating all three as the same question.&lt;/p&gt;

&lt;p&gt;The number that stays with me isn't 188 or 0; it's 2. When a security gate goes&lt;br&gt;
up, we usually think "that thing can't get in anymore". But the gate can also&lt;br&gt;
make invisible what is already inside: the same mechanism both protects and&lt;br&gt;
drops things out of the catalogue. The inventory our machines report to us is&lt;br&gt;
not an inventory of what they measure, but of what they can read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/Homebrew/brew/commit/c6f37230435e29ba827dbfe0d380e99a9e98ac6e" rel="noopener noreferrer"&gt;Homebrew — the tap trust enforcement commit&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Homebrew/brew/blob/main/Library/Homebrew/trust.rb" rel="noopener noreferrer"&gt;Homebrew — &lt;code&gt;Library/Homebrew/trust.rb&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Homebrew/brew/blob/main/Library/Homebrew/cmd/leaves.rb" rel="noopener noreferrer"&gt;Homebrew — the &lt;code&gt;brew leaves&lt;/code&gt; implementation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Homebrew/brew/blob/main/Library/Homebrew/utils/autoremove.rb" rel="noopener noreferrer"&gt;Homebrew — the &lt;code&gt;autoremove&lt;/code&gt; filters&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Homebrew/brew/blob/main/Library/Homebrew/tab.rb" rel="noopener noreferrer"&gt;Homebrew — the install receipt (&lt;code&gt;tab.rb&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Homebrew/brew/blob/main/Library/Homebrew/tap.rb" rel="noopener noreferrer"&gt;Homebrew — tap definition and &lt;code&gt;implicitly_trusted?&lt;/code&gt; (&lt;code&gt;tap.rb&lt;/code&gt;)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://csrc.nist.gov/pubs/sp/800/40/r4/final" rel="noopener noreferrer"&gt;NIST SP 800-40r4 — Guide to Enterprise Patch Management Planning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.cisa.gov/sbom" rel="noopener noreferrer"&gt;CISA — Software Bill of Materials&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>homebrew</category>
      <category>olcum</category>
      <category>dependency</category>
    </item>
    <item>
      <title>I Broke the Thundering Herd, One Worker Took 99% of Connections</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:22:20 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-broke-the-thundering-herd-one-worker-took-99-of-connections-2e9</link>
      <guid>https://dev.to/merbayerp/i-broke-the-thundering-herd-one-worker-took-99-of-connections-2e9</guid>
      <description>&lt;p&gt;Sharing one listening socket across several workers has a classic price: a connection arrives, the kernel wakes everyone waiting on that socket, one of them takes the connection, the rest go back empty-handed. We call it the thundering herd, and since Linux 4.5 it is supposedly solved by a single flag you pass to &lt;code&gt;epoll_ctl&lt;/code&gt; — &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt;. The man page says so too, though what it actually says is that the flag is useful for avoiding thundering herd problems &lt;strong&gt;in certain scenarios&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I wanted to measure it. Eight workers, one socket, 500 connections, five repetitions. The herd really did break up: empty wakeups dropped from 869 to 1, and the workers' total CPU time was cut in half. Then I looked at the distribution table. In the median of the five runs, one worker had handled 497 of the 500 connections — and in all five runs, six workers had never woken up at all.&lt;/p&gt;

&lt;p&gt;This piece is about that table. There is already &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/cache-stampede-thundering-herd-ve-operasyonel-onlemler/" rel="noopener noreferrer"&gt;a post on cache stampede and thundering herd&lt;/a&gt; on this blog, but that one lives at the application layer — clients asking for the same key at the same moment. The herd here is one floor down: processes sitting in the same socket's wait queue. Both the fix and the side effect of the fix are different.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rig
&lt;/h2&gt;

&lt;p&gt;I ran the measurement on this blog's own server: 18 vCPUs, Ubuntu kernel &lt;code&gt;6.8.0-147-generic&lt;/code&gt;, Python 3.12.3, load average around 5 at the time. Eight worker processes are created with &lt;code&gt;fork&lt;/code&gt;, each builds its own &lt;code&gt;epoll&lt;/code&gt; instance, registers the shared listening socket, and then blocks in &lt;code&gt;epoll_wait&lt;/code&gt;. The parent dictates the registration order: no child calls &lt;code&gt;epoll_ctl(ADD)&lt;/code&gt; until its turn comes. That detail matters later. The whole rig is three Python scripts, 305 lines.&lt;/p&gt;

&lt;p&gt;The client side is plain: 500 connections, 2 ms apart, one at a time. Each worker counts three things — how many times it woke with an event, how many connections it accepted, and how many times it woke only to get &lt;code&gt;EAGAIN&lt;/code&gt; from &lt;code&gt;accept&lt;/code&gt;. That last one is the herd's invoice: you woke up, you ran, the plate was empty.&lt;/p&gt;

&lt;p&gt;Four setups went through the rig. No flag: eight workers share one socket, nothing else. Flagged: they all register with &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt;. &lt;code&gt;SO_REUSEPORT&lt;/code&gt;: no shared socket at all, each worker listens on the same address and port with its own. Half-migrated: half the workers registered with the flag and half without — the "we are moving to the flag but we are not done" state. Five runs each. The numbers below are the mean of those five, with the lowest and highest run in parentheses; the individual distributions I show in the text always come from one single run. The CPU figures are Python's figures — do not carry the absolute values over to a C server; the ratios are what matter.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Wakeups&lt;/th&gt;
&lt;th&gt;Empty wakeups&lt;/th&gt;
&lt;th&gt;Worker CPU (ms)&lt;/th&gt;
&lt;th&gt;Largest worker share&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No flag&lt;/td&gt;
&lt;td&gt;1369 (1211-1501)&lt;/td&gt;
&lt;td&gt;869 (63%)&lt;/td&gt;
&lt;td&gt;702 (650-776)&lt;/td&gt;
&lt;td&gt;36.7% (31.4-41.2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;498 (495-501)&lt;/td&gt;
&lt;td&gt;1 (0-1)&lt;/td&gt;
&lt;td&gt;327 (294-380)&lt;/td&gt;
&lt;td&gt;98.9% (97.6-99.8)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;SO_REUSEPORT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;499 (497-500)&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;351 (300-415)&lt;/td&gt;
&lt;td&gt;14.6% (14.2-15.4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Half-migrated&lt;/td&gt;
&lt;td&gt;1294 (1143-1465)&lt;/td&gt;
&lt;td&gt;795 (61%)&lt;/td&gt;
&lt;td&gt;579 (534-644)&lt;/td&gt;
&lt;td&gt;40.6% (38.0-41.8)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The no-flag row shows 1369 wakeups for 500 connections: 2.7 workers get up per connection, and 63% of those trips are wasted. The flag brings the count down to one per connection — 498 to be exact, meaning a couple of wakeups accepted two connections at once. So far the advertising holds.&lt;/p&gt;

&lt;p&gt;The problem is in the last column. Let me say up front: that 98.9% is not a discovery, it is the arithmetic of the setup. Work time is zero and connections arrive 2 ms apart, so the first worker is always back in &lt;code&gt;epoll_wait&lt;/code&gt; before the next one lands. What happens to the monopoly under load comes shortly. The genuinely odd thing is inside the no-flag run, and I missed it at first.&lt;/p&gt;

&lt;h2&gt;
  
  
  The no-flag secret: the last registrant wins
&lt;/h2&gt;

&lt;p&gt;Here is the no-flag distribution (one run, in registration order, left to right):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[6, 9, 22, 35, 44, 78, 107, 199]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the herd means "everyone wakes, one grabs it", then who grabs it should come down to race conditions, and over a long run eight workers should end up with roughly similar shares. Instead the worker that registered last took thirty-three times as many connections as the one that registered first. Same direction, similar magnitude, in every run.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt; on, the opposite happens: now the &lt;strong&gt;first&lt;/strong&gt; registrant takes everything. Same machine, same program; the single difference of one flag flips the distribution end to end. A symmetry that sharp is not an accident. The answer is in the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two lines, two ends
&lt;/h2&gt;

&lt;p&gt;When epoll starts watching a file, &lt;code&gt;fs/eventpoll.c&lt;/code&gt; adds its wait entry like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epi&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;EPOLLEXCLUSIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;add_wait_queue_exclusive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pwq&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="nf"&gt;add_wait_queue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;whead&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;pwq&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two functions write to two different ends. &lt;code&gt;add_wait_queue&lt;/code&gt; puts the entry at the &lt;strong&gt;head&lt;/strong&gt; of the queue; &lt;code&gt;add_wait_queue_exclusive&lt;/code&gt; puts it at the &lt;strong&gt;tail&lt;/strong&gt;, via &lt;code&gt;__add_wait_queue_entry_tail&lt;/code&gt;. The waking side, &lt;code&gt;__wake_up_common&lt;/code&gt; in &lt;code&gt;kernel/sched/wait.c&lt;/code&gt;, always walks the queue from the head and stops at the first exclusive entry that answers "yes, I woke up":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;WQ_FLAG_EXCLUSIVE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!--&lt;/span&gt;&lt;span class="n"&gt;nr_exclusive&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest follows on its own. Without the flag, entries go to the head, so the queue is the reverse of registration order; the last registrant sits in front, gets woken first, and starts the &lt;code&gt;accept&lt;/code&gt; race one step ahead. That is the 199-versus-6 gap. With the flag, entries go to the tail, so the queue matches registration order; the walk from the head hits the same first worker every time and stops there. That is the 98.9%.&lt;/p&gt;

&lt;p&gt;In other words, "exclusive" is not a distribution policy. It is a stopping condition. The kernel tells you how many wakeups it will perform; it says nothing about which one.&lt;/p&gt;

&lt;p&gt;(The measurement ran on a 6.8 kernel while the quotes above come from mainline. The four pieces that matter — &lt;code&gt;ep_ptable_queue_proc&lt;/code&gt;, &lt;code&gt;add_wait_queue_exclusive&lt;/code&gt;, the stopping line in &lt;code&gt;__wake_up_common&lt;/code&gt;, and the &lt;code&gt;ewake&lt;/code&gt; block coming up next — are identical in v6.8 and mainline.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A control: does the winner follow registration order?
&lt;/h2&gt;

&lt;p&gt;That explanation makes a testable prediction. If the winner is decided by &lt;code&gt;epoll_ctl(ADD)&lt;/code&gt; order rather than by the order in which processes were created, then reversing the registration order should move the winner.&lt;/p&gt;

&lt;p&gt;So I wired it that way. The &lt;code&gt;fork&lt;/code&gt; order stayed fixed (0 through 7) and I reversed the registration order: the last process created became the first to register the socket. The result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;registration order: [7, 6, 5, 4, 3, 2, 1, 0]
accepted:           [500, 0, 0, 0, 0, 0, 0, 0]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The winner is worker 7 — born last, registered first. Running the same test without the flag, the winner was again the last registrant (226 against 3). What decides is not the process id, the creation order, or timing; it is the position in the socket's wait queue.&lt;/p&gt;

&lt;p&gt;This is useful to know in practice: if one worker on a server is consistently doing many times the work of its peers, the place to look is not the worker, it is the startup order.&lt;/p&gt;

&lt;h2&gt;
  
  
  The kernel skips a busy worker — without breaking the order
&lt;/h2&gt;

&lt;p&gt;So what happens when the first worker is drowning? There is a subtlety inside &lt;code&gt;ep_poll_callback&lt;/code&gt;: for exclusive entries, the "I woke up" answer is only given if someone is actually waiting on that epoll instance (&lt;code&gt;waitqueue_active&lt;/code&gt;). If the worker is outside &lt;code&gt;epoll_wait&lt;/code&gt;, that is, busy with work in hand, the callback returns zero; &lt;code&gt;__wake_up_common&lt;/code&gt; does not count it and moves on to the next entry.&lt;/p&gt;

&lt;p&gt;Adding fake work after each accept puts numbers on it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work time&lt;/th&gt;
&lt;th&gt;Distribution (in registration order)&lt;/th&gt;
&lt;th&gt;Workers used&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[499, 1, 0, 0, 0, 0, 0, 0]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[460, 37, 3, 0, 0, 0, 0, 0]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5 ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[189, 165, 135, 5, 6, 0, 0, 0]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 ms&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[115, 110, 106, 96, 68, 4, 1, 0]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That table is what finally made the mechanism intuitive for me. The kernel does not treat the eight workers as a pool; starting from the head of the queue, it calls up the next one only as long as the work is not keeping up. The numbers land exactly where queueing theory says they should: serving a connection in 10 ms while one arrives every 2 ms needs about five workers in steady state — and in the measurement the first five carry 495 of the 500. A 5 ms service time needs about two and a half; the first three carry 489. The eighth worker did not wake once in any of these four runs; even at 10 ms, with seven workers busy at the same time, its turn never came.&lt;/p&gt;

&lt;p&gt;The table explains one more thing: that 99% up top is not a fixed number, it is a product of low load. Running the same measurement three more times while the server was busier, the first worker's share fell as low as 88.6% — under load that worker spends more time outside &lt;code&gt;epoll_wait&lt;/code&gt;, so the turn passes to the second. But across all eighteen zero-work runs of the flag, the one in front was always the first registrant, with a share somewhere between 88.6% and 100%. Load softens the monopoly; it does not remove it.&lt;/p&gt;

&lt;p&gt;This is not bad design — it genuinely minimises wakeups, keeps a hot process hot, and is kind to cache locality. But read it as "load balancing" and it will mislead you. It is not a balancer; it is an overflow chain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiQ29ubmVjdGlvbiBhcnJpdmVzPGJyLz5za193cSBpcyB3b2tlbiJdIC0tPiBCWyJfX3dha2VfdXBfY29tbW9uPGJyLz53YWxrcyB0aGUgcXVldWUgZnJvbSB0aGUgSEVBRCJdCiAgQiAtLT4gQ3siaXMgdGhlIG5leHQgZW50cnkgZXhjbHVzaXZlPzxici8-KEVQT0xMRVhDTFVTSVZFKSJ9CiAgQyAtLSAibm8gKGFkZGVkIGF0IGhlYWQpIiAtLT4gRFsiV2FrZSBpdCwgZG8gbm90IGNvdW50PGJyLz50aGUgd2FsayBjb250aW51ZXMiXQogIEQgLS0-IEMKICBDIC0tICJ5ZXMgKGFkZGVkIGF0IHRhaWwpIiAtLT4gRXsiaXMgYW55b25lIHdhaXRpbmc8YnIvPm9uIHRoaXMgZXBvbGw_In0KICBFIC0tICJubzogd29ya2VyIGlzIGJ1c3kiIC0tPiBGWyJTa2lwPGJyLz50aGUgd2FsayBjb250aW51ZXMiXQogIEYgLS0-IEMKICBFIC0tICJ5ZXM6IHdvcmtlciBpcyBpZGxlIiAtLT4gR1siV2FrZSBpdCBhbmQgU1RPUDxici8-bnJfZXhjbHVzaXZlIGV4aGF1c3RlZCJdCiAgQyAtLSAiZW5kIG9mIHF1ZXVlIiAtLT4gSFsiV2l0aCBubyBmbGFnIG5vYm9keSBzdG9wcyB0aGUgd2Fsazxici8-Mi43IHdha2V1cHMgcGVyIGNvbm5lY3Rpb24iXQogIEcgLS0-IElbIk9uZSB3YWtldXA8YnIvPmJ1dCBhbHdheXMgdGhlIHNhbWUgd29ya2VyIl0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiQ29ubmVjdGlvbiBhcnJpdmVzPGJyLz5za193cSBpcyB3b2tlbiJdIC0tPiBCWyJfX3dha2VfdXBfY29tbW9uPGJyLz53YWxrcyB0aGUgcXVldWUgZnJvbSB0aGUgSEVBRCJdCiAgQiAtLT4gQ3siaXMgdGhlIG5leHQgZW50cnkgZXhjbHVzaXZlPzxici8-KEVQT0xMRVhDTFVTSVZFKSJ9CiAgQyAtLSAibm8gKGFkZGVkIGF0IGhlYWQpIiAtLT4gRFsiV2FrZSBpdCwgZG8gbm90IGNvdW50PGJyLz50aGUgd2FsayBjb250aW51ZXMiXQogIEQgLS0-IEMKICBDIC0tICJ5ZXMgKGFkZGVkIGF0IHRhaWwpIiAtLT4gRXsiaXMgYW55b25lIHdhaXRpbmc8YnIvPm9uIHRoaXMgZXBvbGw_In0KICBFIC0tICJubzogd29ya2VyIGlzIGJ1c3kiIC0tPiBGWyJTa2lwPGJyLz50aGUgd2FsayBjb250aW51ZXMiXQogIEYgLS0-IEMKICBFIC0tICJ5ZXM6IHdvcmtlciBpcyBpZGxlIiAtLT4gR1siV2FrZSBpdCBhbmQgU1RPUDxici8-bnJfZXhjbHVzaXZlIGV4aGF1c3RlZCJdCiAgQyAtLSAiZW5kIG9mIHF1ZXVlIiAtLT4gSFsiV2l0aCBubyBmbGFnIG5vYm9keSBzdG9wcyB0aGUgd2Fsazxici8-Mi43IHdha2V1cHMgcGVyIGNvbm5lY3Rpb24iXQogIEcgLS0-IElbIk9uZSB3YWtldXA8YnIvPmJ1dCBhbHdheXMgdGhlIHNhbWUgd29ya2VyIl0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="927" height="1121"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Kill the front worker and the monopoly moves next door
&lt;/h2&gt;

&lt;p&gt;Once the mechanism is clear, the first production question is obvious: what happens if the worker at the head of the queue crashes? The rig answers that too — a &lt;code&gt;SIGKILL&lt;/code&gt; to the first registrant at connection 250:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;killed: registration rank 0 · surviving workers, in registration order
accepted: [250, 0, 0, 0, 0, 0, 0]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next worker in line took every one of the remaining 250 connections, with zero empty wakeups. The monopoly does not break; it shifts by one seat. Three runs out of three. The operational consequence matters more than it looks: when you restart the crashed worker, it registers the socket &lt;strong&gt;again&lt;/strong&gt; and lands at the &lt;strong&gt;tail&lt;/strong&gt; of the queue. A single crash permanently rearranges your load distribution — yesterday's first is now last.&lt;/p&gt;

&lt;h2&gt;
  
  
  One legacy worker brings the herd back
&lt;/h2&gt;

&lt;p&gt;There is an easily skipped sentence in the man page: if the same file is registered in several epoll instances, some with the flag and some without, the event goes to &lt;strong&gt;all&lt;/strong&gt; of the non-exclusive ones and to &lt;strong&gt;at least one&lt;/strong&gt; of the exclusive ones.&lt;/p&gt;

&lt;p&gt;The "half-migrated" row in the table is exactly that: four of the eight workers registered with the flag, four without — the state of a setup in the middle of a migration. The distribution came out like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;registration order: 0(E)  1     2(E)  3     4(E)  5     6(E)  7
accepted:            37   57     0    76     0   128     0   202
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wakeups are back up to 1294 and empty wakeups to 795; barely different from the no-flag row. On top of that, three of the four flagged workers accepted nothing. The cause is the queue layout again: non-exclusive entries are added at the head, so they all sit in front of the wake list (and in reverse among themselves: 7, 5, 3, 1), the exclusive ones are behind them, and the walk stops at the first of those. The measured ordering — 202 &amp;gt; 128 &amp;gt; 76 &amp;gt; 57 &amp;gt; 37, then three zeroes — is exactly what the queue layout predicts. A migration left half-finished gives you the bad half of both worlds.&lt;/p&gt;

&lt;h2&gt;
  
  
  nginx hit this wall in 2022
&lt;/h2&gt;

&lt;p&gt;After finishing the measurement I went to look at what nginx does, since it is the best-known user of this flag. Its change log tells the story in two lines. 1.11.3 (July 2016): &lt;code&gt;accept_mutex&lt;/code&gt; became off by default and nginx started using &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt; on Linux. 1.21.6 (January 2022): a bug fix — with &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt; on Linux, client connections were unevenly distributed among worker processes.&lt;/p&gt;

&lt;p&gt;To see what went missing in between, it is enough to read the &lt;code&gt;accept_mutex&lt;/code&gt; documentation: with that directive on, worker processes accepted connections &lt;strong&gt;by turn&lt;/strong&gt;. The docs still say there is no need to enable it on systems that support EPOLLEXCLUSIVE — but when the flag took over the lock's herd-suppression job, it did not take over the turn-taking. The turn came back six years later, in user space.&lt;/p&gt;

&lt;p&gt;The fix in the source is called &lt;code&gt;ngx_reorder_accept_events&lt;/code&gt;, and its comment matches my table word for word: Linux usually notifies the process that added the socket to epoll first, so "most of the connections are handled by the first worker process". The remedy is to re-add the socket periodically. The interval is visible in the code: a &lt;code&gt;DEL&lt;/code&gt; + &lt;code&gt;ADD&lt;/code&gt; every 16 accepts (more often as a worker approaches its connection limit). Re-adding moves the entry to the tail of the queue, and the turn passes on.&lt;/p&gt;

&lt;p&gt;I tried the same thing in my own rig — re-register every 16 accepts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[64, 64, 64, 64, 64, 64, 64, 52]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical numbers in all three runs; nothing but the arithmetic of 16-accept rounds. Zero empty wakeups, still one wakeup per connection, worker CPU at 335 ms — indistinguishable from the flagged run's 294-380 ms band. So the entire gain from the flag survives and the distribution is fixed too. That a forty-line user-space workaround on top of a kernel behaviour lands this cleanly honestly surprised me.&lt;/p&gt;

&lt;p&gt;Which raises a question: if it is this simple, why doesn't the kernel do it? It was proposed. In 2015, alongside &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt;, &lt;a href="https://lwn.net/Articles/632590/" rel="noopener noreferrer"&gt;a second flag named &lt;code&gt;EPOLLROUNDROBIN&lt;/code&gt;&lt;/a&gt; was suggested to spread wakeups across waiters in turn. One flag went in; the other did not. There is no &lt;code&gt;EPOLLROUNDROBIN&lt;/code&gt; in today's mainline tree — eleven years later, still none.&lt;/p&gt;

&lt;h2&gt;
  
  
  One epoll, eight threads: same kernel, different outcome
&lt;/h2&gt;

&lt;p&gt;To find out whether the problem lives in the idea of exclusive wakeups or somewhere else, I set the rig up once more: this time a single epoll instance shared by eight threads, no flag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[95, 59, 62, 44, 76, 42, 57, 65]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No monopoly. At 1.6 wakeups per connection it is also below the 2.7 of the process version, though not zero.&lt;/p&gt;

&lt;p&gt;The reason is in the source again. &lt;code&gt;ep_poll&lt;/code&gt; re-adds its wait entry on every loop and removes it on wakeup; the removal is done by &lt;code&gt;ep_autoremove_wake_function&lt;/code&gt;, and the comment inside &lt;code&gt;ep_poll&lt;/code&gt; states the rationale plainly: when there are several waiters, each new wakeup should reach the next one. So the kernel &lt;em&gt;does&lt;/em&gt; rotate inside a single epoll instance. Where it does not rotate is where separate epoll instances sit &lt;strong&gt;permanently&lt;/strong&gt; in the socket's queue.&lt;/p&gt;

&lt;p&gt;The monopoly is not caused by the flag; it is caused by the entry being permanent. nginx's fix makes exactly that temporary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag's small print
&lt;/h2&gt;

&lt;p&gt;While measuring I also tried, one by one, the things the flag refuses, because the list of restrictions in the man page is long and most of them show up in your code as a quiet &lt;code&gt;EINVAL&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD&lt;/code&gt; with `EPOLLIN\&lt;/td&gt;
&lt;td&gt;EPOLLEXCLUSIVE`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A later &lt;code&gt;MOD&lt;/code&gt; on the same fd&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EINVAL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD&lt;/code&gt; with `EPOLLEXCLUSIVE\&lt;/td&gt;
&lt;td&gt;EPOLLONESHOT`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD&lt;/code&gt; with `EPOLLEXCLUSIVE\&lt;/td&gt;
&lt;td&gt;EPOLLET`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;EXCLUSIVE&lt;/code&gt; when the target is an epoll fd&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EINVAL&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row is the one that will hurt in practice: a descriptor you added with &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt; can never be changed with &lt;code&gt;EPOLL_CTL_MOD&lt;/code&gt; again. If you want to update the set of events you care about, the only path is delete and re-add. That also explains why nginx's rotation trick is a &lt;code&gt;DEL&lt;/code&gt; + &lt;code&gt;ADD&lt;/code&gt;: there is no other door.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check this on your own server
&lt;/h2&gt;

&lt;p&gt;The one actionable output of this article is a question: are accepted connections spread evenly across your workers? There are two ways to look.&lt;/p&gt;

&lt;p&gt;On nginx the cheapest is the access log: put &lt;code&gt;$pid&lt;/code&gt; in your &lt;code&gt;log_format&lt;/code&gt; (the docs define it as exactly that, the PID of the worker process) and count PIDs over one log file. The distribution shows up right there.&lt;/p&gt;

&lt;p&gt;The way that works regardless of the server is to ask the kernel. I tried it on VPS3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bpftrace &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'kretprobe:inet_csk_accept /retval != 0/ { @accepts[comm, pid] = count(); }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My lab run came out of it as these lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;@accepts[python3, 3259746]: 4
@accepts[python3, 3259745]: 196
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a four-worker run, 196 of 200 connections had gone to a single process. The same dump also listed the server's own services with their own counts — so this is not a lab-only trick but a measurement you can run directly on a live machine. A one-minute run is enough to tell you whether the imbalance is there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to pick
&lt;/h2&gt;

&lt;p&gt;The decision frame this measurement leaves behind:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;With a single worker&lt;/strong&gt; the flag means nothing. nginx only turns it on when &lt;code&gt;worker_processes&lt;/code&gt; is greater than one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you care about CPU and not about the spread between workers&lt;/strong&gt; — for instance, your workers are already loaded enough to saturate — the flag alone is enough. Under load the queue opens downward by itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you want an even spread at low or medium load&lt;/strong&gt;, the flag alone will not get you there. Either do what nginx does (periodic &lt;code&gt;DEL&lt;/code&gt;+&lt;code&gt;ADD&lt;/code&gt;) or use &lt;code&gt;SO_REUSEPORT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you pick &lt;code&gt;SO_REUSEPORT&lt;/code&gt;, know its two blind spots.&lt;/strong&gt; The first is distribution: the kernel picks the socket with a hash derived from the connection's four-tuple (&lt;code&gt;inet_ehashfn&lt;/code&gt;: destination address, local port, source address, source port), with no regard for how busy a worker is. In my rig the shares settled in the 14-15% band because every connection was an exact copy of every other; in real traffic, if one long-lived connection jams a worker, new connections in that queue wait even while the worker next door sits idle. If you want to take the choice back, the door is open: &lt;code&gt;SO_ATTACH_REUSEPORT_CBPF&lt;/code&gt;/&lt;code&gt;EBPF&lt;/code&gt; lets you pick the socket in the group yourself (on TCP since Linux 4.6). The second blind spot is sneakier, and it has a measurement of its own: when you swap workers, &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/kapanan-soketin-kuyrugu-kime-kalir/" rel="noopener noreferrer"&gt;the connections sitting in the closing listener's queue&lt;/a&gt; die silently unless &lt;code&gt;tcp_migrate_req&lt;/code&gt; is on — and that switch is off by default.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you are migrating, migrate in one go.&lt;/strong&gt; Running flagged and unflagged workers side by side brought the herd back in full.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the docs promised, and what I read
&lt;/h2&gt;

&lt;p&gt;The real lesson I took from this is not about the flag. For &lt;code&gt;EPOLLEXCLUSIVE&lt;/code&gt; the man page says that &lt;strong&gt;one or more&lt;/strong&gt; of the epoll descriptors will receive an event. For years I read that sentence as "one of them gets it, problem solved"; what the sentence actually gives you is only a count. It makes no promise about which one. The promise it never made, I had added myself.&lt;/p&gt;

&lt;p&gt;This trap is common in kernel interfaces: the documentation tells you a quantity and we read a policy. The distance between "the minimum number of wakeups" and "a fair share" was, in my rig, 497 out of 500 connections. Without measuring I would never have noticed; the server was running, requests were being answered, and six workers simply never woke.&lt;/p&gt;

&lt;p&gt;The question you can put to your own setup is simple: have you ever compared the number of accepted connections across your worker processes? If they are not even, that is not a bug — it is most likely exactly what the kernel promised.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man2/epoll_ctl.2" rel="noopener noreferrer"&gt;epoll_ctl(2) — EPOLLEXCLUSIVE, its restrictions and the mixed-registration rule&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/fs/eventpoll.c" rel="noopener noreferrer"&gt;fs/eventpoll.c — ep_ptable_queue_proc, ep_poll_callback and ep_autoremove_wake_function&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/sched/wait.c" rel="noopener noreferrer"&gt;kernel/sched/wait.c — add_wait_queue_exclusive and __wake_up_common&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/master/include/uapi/linux/eventpoll.h" rel="noopener noreferrer"&gt;include/uapi/linux/eventpoll.h — the EPOLLEXCLUSIVE bit definition&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nginx.org/en/CHANGES" rel="noopener noreferrer"&gt;nginx CHANGES — the 1.11.3 and 1.21.6 entries&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nginx.org/en/docs/ngx_core_module.html" rel="noopener noreferrer"&gt;nginx Core functionality — the accept_mutex directive&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nginx.org/en/docs/http/ngx_http_core_module.html" rel="noopener noreferrer"&gt;nginx HTTP Core — the $pid embedded variable&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/docs/man-pages/man-pages.git/tree/man/man7/socket.7" rel="noopener noreferrer"&gt;socket(7) — taking over socket selection with SO_ATTACH_REUSEPORT_CBPF/EBPF&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>kernel</category>
      <category>epoll</category>
      <category>networking</category>
    </item>
    <item>
      <title>I Moved the Binary Twelve Ways, Six Kept the File Capability</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Thu, 22 Oct 2026 17:23:41 +0000</pubDate>
      <link>https://dev.to/merbayerp/i-moved-the-binary-twelve-ways-six-kept-the-file-capability-3691</link>
      <guid>https://dev.to/merbayerp/i-moved-the-binary-twelve-ways-six-kept-the-file-capability-3691</guid>
      <description>&lt;p&gt;"Don't use setuid, grant a capability" is good advice. Giving a program the whole of root just so it can open a raw socket, when &lt;code&gt;cap_net_raw&lt;/code&gt; alone would do, trades the forty-one capabilities root carries (the kernel's &lt;code&gt;cap_last_cap&lt;/code&gt; is 40, i.e. 0-40) for one. I agree with the advice and I follow it myself.&lt;/p&gt;

&lt;p&gt;The problem isn't the advice — it's what the advice leaves out. The setuid bit is part of the file mode: &lt;code&gt;ls -l&lt;/code&gt; shows it, &lt;code&gt;cp&lt;/code&gt; carries it, &lt;code&gt;tar&lt;/code&gt; packs it, everyone knows about it. A file capability lives in an extended attribute called &lt;code&gt;security.capability&lt;/code&gt; — and extended attributes are quietly dropped by default in most of the tools that move a file from one place to another. So granting the capability correctly isn't the end of the job. The job ends when the file actually &lt;em&gt;arrives&lt;/em&gt; at its destination still carrying it.&lt;/p&gt;

&lt;p&gt;That is what today's lab run shows. The same binary went through twelve different transfer routes; six arrived with the capability, six silently left it behind. Along the way the capability model showed four more sharp edges: a case where &lt;code&gt;getcap&lt;/code&gt; tells the truth and the kernel still hands out nothing, a one-letter typo that makes a binary unrunnable even for root, the same typo turning a loud failure into silent nothing when you change that letter, and the fact that the capability cannot be written at all into a directory mounted from my Mac.&lt;/p&gt;

&lt;p&gt;Everything here comes from commands run today. The rig: Docker Desktop 4.37.2 (Docker Engine 27.4.0, Buildx v0.19.2) on my Mac, &lt;code&gt;linuxkit&lt;/code&gt; kernel 6.10.14, &lt;code&gt;debian:trixie-slim&lt;/code&gt; inside it (Debian 13.7), &lt;code&gt;libcap2-bin&lt;/code&gt; 1:2.75-10+deb13u1+b3, &lt;code&gt;tcpdump&lt;/code&gt; 4.99.5-2, &lt;code&gt;coreutils&lt;/code&gt; 9.7-3, &lt;code&gt;rsync&lt;/code&gt; 3.5.0, &lt;code&gt;git&lt;/code&gt; 2.47.3.&lt;/p&gt;

&lt;p&gt;Source verification was done against &lt;code&gt;security/commoncap.c&lt;/code&gt; in Linux 7.3, while the measurements ran on 6.10.14. The two were compared directly: the functions quoted below (&lt;code&gt;bprm_caps_from_vfs_caps&lt;/code&gt; and &lt;code&gt;get_file_caps&lt;/code&gt;) are &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; in v6.10 and v7.3, so the code quotes apply to the kernel under test as well.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, an inventory: nobody is using this model
&lt;/h2&gt;

&lt;p&gt;Curiosity first: in a clean Debian image, how many files are setuid and how many carry a capability?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find / &lt;span class="nt"&gt;-xdev&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; f &lt;span class="nt"&gt;-perm&lt;/span&gt; &lt;span class="nt"&gt;-4000&lt;/span&gt; 2&amp;gt;/dev/null | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
getcap &lt;span class="nt"&gt;-r&lt;/span&gt; / 2&amp;gt;/dev/null | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The answer is lopsided. Eight setuid files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/usr/bin/chsh
/usr/bin/chfn
/usr/bin/umount
/usr/bin/mount
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/gpasswd
/usr/bin/su
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Files carrying a capability: &lt;strong&gt;zero&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is surprising, because neither &lt;code&gt;mount&lt;/code&gt; nor &lt;code&gt;passwd&lt;/code&gt; genuinely needs full root, and the model is hardly new — the attribute has been around long enough to go through three revisions (&lt;code&gt;capability.h&lt;/code&gt; defines &lt;code&gt;VFS_CAP_REVISION_1&lt;/code&gt;, &lt;code&gt;_2&lt;/code&gt; and &lt;code&gt;_3&lt;/code&gt;). But the more interesting half of the story is &lt;code&gt;ping&lt;/code&gt;. For years &lt;code&gt;ping&lt;/code&gt; was the showcase example: once setuid, later migrated to &lt;code&gt;cap_net_raw=ep&lt;/code&gt;. Looking at it now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-rwxr-xr-x 1 root root 213448 Apr 22  2025 /usr/bin/ping
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No setuid bit, and &lt;code&gt;getcap&lt;/code&gt; prints nothing. Debian has taken &lt;code&gt;ping&lt;/code&gt; out of the capability model too. The package changelog says why; the &lt;code&gt;iputils&lt;/code&gt; 3:20240905-1 entry (24 September 2024) reads: &lt;em&gt;"ping: Add Recommends on linux-sysctl-defaults and stop setting additional capabilities"&lt;/em&gt;. Two releases earlier, in 3:20240117-1, the setuid path was closed: &lt;em&gt;"Stop falling back to setuid binaries for raw socket access"&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The same changelog, in the same release, also contains the opposite move: &lt;em&gt;"Set cap_sys_nice in iputils-clockdiff"&lt;/em&gt;. So Debian isn't abandoning capabilities wholesale — it is taking one away from &lt;code&gt;ping&lt;/code&gt; and giving one to &lt;code&gt;clockdiff&lt;/code&gt;. Capabilities aren't the wrong tool; they became an unnecessary tool for &lt;code&gt;ping&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What replaced it is decided by a sysctl:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ping_group_range: 0 2147483647
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One caveat is owed here: that value was read inside a container, and as such it is Docker's contribution rather than Debian's — the &lt;code&gt;debian:trixie-slim&lt;/code&gt; image does not install &lt;code&gt;linux-sysctl-defaults&lt;/code&gt;, and the range is written into the container by Docker's own default. On a real Debian 13 install the same result is reached through the &lt;code&gt;linux-sysctl-defaults&lt;/code&gt; package the changelog recommends. Different route, same destination: &lt;code&gt;ping&lt;/code&gt; no longer opens a raw socket at all — it opens an unprivileged ICMP datagram socket, and that range decides who may.&lt;/p&gt;

&lt;p&gt;The showcase example has been taken out of the showcase, because the best capability is the one you never need. That's a useful frame for the rest of this article: there are ways to carry a capability correctly, but not carrying one is always cheaper.&lt;/p&gt;

&lt;p&gt;So the experiment here rests not on &lt;code&gt;ping&lt;/code&gt; but on a program that genuinely needs a raw socket: &lt;code&gt;tcpdump&lt;/code&gt;. I wrote about the tool itself in &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/tcpdump-ile-uretimde-paket-yakalama-runbooku/" rel="noopener noreferrer"&gt;the production packet-capture runbook&lt;/a&gt;; here I'm dealing with its privileges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Granting the capability: three lines, three different outcomes
&lt;/h2&gt;

&lt;p&gt;Copy &lt;code&gt;tcpdump&lt;/code&gt;, leave its mode at 755, run it as an ordinary user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; /usr/bin/tcpdump /opt/td
su deneme &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"/opt/td -i lo -c 1 -w /dev/null"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;td: lo: You don't have permission to perform this capture on that device
(socket: Operation not permitted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. As expected. Now the capability. Since &lt;code&gt;-c 1&lt;/code&gt; waits for the first packet, and &lt;code&gt;lo&lt;/code&gt; sits idle in a fresh container, traffic has to be generated first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setcap cap_net_raw+ep /opt/td
&lt;span class="o"&gt;(&lt;/span&gt;ping &lt;span class="nt"&gt;-c&lt;/span&gt; 3 &lt;span class="nt"&gt;-i&lt;/span&gt; 0.3 127.0.0.1 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;0.2
su deneme &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"/opt/td -i lo -c 1 -w /dev/null"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;td: listening on lo, link-type EN10MB (Ethernet), snapshot length 262144 bytes
1 packet captured
8 packets received by filter
0 packets dropped by kernel
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file is still owned by root, its mode is still &lt;code&gt;-rwxr-xr-x&lt;/code&gt;, there is no setuid bit — and an ordinary user is capturing packets. The model worked exactly as advertised.&lt;/p&gt;

&lt;p&gt;Then &lt;code&gt;+p&lt;/code&gt; instead of &lt;code&gt;+ep&lt;/code&gt;. One letter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setcap cap_net_raw+p /opt/td
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;td: lo: You don't have permission to perform this capture on that device
(socket: Operation not permitted)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Back to square one. Meanwhile &lt;code&gt;getcap&lt;/code&gt; happily confirms there is a capability on the file: &lt;code&gt;cap_net_raw=p&lt;/code&gt;. That &lt;code&gt;e&lt;/code&gt; is the "effective" bit, and it means: should the capability go into the process's &lt;em&gt;effective&lt;/em&gt; set, or will the program raise it itself? &lt;code&gt;tcpdump&lt;/code&gt; is not capability-aware — it doesn't call &lt;code&gt;capset&lt;/code&gt; to promote something from its permitted set into its effective set. So with &lt;code&gt;+p&lt;/code&gt; it receives the capability and cannot use it.&lt;/p&gt;

&lt;p&gt;To see this from the process side, three copies of &lt;code&gt;capsh&lt;/code&gt; marked three different ways, each printing its own sets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;marking on the file&lt;/th&gt;
&lt;th&gt;process's &lt;code&gt;Current:&lt;/code&gt; line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;no capability&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_net_raw=p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cap_net_raw=p&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_net_raw=ep&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cap_net_raw=ep&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The middle row is the trap itself: the capability is there, in the process's hands, and not effective. My rule became this — if the program you're marking doesn't call &lt;code&gt;capset&lt;/code&gt;, write &lt;code&gt;+ep&lt;/code&gt;, and don't go hunting for why &lt;code&gt;+p&lt;/code&gt; didn't work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the capability actually lives
&lt;/h2&gt;

&lt;p&gt;To see where the difference between &lt;code&gt;+ep&lt;/code&gt; and &lt;code&gt;+p&lt;/code&gt; is stored, look straight at the attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;getfattr &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; - /opt/td
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# file: opt/td
security.capability=0sAQAAAgAgAAAAAAAAAAAAAAAAAAA=
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty bytes. Decoded, the structure is entirely legible:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw hex:   0100000200200000000000000000000000000000
magic_etc = 0x02000001
  revision = 0x02000000   effective bit = 1
permitted = 0x00002000 -&amp;gt; bit set: 13
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twenty bytes matches the kernel's version-2 layout. The &lt;code&gt;capability.h&lt;/code&gt; definition reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define VFS_CAP_U32_2           2
#define XATTR_CAPS_SZ_2         (sizeof(__le32)*(1 + 2*VFS_CAP_U32_2))
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;VFS_CAP_U32_2&lt;/code&gt; equal to 2, that is &lt;code&gt;4 × (1 + 4)&lt;/code&gt; = 20. Version 3 would be 24 bytes, because &lt;code&gt;XATTR_CAPS_SZ_3&lt;/code&gt; counts one more &lt;code&gt;__le32&lt;/code&gt;: the &lt;code&gt;rootid&lt;/code&gt; field. That field isn't decoration — inside a user namespace it records which mapped root the capability belongs to, which is what rootless containers need. If you run rootless Docker or Podman, you'll meet v3 rather than v2.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;effective&lt;/code&gt; bit, meanwhile, is not a separate set but the lowest bit of the &lt;code&gt;magic_etc&lt;/code&gt; field; the entire difference between &lt;code&gt;+ep&lt;/code&gt; and &lt;code&gt;+p&lt;/code&gt; is that one bit. Only bit 13 is set in the permitted set, and &lt;code&gt;capability.h&lt;/code&gt; says &lt;code&gt;#define CAP_NET_RAW 13&lt;/code&gt;. The capability granted is where it was expected.&lt;/p&gt;

&lt;p&gt;Worth pausing on: a twenty-byte attribute shows up neither in the file size nor in &lt;code&gt;ls -l&lt;/code&gt; output. That invisibility is the reason this whole article exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twelve ways to move it, six survivors
&lt;/h2&gt;

&lt;p&gt;The capability is granted and working. Now the real-world question: if the file moves somewhere, does the capability come along? The same binary went through twelve different transfer routes, with &lt;code&gt;getcap&lt;/code&gt; checked at the destination each time.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;transfer method&lt;/th&gt;
&lt;th&gt;capability arrived&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp -p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;no&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp -a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes — but silently (see below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cp --preserve=xattr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;install -m755&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tar cf&lt;/code&gt; + &lt;code&gt;tar xf&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tar --xattrs --xattrs-include='*'&lt;/code&gt; (both ends)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rsync -a&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rsync -aX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;mv&lt;/code&gt; (across filesystems)&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git add&lt;/code&gt; + &lt;code&gt;git checkout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dockerfile &lt;code&gt;COPY --from&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Six of twelve kept it. Five rows stand out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;cp -p&lt;/code&gt; row&lt;/strong&gt; is the one that stings. We read &lt;code&gt;-p&lt;/code&gt; as "preserve", but &lt;code&gt;cp --help&lt;/code&gt; defines it precisely: &lt;code&gt;-p&lt;/code&gt; = &lt;code&gt;--preserve=mode,ownership,timestamps&lt;/code&gt;. Extended attributes aren't on that list. Assuming &lt;code&gt;-p&lt;/code&gt; is enough is the shortest path to shipping the mistake to production without noticing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;cp -a&lt;/code&gt; row&lt;/strong&gt; says "yes" but deserves an asterisk, because the coreutils manual describes &lt;code&gt;-a&lt;/code&gt; as trying to preserve extended attributes but &lt;em&gt;"ignore any failure to do that and print no corresponding diagnostic"&lt;/em&gt;. &lt;code&gt;--preserve=xattr&lt;/code&gt; is the opposite: &lt;em&gt;"Preserve extended attributes of the file, or fail with full diagnostics."&lt;/em&gt; On this rig both carried the capability. But in an environment where &lt;code&gt;CAP_SETFCAP&lt;/code&gt; has been dropped, or onto a destination filesystem that can't hold the attribute, &lt;code&gt;cp -a&lt;/code&gt; drops the capability with exit code 0 and without a word. It would be odd for an article whose thesis is "the system doesn't tell you" to recommend the flag that is documented not to tell you: &lt;strong&gt;in automation, write &lt;code&gt;--preserve=xattr&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;install -m755&lt;/code&gt; row&lt;/strong&gt; is sneakier, because &lt;code&gt;install&lt;/code&gt; is the final step of nearly every &lt;code&gt;Makefile&lt;/code&gt;. Here the measurement simply matches the documentation, which states it in one line: &lt;em&gt;"&lt;code&gt;install&lt;/code&gt; never preserves extended attributes (xattr)."&lt;/em&gt; This isn't a side effect, it's by design. A flow that runs &lt;code&gt;setcap&lt;/code&gt; during the build and then puts the file in &lt;code&gt;/usr/local/bin&lt;/code&gt; with &lt;code&gt;make install&lt;/code&gt; leaves the capability behind every single time. The correct order is the reverse: install first, then &lt;code&gt;setcap&lt;/code&gt; the installed file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;rsync -a&lt;/code&gt; row&lt;/strong&gt; will bite hardest in practice, because &lt;code&gt;rsync&lt;/code&gt; is the deployment transport in most places and &lt;code&gt;-a&lt;/code&gt; has become a reflex. &lt;code&gt;-a&lt;/code&gt; isn't enough here either; &lt;code&gt;-X&lt;/code&gt; (&lt;code&gt;--xattrs&lt;/code&gt;) is required. The same reflex breaks with &lt;code&gt;tar&lt;/code&gt;, and there the measured detail is the one that made me think hardest. Comparing the two archives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plain.tar size: 1331200
xattr.tar size: 1331200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical. The twenty-byte attribute disappears into the archive's block padding. So you cannot answer "does my archive carry the attribute" by looking at file size; you have to unpack it and check with &lt;code&gt;getcap&lt;/code&gt;. It also matters that &lt;code&gt;--xattrs&lt;/code&gt; is required on &lt;strong&gt;both ends&lt;/strong&gt;: specify it only when packing and the archive will contain the attribute but extraction will drop it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;git&lt;/code&gt; row&lt;/strong&gt; isn't a shortcoming but a structural fact: git stores no extended attributes. A file that is &lt;code&gt;cap_net_raw=ep&lt;/code&gt; before the commit comes back bare after &lt;code&gt;git checkout&lt;/code&gt;. The practical consequence is that a capability can never live in a repository. That makes the "install first, then &lt;code&gt;setcap&lt;/code&gt;" rule a requirement rather than a suggestion. In production the place this happens is the packaging step: on the &lt;code&gt;.deb&lt;/code&gt; side &lt;code&gt;setcap&lt;/code&gt; is called from &lt;code&gt;postinst&lt;/code&gt; (exactly what Debian does for &lt;code&gt;clockdiff&lt;/code&gt;), and on the RPM side the &lt;code&gt;%caps&lt;/code&gt; directive does it.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mv&lt;/code&gt; row stands slightly apart as the only true &lt;em&gt;move&lt;/em&gt;: within one filesystem &lt;code&gt;mv&lt;/code&gt; is a rename, so the attribute simply stays with the file. Across filesystems it genuinely copies, and the documentation reads: &lt;em&gt;"&lt;code&gt;mv&lt;/code&gt; always tries to copy extended attributes (xattr)… Upon failure all warnings are emitted, but do not modify the exit status."&lt;/em&gt; So &lt;code&gt;mv&lt;/code&gt; warns but still exits 0. That yields a three-point spectrum: &lt;code&gt;--preserve=xattr&lt;/code&gt; errors on failure, &lt;code&gt;mv&lt;/code&gt; warns but doesn't change the exit status, &lt;code&gt;cp -a&lt;/code&gt; says nothing at all.&lt;/p&gt;

&lt;p&gt;The last row refuted my expectation. &lt;code&gt;COPY --from&lt;/code&gt; inside a Dockerfile &lt;em&gt;preserves&lt;/em&gt; the capability — I assumed it dropped it, and measuring showed the opposite:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#5 3.439 YAPICI KATMANDA:
#5 3.440 /td cap_net_raw=ep
#8 0.087 COPY --from SONRASI:
#8 0.088 /td-copy cap_net_raw=ep
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The labels in that log are the Turkish &lt;code&gt;echo&lt;/code&gt; strings from my Dockerfile: "in the builder layer" and "after COPY --from". The output is quoted verbatim.)&lt;/p&gt;

&lt;p&gt;In a multi-stage build, running &lt;code&gt;setcap&lt;/code&gt; in the builder stage and carrying the binary into the final image with &lt;code&gt;COPY --from&lt;/code&gt; works. I went looking for this in Docker's documentation and couldn't find it: &lt;code&gt;COPY&lt;/code&gt; is documented as preserving "file permissions" and says nothing about extended attributes. So the basis for that row isn't documentation, it's the build log above — and undocumented behaviour is behaviour that can change with a version. Measure it once on your own version; this one came out on Buildx v0.19.2.&lt;/p&gt;

&lt;p&gt;I can't say the same for an ordinary &lt;code&gt;COPY&lt;/code&gt; from the build context, though, because on my Mac a capability-carrying file cannot be put into the context at all. The reason comes a little further down.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;getcap&lt;/code&gt; is telling the truth, and the kernel grants nothing
&lt;/h2&gt;

&lt;p&gt;Now the measurement with the strongest smell of a trap. Two &lt;code&gt;tmpfs&lt;/code&gt; filesystems — one normal, one &lt;code&gt;nosuid&lt;/code&gt; — with the same binary and the same capability on both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mount &lt;span class="nt"&gt;-t&lt;/span&gt; tmpfs &lt;span class="nt"&gt;-o&lt;/span&gt; nosuid tmpfs /mnt/nosuid
mount &lt;span class="nt"&gt;-t&lt;/span&gt; tmpfs tmpfs /mnt/suid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--- /mnt/suid    on file: cap_net_raw=ep   -&amp;gt; Current: cap_net_raw=ep
--- /mnt/nosuid  on file: cap_net_raw=ep   -&amp;gt; Current: =
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that on the &lt;code&gt;nosuid&lt;/code&gt; side &lt;code&gt;getcap&lt;/code&gt; still reports &lt;code&gt;cap_net_raw=ep&lt;/code&gt; on the file. The attribute really is there; &lt;code&gt;getcap&lt;/code&gt; isn't lying. But the process receives nothing. Before reading the attribute the kernel looks at the mount, and if it's &lt;code&gt;nosuid&lt;/code&gt; it skips file capabilities entirely. The code is in &lt;code&gt;get_file_caps&lt;/code&gt;, two lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;mnt_may_suid&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;file&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;f_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mnt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The auditing consequence is this: &lt;strong&gt;&lt;code&gt;getcap&lt;/code&gt; alone is not sufficient as an audit tool.&lt;/strong&gt; It answers "which files carry a capability"; it does not answer "which files can actually &lt;em&gt;use&lt;/em&gt; one". For the second question you also have to look at mount options — and &lt;code&gt;/tmp&lt;/code&gt;, &lt;code&gt;/dev/shm&lt;/code&gt;, many &lt;code&gt;/home&lt;/code&gt; mounts and most mounts inside containers are already &lt;code&gt;nosuid&lt;/code&gt;. Just above those two lines there's also a &lt;code&gt;file_caps_enabled&lt;/code&gt; check; file capabilities can be switched off wholesale at kernel build or boot time.&lt;/p&gt;

&lt;p&gt;The place this rule bit me directly was the bind-mounted directory from my Mac. Docker Desktop shows the mount like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fakeowner rw,nosuid,nodev,relatime,fakeowner
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trying &lt;code&gt;setcap&lt;/code&gt; there simply didn't work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Failed to set capabilities on file 'setcap': Operation not supported
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code 1. So anyone bind-mounting a directory from their Mac and trying to grant a capability to a binary inside it hits two walls at once: the filesystem can't hold the &lt;code&gt;security.capability&lt;/code&gt; attribute at all, and the mount is &lt;code&gt;nosuid&lt;/code&gt; anyway. The same limit applies to NFS and to many read-only image layers; the issue isn't macOS, it's whether the filesystem can hold the attribute. The practical upshot is clear — a capability-carrying binary has to live &lt;em&gt;inside&lt;/em&gt; the container image, not on a mounted volume. (The error message naming the file &lt;code&gt;'setcap'&lt;/code&gt; is a quirk of libcap 2.75's own wording; it doesn't change the measurement, but it did sidetrack me for a minute the first time I saw it.)&lt;/p&gt;

&lt;p&gt;Writing the capability needs its own permission, too. In a container with &lt;code&gt;CAP_SETFCAP&lt;/code&gt; dropped, &lt;code&gt;setcap&lt;/code&gt; fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;unable to set CAP_SETFCAP effective capability: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that came back as uid 0 — being root inside the container is not enough to write a file capability. On the kernel side the corresponding check is &lt;code&gt;capable_wrt_inode_uidgid(idmap, inode, CAP_SETFCAP)&lt;/code&gt; inside &lt;code&gt;cap_convert_nscap&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bounding-set ceiling: a wrong capability locks the binary
&lt;/h2&gt;

&lt;p&gt;This is the finding with the most potential to hurt. Granting the same binary a capability the container does not have:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setcap cap_sys_time+ep /opt/t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result isn't the program failing — it's the program &lt;strong&gt;never starting&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;bash: line 1: /opt/t: Operation not permitted
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exit code the shell reports is 126. Tried as root too: same error, same 126. So a single wrong &lt;code&gt;setcap&lt;/code&gt; call renders a working binary unrunnable for &lt;em&gt;everyone&lt;/em&gt; in that container. You can't even get &lt;code&gt;--version&lt;/code&gt; out of it.&lt;/p&gt;

&lt;p&gt;A note on that number: 126 is the shell's language, not the kernel's. The &lt;code&gt;execve()&lt;/code&gt; call returns &lt;code&gt;EPERM&lt;/code&gt;; it's bash that renders "found but not executable" as 126. The same failure appears as &lt;code&gt;203/EXEC&lt;/code&gt; under systemd, and as plain &lt;code&gt;errno == EPERM&lt;/code&gt; in a program that calls &lt;code&gt;execve()&lt;/code&gt; directly. When you write a monitoring rule, the thing to match is not a single number.&lt;/p&gt;

&lt;p&gt;The cause is the container's capability bounding set. The &lt;code&gt;CapBnd&lt;/code&gt; value in &lt;code&gt;/proc/self/status&lt;/code&gt; is &lt;code&gt;00000000a80425fb&lt;/code&gt;, which decodes to 14 capabilities:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cap_chown, cap_dac_override, cap_fowner, cap_fsetid, cap_kill,
cap_setgid, cap_setuid, cap_setpcap, cap_net_bind_service,
cap_net_raw, cap_sys_chroot, cap_mknod, cap_audit_write, cap_setfcap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That set of 14 is Docker's default; it matches the &lt;code&gt;DefaultCapabilities&lt;/code&gt; list in the &lt;code&gt;moby&lt;/code&gt; source exactly, and &lt;code&gt;cap_sys_time&lt;/code&gt; is not in it. (A note on naming: "bounding set" is my term — Docker's documentation only speaks of capabilities being "kept" or "dropped" and never says which set it means; the 14 were read from &lt;code&gt;CapBnd&lt;/code&gt; directly.)&lt;/p&gt;

&lt;p&gt;The kernel states plainly what it does here. The formula in &lt;code&gt;bprm_caps_from_vfs_caps&lt;/code&gt; and the check right after it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="cm"&gt;/*
 * pP' = (X &amp;amp; fP) | (pI &amp;amp; fI)
 * The addition of pA' is handled later.
 */&lt;/span&gt;
&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cap_permitted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cap_bset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;caps&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;permitted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cap_inheritable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;caps&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;inheritable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caps&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;permitted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;cap_permitted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;val&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="cm"&gt;/* insufficient to execute correctly */&lt;/span&gt;
    &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;EPERM&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;X&lt;/code&gt; in the formula is the bounding set. The file wants &lt;code&gt;cap_sys_time&lt;/code&gt;, the bounding set doesn't have it, the intersection comes out empty — and when a bit the file asked for is missing from the result, the kernel returns &lt;code&gt;-EPERM&lt;/code&gt;. The comment states the intent: a program running with a capability missing would do the wrong thing, so the kernel chooses not to start it at all.&lt;/p&gt;

&lt;p&gt;The next line of that code predicted something that hadn't been measured yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;effective&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="n"&gt;ret&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EPERM&lt;/code&gt; is returned only if the effective bit is set. So granting the same wrong capability with &lt;code&gt;+p&lt;/code&gt; should let the program start. Setting up that experiment:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;marking on the file&lt;/th&gt;
&lt;th&gt;exit code&lt;/th&gt;
&lt;th&gt;process's &lt;code&gt;Current:&lt;/code&gt; line&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_sys_time+ep&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;126, &lt;code&gt;Operation not permitted&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;(never ran)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cap_sys_time+p&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;=&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prediction held exactly. With &lt;code&gt;+p&lt;/code&gt; the program starts fine — and receives no capability at all. The choice between them is a choice between a loud failure and silent nothing. Which is worse depends on your setup: your monitoring will catch the 126 from &lt;code&gt;+ep&lt;/code&gt; immediately, whereas the silence of &lt;code&gt;+p&lt;/code&gt; only surfaces when the program tries to open a raw socket and fails, probably in the middle of the night.&lt;/p&gt;

&lt;p&gt;The same experiment again with one change — giving the container the capability:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--cap-add&lt;/span&gt; SYS_TIME ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bounding set went from 14 to 15, &lt;code&gt;cap_sys_time&lt;/code&gt; joined it, and the binary marked &lt;code&gt;cap_sys_time+ep&lt;/code&gt; ran with exit code 0. Same file, same &lt;code&gt;setcap&lt;/code&gt;, same kernel; the only variable was the bounding set. That's the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the decision flows at execve
&lt;/h2&gt;

&lt;p&gt;Every behaviour recorded above sits on a single decision chain:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiZXhlY3ZlOiBmaWxlIGhhcyBzZWN1cml0eS5jYXBhYmlsaXR5Il0gLS0-IEJ7ImlzIGZpbGVfY2Fwc19lbmFibGVkIG9mZiJ9CiAgQiAtLSAib2ZmIiAtLT4gWlsibm8gY2FwYWJpbGl0eSBncmFudGVkPGJyLz5wcm9jZXNzIHN0YXJ0cyBiYXJlIl0KICBCIC0tICJvbiIgLS0-IEN7ImlzIHRoZSBtb3VudCBub3N1aWQifQogIEMgLS0gIm5vc3VpZCIgLS0-IFoKICBDIC0tICJub3JtYWwiIC0tPiBEWyJjb21wdXRlIHBQJzo8YnIvPmludGVyc2VjdCB3aXRoIGJvdW5kaW5nIHNldCJdCiAgRCAtLT4gRXsiaXMgdGhlIHJlcXVlc3RlZCBiaXQ8YnIvPmluIHRoZSBib3VuZGluZyBzZXQifQogIEUgLS0gIm5vIiAtLT4gRnsiaXMgdGhlIGVmZmVjdGl2ZSBiaXQgc2V0In0KICBGIC0tICJzZXQiIC0tPiBHWyJleGVjdmUgRVBFUk08YnIvPnNoZWxsIDEyNiwgc3lzdGVtZCAyMDMvRVhFQyJdCiAgRiAtLSAibm90IHNldCIgLS0-IFoKICBFIC0tICJ5ZXMiIC0tPiBIeyJpcyB0aGUgZWZmZWN0aXZlIGJpdCBzZXQifQogIEggLS0gInNldCIgLS0-IElbImNhcGFiaWxpdHkgaW4gZWZmZWN0aXZlIHNldDxici8-cHJvZ3JhbSB1c2VzIGl0IGRpcmVjdGx5Il0KICBIIC0tICJub3Qgc2V0IiAtLT4gSlsiY2FwYWJpbGl0eSBvbmx5IGluIHBlcm1pdHRlZCBzZXQ8YnIvPnByb2dyYW0gbXVzdCBjYWxsIGNhcHNldCJd%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiZXhlY3ZlOiBmaWxlIGhhcyBzZWN1cml0eS5jYXBhYmlsaXR5Il0gLS0-IEJ7ImlzIGZpbGVfY2Fwc19lbmFibGVkIG9mZiJ9CiAgQiAtLSAib2ZmIiAtLT4gWlsibm8gY2FwYWJpbGl0eSBncmFudGVkPGJyLz5wcm9jZXNzIHN0YXJ0cyBiYXJlIl0KICBCIC0tICJvbiIgLS0-IEN7ImlzIHRoZSBtb3VudCBub3N1aWQifQogIEMgLS0gIm5vc3VpZCIgLS0-IFoKICBDIC0tICJub3JtYWwiIC0tPiBEWyJjb21wdXRlIHBQJzo8YnIvPmludGVyc2VjdCB3aXRoIGJvdW5kaW5nIHNldCJdCiAgRCAtLT4gRXsiaXMgdGhlIHJlcXVlc3RlZCBiaXQ8YnIvPmluIHRoZSBib3VuZGluZyBzZXQifQogIEUgLS0gIm5vIiAtLT4gRnsiaXMgdGhlIGVmZmVjdGl2ZSBiaXQgc2V0In0KICBGIC0tICJzZXQiIC0tPiBHWyJleGVjdmUgRVBFUk08YnIvPnNoZWxsIDEyNiwgc3lzdGVtZCAyMDMvRVhFQyJdCiAgRiAtLSAibm90IHNldCIgLS0-IFoKICBFIC0tICJ5ZXMiIC0tPiBIeyJpcyB0aGUgZWZmZWN0aXZlIGJpdCBzZXQifQogIEggLS0gInNldCIgLS0-IElbImNhcGFiaWxpdHkgaW4gZWZmZWN0aXZlIHNldDxici8-cHJvZ3JhbSB1c2VzIGl0IGRpcmVjdGx5Il0KICBIIC0tICJub3Qgc2V0IiAtLT4gSlsiY2FwYWJpbGl0eSBvbmx5IGluIHBlcm1pdHRlZCBzZXQ8YnIvPnByb2dyYW0gbXVzdCBjYWxsIGNhcHNldCJd%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="1139" height="1524"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The order in that diagram is itself a claim: the &lt;code&gt;nosuid&lt;/code&gt; check comes &lt;strong&gt;before&lt;/strong&gt; the bounding-set arithmetic. That's why granting a wrong capability on a &lt;code&gt;nosuid&lt;/code&gt; mount produces no &lt;code&gt;EPERM&lt;/code&gt; — the kernel skipped the attribute before it ever got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Secure-exec: you get the side effect too
&lt;/h2&gt;

&lt;p&gt;The last measurement is a behaviour a file capability inherits whether you want it or not. Two binaries, one with a capability and one bare, both asked to preload a nonexistent library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;su deneme &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"LD_PRELOAD=/yok/kitaplik.so /opt/ciplak --version"&lt;/span&gt;
su deneme &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"LD_PRELOAD=/yok/kitaplik.so /opt/td --version"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bare binary complained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR: ld.so: object '/yok/kitaplik.so' from LD_PRELOAD cannot be preloaded (cannot open shared object file): ignored.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The capability-carrying binary said nothing at all; it printed its version line straight away. Because an exec that gains a capability triggers the kernel's secure-execution mode, and in that mode the dynamic loader silently ignores variables like &lt;code&gt;LD_PRELOAD&lt;/code&gt; and &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;su deneme&lt;/code&gt; part is essential, because the condition is not merely "a capability was gained". The assignment in &lt;code&gt;commoncap.c&lt;/code&gt; reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;__is_real&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;root_uid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
 &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;effective&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="n"&gt;__cap_grew&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;permitted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ambient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
    &lt;span class="n"&gt;bprm&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;secureexec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;!__is_real(root_uid, new)&lt;/code&gt; — a caller whose &lt;em&gt;real&lt;/em&gt; uid is &lt;strong&gt;not&lt;/strong&gt; root. Run the same command as root and &lt;code&gt;secureexec&lt;/code&gt; stays 0, &lt;code&gt;LD_PRELOAD&lt;/code&gt; is honoured, and the capability-carrying binary prints the very same &lt;code&gt;ld.so&lt;/code&gt; error. A file capability does not put root into secure-exec. (The capability-related part of this condition is identical in v6.10 and v7.3; the setuid checks around it changed.)&lt;/p&gt;

&lt;p&gt;Security-wise this is what we want: a privileged binary should not accept injected code from outside. But it has a side effect that can bite you in practice: if you have a wrapper script that wraps the binary with &lt;code&gt;LD_PRELOAD&lt;/code&gt; or a custom &lt;code&gt;LD_LIBRARY_PATH&lt;/code&gt;, the day you apply &lt;code&gt;setcap&lt;/code&gt; that wrapper quietly stops mattering. It doesn't error. It just no longer does anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to avoid granting it at all
&lt;/h2&gt;

&lt;p&gt;Before the checklist, a reminder of the frame from the opening: the best capability is the one you don't grant. If you're writing a service you may not need &lt;code&gt;setcap&lt;/code&gt; at all, because systemd can hand the capability to the process rather than the file — via &lt;code&gt;AmbientCapabilities=&lt;/code&gt; and &lt;code&gt;CapabilityBoundingSet=&lt;/code&gt;. The binary then stays bare, the attribute-transport problem disappears entirely, and the twelve-row table above stops being your concern. I covered the neighbours of those settings on the &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/systemd-service-sandbox-ile-sertlestirme/" rel="noopener noreferrer"&gt;systemd service hardening&lt;/a&gt; side.&lt;/p&gt;

&lt;p&gt;There's a warning in the same place: setting &lt;code&gt;NoNewPrivileges=yes&lt;/code&gt; silently disables file capabilities — the container equivalents being &lt;code&gt;--security-opt no-new-privileges&lt;/code&gt; and Kubernetes' &lt;code&gt;allowPrivilegeEscalation: false&lt;/code&gt;. So your hardening setting can cancel your &lt;code&gt;setcap&lt;/code&gt; solution without telling you. That is the last of the silent failures on this list.&lt;/p&gt;

&lt;p&gt;The second route is replacing the capability with a different mechanism altogether. &lt;code&gt;ping&lt;/code&gt; moving to &lt;code&gt;ping_group_range&lt;/code&gt; is the example. Similarly, binding to a privileged port can use &lt;code&gt;net.ipv4.ip_unprivileged_port_start&lt;/code&gt; or socket activation instead of &lt;code&gt;cap_net_bind_service&lt;/code&gt;. In &lt;a href="https://mustafaerbay.com.tr/en/blog/technology/kardese-yasak-toruna-serbest-ptrace-scope-1-kimi-durduruyor/" rel="noopener noreferrer"&gt;the article on ptrace_scope&lt;/a&gt; I noted the reverse direction: some capabilities (&lt;code&gt;cap_sys_ptrace&lt;/code&gt; among them) are in practice aliases for root, and granting them is no safer than setuid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation checklist
&lt;/h2&gt;

&lt;p&gt;The order these measurements produced, and what I apply in my own setups:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First ask whether you need the capability at all.&lt;/strong&gt; If systemd's &lt;code&gt;AmbientCapabilities=&lt;/code&gt;, a suitable sysctl or socket activation will do, don't touch the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always grant the capability after installation.&lt;/strong&gt; &lt;code&gt;install&lt;/code&gt; never carries the attribute by definition and git never stores it, so a capability can only be granted at the packaging/installation step (&lt;code&gt;.deb&lt;/code&gt; &lt;code&gt;postinst&lt;/code&gt;, RPM &lt;code&gt;%caps&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In automation, write &lt;code&gt;--preserve=xattr&lt;/code&gt; rather than &lt;code&gt;cp -a&lt;/code&gt;, and &lt;code&gt;rsync -aX&lt;/code&gt; rather than &lt;code&gt;rsync -a&lt;/code&gt;.&lt;/strong&gt; The first errors on failure; the second adds the attribute &lt;code&gt;-a&lt;/code&gt; skips.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choose &lt;code&gt;+ep&lt;/code&gt; or &lt;code&gt;+p&lt;/code&gt; by looking at the program.&lt;/strong&gt; If it doesn't call &lt;code&gt;capset&lt;/code&gt;, use &lt;code&gt;+ep&lt;/code&gt;. &lt;code&gt;+p&lt;/code&gt; is meaningful for capability-aware programs; otherwise it hands you silent nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify the capability is in the bounding set before you grant it.&lt;/strong&gt; Check with &lt;code&gt;capsh --decode=$(grep CapBnd /proc/self/status | awk '{print $2}')&lt;/code&gt;; writing one that isn't there with &lt;code&gt;+ep&lt;/code&gt; makes the binary unrunnable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check that the mount the file lives on isn't &lt;code&gt;nosuid&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;findmnt -no OPTIONS &amp;lt;path&amp;gt;&lt;/code&gt;. A positive answer from &lt;code&gt;getcap&lt;/code&gt; does not replace this check. Scan for &lt;code&gt;NoNewPrivileges&lt;/code&gt; / &lt;code&gt;no-new-privileges&lt;/code&gt; with the same eye.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In containers, keep the capability-carrying binary inside the image.&lt;/strong&gt; Mounted volumes are &lt;code&gt;nosuid&lt;/code&gt;, and macOS mounts and NFS can't hold the attribute at all. Running rootless, expect the v3 attribute layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audit with two questions:&lt;/strong&gt; which files have the attribute (&lt;code&gt;getcap -r /&lt;/code&gt;) &lt;em&gt;and&lt;/em&gt; whether those files sit on a non-&lt;code&gt;nosuid&lt;/code&gt; mount. The first question alone misleads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure your backup and restore path once.&lt;/strong&gt; Is &lt;code&gt;--xattrs&lt;/code&gt; on both ends of your &lt;code&gt;tar&lt;/code&gt;, does your &lt;code&gt;rsync&lt;/code&gt; get &lt;code&gt;-X&lt;/code&gt;, does your image build carry the attribute — file size won't tell you, &lt;code&gt;getcap&lt;/code&gt; will.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;File capabilities are better than setuid; that still holds after measuring. But the difference between the two models isn't only the breadth of privilege granted — it's visibility. The setuid bit lives in the file mode, and every tool knows and carries the mode. A file capability lives in an extended attribute, and extended attributes are a data type that falls off by default all along the plumbing: dropped by &lt;code&gt;cp&lt;/code&gt;, dropped by &lt;code&gt;install&lt;/code&gt; by design, dropped by &lt;code&gt;tar&lt;/code&gt; and &lt;code&gt;rsync&lt;/code&gt; unless you ask for a flag, never held by git, present but inert on a &lt;code&gt;nosuid&lt;/code&gt; mount, unwritable on a directory mounted from a Mac.&lt;/p&gt;

&lt;p&gt;Today's real lesson: in the capability model, the mistake rarely arrives as "I granted the wrong capability". It arrives as "I granted the right capability, then moved the file". And outside two edge cases — the binary that refuses to run and the silence of &lt;code&gt;ld.so&lt;/code&gt; — the system doesn't tell you. Six of twelve transfer routes quietly leaving the capability behind shows that the discipline isn't in granting it, it's in everything after.&lt;/p&gt;

&lt;p&gt;So the question to ask about your own setup: how many transfer steps did your capability-carrying binary take to reach where it sits in production today, and which of those steps was verified with &lt;code&gt;getcap&lt;/code&gt;?&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/c03c1a14507dfaf8b856c2608f9ac216021fc222/security/commoncap.c" rel="noopener noreferrer"&gt;Linux 7.3 (commit c03c1a1) — security/commoncap.c: how file capabilities are processed at exec&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/torvalds/linux/blob/c03c1a14507dfaf8b856c2608f9ac216021fc222/include/uapi/linux/capability.h" rel="noopener noreferrer"&gt;Linux 7.3 (commit c03c1a1) — include/uapi/linux/capability.h: capability numbers, XATTR_CAPS_SZ, VFS_CAP_FLAGS_EFFECTIVE&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/coreutils/coreutils/blob/master/doc/coreutils.texi" rel="noopener noreferrer"&gt;GNU coreutils manual — extended-attribute behaviour of cp, install and mv&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/moby/moby/blob/master/daemon/pkg/oci/caps/defaults.go" rel="noopener noreferrer"&gt;moby — DefaultCapabilities: a container's default 14 capabilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/security/" rel="noopener noreferrer"&gt;Docker Engine security documentation — Linux kernel capabilities&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/containers/run/" rel="noopener noreferrer"&gt;Docker — runtime privilege and Linux capabilities (--cap-add / --cap-drop)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://metadata.ftp-master.debian.org/changelogs/main/i/iputils/iputils_20240905-3_changelog" rel="noopener noreferrer"&gt;Debian iputils changelog — ping leaving capabilities and setuid behind&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>capabilities</category>
      <category>security</category>
      <category>docker</category>
    </item>
    <item>
      <title>A 9 MiB Clone, Then Checkout Fetched 204 MiB More</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Thu, 22 Oct 2026 17:20:50 +0000</pubDate>
      <link>https://dev.to/merbayerp/a-9-mib-clone-then-checkout-fetched-204-mib-more-1ak2</link>
      <guid>https://dev.to/merbayerp/a-9-mib-clone-then-checkout-fetched-204-mib-more-1ak2</guid>
      <description>&lt;p&gt;This blog's repository got fat: 3,538 commits, 46,219 objects, a 276 MiB pack. The weight is not in the history, it is in the present — every article also has a cover PNG, and PNGs do not compress. Once cloning started to annoy me, my reflex was everybody's reflex: &lt;code&gt;--depth 1&lt;/code&gt;. Shallow clone. Drop the history, take the current state, get on with it.&lt;/p&gt;

&lt;p&gt;The reflex was wrong. &lt;code&gt;--depth 1&lt;/code&gt; fetched 204.3 MiB; &lt;code&gt;--filter=blob:none&lt;/code&gt; fetched 9.2 MiB. A twenty-two-fold difference, and against the shallow clone. The real lesson came afterwards: those 9 MiB were not a saving, they were a &lt;strong&gt;loan&lt;/strong&gt;. The first &lt;code&gt;git checkout&lt;/code&gt; showed up at the door and collected 204.1 MiB in one payment. A saving that was 30-fold at clone time came down to 1.3-fold once the repository was workable.&lt;/p&gt;

&lt;p&gt;Three tables, three traps, and at the end the one combination that actually stays small. Plus a constraint in my own CI that I do not like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ground the measurements stand on
&lt;/h2&gt;

&lt;p&gt;Environment: macOS (arm64), Homebrew git 2.48.1. Everything was taken against a &lt;strong&gt;single-branch&lt;/strong&gt; bare copy derived from the repository's &lt;code&gt;origin/main&lt;/code&gt; tip — &lt;code&gt;df0de9d4&lt;/code&gt;, 22 October 2026:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git init &lt;span class="nt"&gt;--bare&lt;/span&gt; srv.git
&lt;span class="nv"&gt;$ &lt;/span&gt;git push srv.git origin/main:refs/heads/main
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; srv.git config uploadpack.allowFilter &lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; srv.git rev-list &lt;span class="nt"&gt;--count&lt;/span&gt; HEAD      &lt;span class="c"&gt;# 3538&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; srv.git count-objects &lt;span class="nt"&gt;-v&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="nt"&gt;-pack&lt;/span&gt;   &lt;span class="c"&gt;# in-pack: 46219&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The single branch matters: &lt;code&gt;rev-list --count HEAD&lt;/code&gt; and &lt;code&gt;--all&lt;/code&gt; give the same number, and all 46,219 objects in the pack are reachable. That removes the question "which ref set does this number come from".&lt;/p&gt;

&lt;p&gt;There is also the matter of the unit. The first round used &lt;code&gt;du -s&lt;/code&gt;, and APFS told a lie: two pack files of 289,923,968 bytes &lt;strong&gt;with identical md5 sums&lt;/strong&gt; occupied 566,264 and 590,824 512-byte blocks in two different clones. Same content, 4.3 percent apart. So every number below is not &lt;code&gt;du&lt;/code&gt; but the &lt;strong&gt;byte sum of the pack files&lt;/strong&gt; — unchanged by whatever the filesystem decides to do, and the quantity we actually care about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first two traps: the filter drops out quietly
&lt;/h2&gt;

&lt;p&gt;The first attempt at a filtered clone gave a local &lt;strong&gt;path&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C git clone &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--no-checkout&lt;/span&gt; /tmp/lab/srv.git a1
Cloning into &lt;span class="s1"&gt;'a1'&lt;/span&gt;...
warning: &lt;span class="nt"&gt;--filter&lt;/span&gt; is ignored &lt;span class="k"&gt;in &lt;/span&gt;&lt;span class="nb"&gt;local &lt;/span&gt;clones&lt;span class="p"&gt;;&lt;/span&gt; use file:// instead.
&lt;span class="k"&gt;done&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding the &lt;code&gt;file://&lt;/code&gt; prefix fixed that. Then the second road: right prefix, but the server setting off.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; srv.git config &lt;span class="nt"&gt;--unset&lt;/span&gt; uploadpack.allowFilter
&lt;span class="nv"&gt;$ LC_ALL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;C git clone &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--no-checkout&lt;/span&gt; &lt;span class="s2"&gt;"file:///tmp/lab/srv.git"&lt;/span&gt; a2
Cloning into &lt;span class="s1"&gt;'a2'&lt;/span&gt;...
warning: filtering not recognized by server, ignoring
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both warn, both return &lt;strong&gt;zero&lt;/strong&gt;, and both produce a full clone — 21,213 blobs, 276 MiB. Filtering has two separate gates, and with either one shut the command still counts as a success. The server-side gate is &lt;code&gt;uploadpack.allowFilter&lt;/code&gt;; its documentation says what the setting does ("if this option is set, &lt;code&gt;upload-pack&lt;/code&gt; will support partial clone and partial fetch object filtering") but not what happens when it is unset. The measurement says: the request is not refused, it falls back to a full clone without a sound.&lt;/p&gt;

&lt;p&gt;The genuinely unpleasant part is common to both. The filter was not applied, yet Git still wrote this into the config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; a2 config &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--list&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'promisor|partialclone'&lt;/span&gt;
remote.origin.promisor&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;remote.origin.partialclonefilter&lt;span class="o"&gt;=&lt;/span&gt;blob:none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository is complete but &lt;strong&gt;labelled&lt;/strong&gt; a partial clone, and later fetches honour the label. In the &lt;code&gt;file://&lt;/code&gt;-with-the-setting-off case Git also left a &lt;code&gt;.promisor&lt;/code&gt; file behind. That file is not a symptom — every partial clone has one. The oddity here is that the pack behind it is complete.&lt;/p&gt;

&lt;p&gt;So how do you spot it? The two indicators that come to mind first both turn out to be useless: &lt;code&gt;git config remote.origin.promisor&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt; in a successful partial clone too, and the &lt;code&gt;warning: This repository uses promisor remotes. Some objects may not be loaded.&lt;/code&gt; line that appears on commands touching the object store shows up in that clone too. Both tell you the repository is &lt;em&gt;labelled&lt;/em&gt; partial; your question is whether the filter was applied. The one thing that separates them is counting blobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; a2 cat-file &lt;span class="nt"&gt;--batch-all-objects&lt;/span&gt; &lt;span class="nt"&gt;--batch-check&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $2}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt;
21213 blob
 3538 commit
21468 tree
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you were expecting zero blobs, that table answers the question: 21,213 in the degraded clone, 0 in the successful one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The clone ladder
&lt;/h2&gt;

&lt;p&gt;All taken with &lt;code&gt;--no-checkout&lt;/code&gt;, so the only thing compared is the set of downloaded objects. The server's own pack is 289,908,372 bytes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;clone&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;pack fetched&lt;/th&gt;
&lt;th&gt;objects&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;full&lt;/td&gt;
&lt;td&gt;2.9 s&lt;/td&gt;
&lt;td&gt;289,923,968 B — 276.5 MiB&lt;/td&gt;
&lt;td&gt;21,213 blobs · 3,538 commits · 21,468 trees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--depth 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2.0 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;214,235,359 B — 204.3 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6,321 blobs · 1 commit · 84 trees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--filter=blob:limit=10k&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.5 s&lt;/td&gt;
&lt;td&gt;14,034,161 B — 13.4 MiB&lt;/td&gt;
&lt;td&gt;8,371 blobs · 3,538 commits · 21,468 trees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--filter=blob:none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.4 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;9,656,017 B — 9.2 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0 blobs · 3,538 commits · 21,468 trees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--filter=tree:0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1 s&lt;/td&gt;
&lt;td&gt;1,086,548 B — 1.0 MiB&lt;/td&gt;
&lt;td&gt;3,538 commits only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why the shallow clone is so large sits in the blob column: 6,321 blobs. &lt;code&gt;--depth 1&lt;/code&gt; cuts the history but brings &lt;strong&gt;the complete photograph of HEAD&lt;/strong&gt; — every file as it stands today, cover PNGs included. Dropping the history takes 276.5 down to 204.3, twenty-six percent. Dropping the blobs takes it to 9.2.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;tree:0&lt;/code&gt; row is instructive too. The local man page (2.48.1) says of &lt;code&gt;tree:&amp;lt;depth&amp;gt;&lt;/code&gt;: it omits all blobs and trees whose depth from the root tree is greater than or equal to &lt;code&gt;&amp;lt;depth&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;depth&amp;gt;=0&lt;/code&gt; will not include any trees or blobs unless given explicitly on the command line. That is exactly the table — 3,538 commits, nothing else, 1 MiB.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;blob:limit=10k&lt;/code&gt; is there because its documentation says it omits blobs of size at least &lt;code&gt;&amp;lt;n&amp;gt;&lt;/code&gt; bytes, meaning it &lt;strong&gt;keeps&lt;/strong&gt; the small ones. Your text files are local, the PNGs are remote: 13.4 MiB. A reasonable middle road for anyone who wants the code but not the assets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loan: the first checkout
&lt;/h2&gt;

&lt;p&gt;Starting work in the blobless clone changes the table. Pack bytes were re-totalled after each command:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;command&lt;/th&gt;
&lt;th&gt;time&lt;/th&gt;
&lt;th&gt;fetched&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;em&gt;(clone)&lt;/em&gt;&lt;/td&gt;
&lt;td&gt;0.4 s&lt;/td&gt;
&lt;td&gt;9,656,017 B — 9.2 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git checkout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3.6 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+214,046,263 B — 204.1 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;git log --oneline&lt;/code&gt; (3,538 lines)&lt;/td&gt;
&lt;td&gt;0.0 s&lt;/td&gt;
&lt;td&gt;0 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git log -p -1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1 s&lt;/td&gt;
&lt;td&gt;+15,379 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git blame README.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.2 s&lt;/td&gt;
&lt;td&gt;+12,571 B&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git log -p -20&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.9 s&lt;/td&gt;
&lt;td&gt;+1,653,754 B — 1.6 MiB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything after the first row is good news: walking the history is free. Listing 3,538 commits fetches not one byte, because the commits and the trees are already here. Ask for a patch and Git goes for that commit's blobs; twenty commits' worth of patches costs 1.6 MiB. For someone who says "I want to look at the past, but not at every version of every file", the model fits.&lt;/p&gt;

&lt;p&gt;The bad news is the second row. &lt;code&gt;git checkout&lt;/code&gt; alone fetched 204.1 MiB. The clone was 9.2 MiB, the working repository is 213.3 MiB, a full clone is 276.5 MiB. The saving that was 30-fold at clone time is now 1.3-fold.&lt;/p&gt;

&lt;p&gt;Why is in the partial clone documentation, and the mechanism was fixed by a patch: &lt;code&gt;checkout&lt;/code&gt; (and any other command using &lt;code&gt;unpack-trees&lt;/code&gt;) has been taught to bulk pre-fetch all required missing blobs in a single batch. Without it things would be worse; the same document says dynamic object fetching tends to be slow as objects are fetched one at a time, and among the known limitations it lists that dynamic fetching invokes &lt;code&gt;fetch-pack&lt;/code&gt; once for each item, which may incur significant overhead — and multiple authentication requests — if many objects are needed. The batch pre-fetch is why it finished in 3.6 seconds.&lt;/p&gt;

&lt;p&gt;That one-at-a-time fetching leaves a trace on disk. After the five commands above the repository held &lt;strong&gt;27 pack files&lt;/strong&gt;, every one of them marked promisor: each lazy fetch leaves its own pack. I was about to write something here, and the measurement stopped me. The partial clone design document says &lt;code&gt;gc&lt;/code&gt; does "not touch promisor packfiles at all"; but the same document's "Current Limitations" section says &lt;code&gt;repack&lt;/code&gt; treats promisor and non-promisor packfiles as two distinct partitions and does not mix them. The document contradicts itself: the first sentence has gone stale, the second matches both &lt;code&gt;git-repack&lt;/code&gt;'s 2.48.1 man page and today's behaviour. The measurement backs the second — running &lt;code&gt;git gc&lt;/code&gt; took those 27 packs down to a single promisor pack. That consolidation came from the &lt;code&gt;gc&lt;/code&gt; I ran by hand; at 27, below &lt;code&gt;gc.autoPackLimit&lt;/code&gt;'s default of 50, automatic maintenance would not have fired yet. In a long-lived copy, once the threshold is crossed, it tidies up on its own. For the record: that one sentence is the stale part. The batch pre-fetch, the per-object &lt;code&gt;fetch-pack&lt;/code&gt; and the online requirement I take from the same document all agree with the man pages and with the measurements.&lt;/p&gt;

&lt;p&gt;In the treeless clone the ratio is wilder still: a 1,086,548-byte repository climbs to 215,334,800 bytes after &lt;code&gt;checkout&lt;/code&gt; — 198-fold. In the blobless clone that multiplier is 23. The absolute figure is smaller than the blobless clone's 213.3 MiB (205.4), but it shows well how little the number you see at clone time tells you.&lt;/p&gt;

&lt;p&gt;The rule that falls out: &lt;strong&gt;&lt;code&gt;--filter&lt;/code&gt; has deferred the download, not reduced it.&lt;/strong&gt; If you are going to open the whole working tree, you pay the loan at the first &lt;code&gt;checkout&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shallow clone's bill is not in size, it is in correctness
&lt;/h2&gt;

&lt;p&gt;Size is not the shallow clone's only problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; shallow rev-list &lt;span class="nt"&gt;--count&lt;/span&gt; HEAD
1
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; blobless rev-list &lt;span class="nt"&gt;--count&lt;/span&gt; HEAD
3538
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The number dropping was expected. What is uncomfortable is the same line of the same file across the two clones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; shallow blame README.md | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
^df0de9d &lt;span class="o"&gt;(&lt;/span&gt;Social Bot 2026-10-22 07:15:19 +0000   1&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c"&gt;# Mustafa Erbay — Blog &amp;amp; Teknoloji&lt;/span&gt;
&lt;span class="nv"&gt;$ &lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; blobless blame README.md | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;
d263086f6 &lt;span class="o"&gt;(&lt;/span&gt;mustafaerbay 2026-06-09 20:55:09 +0300   1&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="c"&gt;# Mustafa Erbay — Blog &amp;amp; Teknoloji&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;git blame&lt;/code&gt; does not fail. It runs, it produces output, and it attributes that line to the commit a social-posting bot pushed that same morning. The true answer is 9 June, a different hand. The leading &lt;code&gt;^&lt;/code&gt; marks a boundary commit, but to an eye not trained on that table this is simply a wrong date and a wrong name. A slow tool is liveable; a wrong one is not.&lt;/p&gt;

&lt;p&gt;One more side effect is in the documentation: &lt;code&gt;--depth&lt;/code&gt; implies &lt;code&gt;--single-branch&lt;/code&gt; unless &lt;code&gt;--no-single-branch&lt;/code&gt; is given. For CI that is usually right; but the answer to "why does &lt;code&gt;git fetch&lt;/code&gt; not see that branch" lands here months later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The combination that actually stays small
&lt;/h2&gt;

&lt;p&gt;The only way not to pay the loan is never to open the whole working tree. &lt;code&gt;git clone&lt;/code&gt; carries &lt;code&gt;--sparse&lt;/code&gt; for this, and its documentation says word for word what it does: it employs a sparse-checkout, with only files in the toplevel directory initially being present.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;setup&lt;/th&gt;
&lt;th&gt;pack&lt;/th&gt;
&lt;th&gt;working tree&lt;/th&gt;
&lt;th&gt;files&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;full clone + checkout&lt;/td&gt;
&lt;td&gt;276.5 MiB&lt;/td&gt;
&lt;td&gt;232.8 MiB&lt;/td&gt;
&lt;td&gt;6,336&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;full clone + &lt;code&gt;sparse-checkout set&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;276.5 MiB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;15.9 MiB&lt;/td&gt;
&lt;td&gt;1,112&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--filter=blob:none --sparse&lt;/code&gt; (at clone)&lt;/td&gt;
&lt;td&gt;9.3 MiB&lt;/td&gt;
&lt;td&gt;0.5 MiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;+ &lt;code&gt;sparse-checkout set&lt;/code&gt; (3 dirs)&lt;/td&gt;
&lt;td&gt;16.7 MiB&lt;/td&gt;
&lt;td&gt;15.9 MiB&lt;/td&gt;
&lt;td&gt;1,112&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second row explains why &lt;code&gt;sparse-checkout&lt;/code&gt; does not deliver what people expect. The working tree went from 232.8 MiB to 15.9 — but the pack stayed at 276.5 MiB, not a byte moved. &lt;code&gt;sparse-checkout&lt;/code&gt; does not shrink the download, and the command's own tagline promises no more than that: it reduces your working tree to a subset of tracked files. The mechanism is described in the documentation's INTERNALS section and has nothing to do with the object store: the skip-worktree bit goes into the index, Git avoids populating those files' contents, and their absence is ignored. The index entry stays put — as in the lab: in a working tree with 14 files, &lt;code&gt;git ls-files&lt;/code&gt; still counts 6,336 paths.&lt;/p&gt;

&lt;p&gt;The last row is the whole point: a 32.6 MiB setup holding all the articles of one category plus &lt;code&gt;src/lib&lt;/code&gt; and &lt;code&gt;scripts&lt;/code&gt;. The full setup is 509.3 MiB; less than one fifteenth. And the history is still there. The recipe is three lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git clone &lt;span class="nt"&gt;--filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;blob:none &lt;span class="nt"&gt;--sparse&lt;/span&gt; &lt;span class="s2"&gt;"file://&lt;/span&gt;&lt;span class="nv"&gt;$SRV&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; work
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;work
&lt;span class="nv"&gt;$ &lt;/span&gt;git sparse-checkout &lt;span class="nb"&gt;set &lt;/span&gt;src/content/blog/tutorials src/lib scripts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the first line you will have 14 files; until the cone widens, the repository is just its root directory. The "it came up empty" panic is normal.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiSSB3YW50IGEgc21hbGxlciBjbG9uZSJdIC0tPiBCeyJpcyBoaXN0b3J5IG5lZWRlZD88YnIvPmxvZywgYmxhbWUsIGJpc2VjdCJ9CiAgQiAtLSAibm8iIC0tPiBDWyItLWRlcHRoIDE8YnIvPjIwNCBNaUI8YnIvPmJsYW1lIG5hbWVzIG9uZSBjb21taXQiXQogIEIgLS0gInllcyIgLS0-IEZ7IndpbGwgeW91PGJyLz53b3JrIG9mZmxpbmU_In0KICBGIC0tICJ5ZXMiIC0tPiBHWyJmdWxsIGNsb25lPGJyLz51bnJlYWNoYWJsZSBwcm9taXNvcjxici8-ZmFpbHMgYW55IGNvbW1hbmQ8YnIvPm5lZWRpbmcgYSBtaXNzaW5nIG9iamVjdCJdCiAgRiAtLSAibm8iIC0tPiBEeyJpcyB0aGUgd2hvbGU8YnIvPndvcmtpbmcgdHJlZSBuZWVkZWQ_In0KICBEIC0tICJ5ZXMiIC0tPiBFWyItLWZpbHRlcj1ibG9iOm5vbmU8YnIvPmNsb25lIDkgTWlCPGJyLz5jaGVja291dCArMjA0IE1pQiJdCiAgRCAtLSAibm8iIC0tPiBIWyItLWZpbHRlcj1ibG9iOm5vbmUgLS1zcGFyc2U8YnIvPisgc3BhcnNlLWNoZWNrb3V0IHNldDxici8-dG90YWwgMzMgTWlCIl0%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiSSB3YW50IGEgc21hbGxlciBjbG9uZSJdIC0tPiBCeyJpcyBoaXN0b3J5IG5lZWRlZD88YnIvPmxvZywgYmxhbWUsIGJpc2VjdCJ9CiAgQiAtLSAibm8iIC0tPiBDWyItLWRlcHRoIDE8YnIvPjIwNCBNaUI8YnIvPmJsYW1lIG5hbWVzIG9uZSBjb21taXQiXQogIEIgLS0gInllcyIgLS0-IEZ7IndpbGwgeW91PGJyLz53b3JrIG9mZmxpbmU_In0KICBGIC0tICJ5ZXMiIC0tPiBHWyJmdWxsIGNsb25lPGJyLz51bnJlYWNoYWJsZSBwcm9taXNvcjxici8-ZmFpbHMgYW55IGNvbW1hbmQ8YnIvPm5lZWRpbmcgYSBtaXNzaW5nIG9iamVjdCJdCiAgRiAtLSAibm8iIC0tPiBEeyJpcyB0aGUgd2hvbGU8YnIvPndvcmtpbmcgdHJlZSBuZWVkZWQ_In0KICBEIC0tICJ5ZXMiIC0tPiBFWyItLWZpbHRlcj1ibG9iOm5vbmU8YnIvPmNsb25lIDkgTWlCPGJyLz5jaGVja291dCArMjA0IE1pQiJdCiAgRCAtLSAibm8iIC0tPiBIWyItLWZpbHRlcj1ibG9iOm5vbmUgLS1zcGFyc2U8YnIvPisgc3BhcnNlLWNoZWNrb3V0IHNldDxici8-dG90YWwgMzMgTWlCIl0%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="792" height="1051"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The sparse index does not come unless you ask
&lt;/h2&gt;

&lt;p&gt;Switching to cone mode was supposed to shrink the index too. It did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git sparse-checkout &lt;span class="nb"&gt;set &lt;/span&gt;src/lib
&lt;span class="nv"&gt;$ &lt;/span&gt;git config index.sparse &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z .git/index
                       &lt;span class="c"&gt;# no line printed&lt;/span&gt;
938361
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The documentation says so explicitly: the &lt;code&gt;--[no-]sparse-index&lt;/code&gt; option can be used to get a sparse index, and &lt;strong&gt;the default is not to use it&lt;/strong&gt;. With the flag, 938,361 bytes drop to 5,839. In a wider three-directory cone, 936,257 → 162,430 bytes, a factor of 5.8.&lt;/p&gt;

&lt;p&gt;On the speed side there was nothing, and that goes down as it is. &lt;code&gt;git status --porcelain&lt;/code&gt; ran five times each; best values: 0.040 s with the sparse index, 0.041 s with the full one. The difference sits inside the noise. The documentation says this can have significant performance advantages for commands such as &lt;code&gt;git status&lt;/code&gt; or &lt;code&gt;git add&lt;/code&gt;; in a repository with 6,336 paths it does not show. This setting's promise is for monorepos with tens of thousands of files. The same page says the feature is still experimental and that some commands might be slower with a sparse index, and the capitalised "this command is experimental" warning sits at the end of the DESCRIPTION section.&lt;/p&gt;

&lt;p&gt;One more trap: if files are left outside the cone, Git warns during &lt;code&gt;git status&lt;/code&gt; that the sparse index is expanding to a full index, a slow operation — and that run's numbers are garbage. My first attempt landed exactly there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint in my CI that I do not like
&lt;/h2&gt;

&lt;p&gt;All of this was for my own publishing pipeline. Of 17 workflow files, 7 use &lt;code&gt;actions/checkout&lt;/code&gt;; 2 of those declare &lt;code&gt;fetch-depth&lt;/code&gt; (one &lt;code&gt;1&lt;/code&gt;, one &lt;code&gt;0&lt;/code&gt;) and the remaining 5 take the default. The pinned version's &lt;code&gt;action.yml&lt;/code&gt; gives &lt;code&gt;1&lt;/code&gt; as that default, described as "Number of commits to fetch. 0 indicates all history for all branches and tags."&lt;/p&gt;

&lt;p&gt;So my pipeline implements my reflex: a shallow clone. Per table 1 that is a 204 MiB photograph per run — with the caveat that the 204 MiB was taken on the local &lt;code&gt;file://&lt;/code&gt; side; GitHub's &lt;code&gt;upload-pack&lt;/code&gt; packs differently and &lt;code&gt;actions/checkout&lt;/code&gt; does not run &lt;code&gt;git clone&lt;/code&gt;, it fetches a single ref. It gives the order of magnitude, not the bill. The workflow that says &lt;code&gt;fetch-depth: 0&lt;/code&gt; pays at least 276 MiB — the server in the table had a single branch, while &lt;code&gt;fetch-depth: 0&lt;/code&gt; asks for all branches and tags.&lt;/p&gt;

&lt;p&gt;The action also has &lt;code&gt;filter&lt;/code&gt; and &lt;code&gt;sparse-checkout&lt;/code&gt; inputs — precisely the winning combination above. But the &lt;code&gt;filter&lt;/code&gt; input's description is two sentences, and the second is this: &lt;strong&gt;it overrides &lt;code&gt;sparse-checkout&lt;/code&gt; if set.&lt;/strong&gt; The two cannot be used together in this action. The smallest setup in this article — blobless clone &lt;em&gt;and&lt;/em&gt; a narrow cone — cannot be expressed with &lt;code&gt;actions/checkout&lt;/code&gt;'s inputs. Either take the 9 MiB clone and pay the loan by opening the full tree, or ask for the narrow tree and download the full object set.&lt;/p&gt;

&lt;p&gt;There is a way out, and it is not cheap: use &lt;code&gt;actions/checkout&lt;/code&gt; only to place credentials (&lt;code&gt;persist-credentials&lt;/code&gt;), then run &lt;code&gt;git clone --filter=blob:none --sparse&lt;/code&gt; yourself. The price is that credential handling becomes your problem — and since every later &lt;code&gt;log -p&lt;/code&gt;/&lt;code&gt;blame&lt;/code&gt; in a partial clone is a network request, that credential has to outlive the checkout step. I have not done it in my pipeline yet; the measurement is on the table, the constraint is named, the decision is pending.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you apply any of this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Is history needed?&lt;/strong&gt; &lt;code&gt;git log&lt;/code&gt;, &lt;code&gt;git blame&lt;/code&gt;, &lt;code&gt;git bisect&lt;/code&gt;. If yes, &lt;code&gt;--depth 1&lt;/code&gt; is off the table: it gives a wrong answer without erroring. If no, it is the simplest road — provided your repository's weight is in its history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Where is the weight?&lt;/strong&gt; Of my repository's 276 MiB, 204 is today's photograph. Measure once: clone with &lt;code&gt;--depth 1 --no-checkout&lt;/code&gt; and look at the pack bytes. If the photograph is large, cutting history will not help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the whole working tree needed?&lt;/strong&gt; If yes, &lt;code&gt;--filter&lt;/code&gt; defers the download, it does not reduce it. If no, &lt;code&gt;--filter=blob:none --sparse&lt;/code&gt; is the only real win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Will you work offline?&lt;/strong&gt; The documentation is explicit: use of partial clone requires that the user be online and the origin remote or other promisor remotes be available. When the promisor is unreachable a partial clone does not get slow: commands that make do with local objects (&lt;code&gt;status&lt;/code&gt;, &lt;code&gt;commit&lt;/code&gt;, &lt;code&gt;log --oneline&lt;/code&gt;) keep working, while &lt;strong&gt;every command needing a missing object fails&lt;/strong&gt; — and you cannot repair that offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does the server allow it?&lt;/strong&gt; &lt;code&gt;uploadpack.allowFilter&lt;/code&gt; is not the only gate: &lt;code&gt;uploadpackfilter.&amp;lt;filter&amp;gt;.allow&lt;/code&gt; can ban individual filters and &lt;code&gt;uploadpackfilter.tree.maxDepth&lt;/code&gt; caps &lt;code&gt;tree:&amp;lt;n&amp;gt;&lt;/code&gt;. &lt;code&gt;blob:none&lt;/code&gt; may work while &lt;code&gt;tree:0&lt;/code&gt; is refused. On your own Git server this is the first place to look.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three things to avoid: asking for a filter without &lt;code&gt;file://&lt;/code&gt;, asking for one while &lt;code&gt;allowFilter&lt;/code&gt; is off on the server (both quietly give you a full clone), and assuming cone mode brings the sparse index along. None of the three breaks anything; they just do not do what you asked, and the first two exit zero while doing it. A two-line assert in a CI job catches those two — but not via the &lt;code&gt;promisor&lt;/code&gt; setting, which is &lt;code&gt;true&lt;/code&gt; in a successful clone as well. After the clone, count the blobs, or test the pack bytes against a threshold.&lt;/p&gt;

&lt;h2&gt;
  
  
  A deferred cost is not a reduced cost
&lt;/h2&gt;

&lt;p&gt;What made me think most this round was the delight at first seeing that 9 MiB clone. The number was real, the measurement was correct, it said thirty-fold. The only wrong thing was the conclusion drawn from it: "the repository got smaller". It had not; its bill had been postponed, and it came back the moment I typed &lt;code&gt;git checkout&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The second lesson was in the measuring itself. The first round used &lt;code&gt;du&lt;/code&gt;, and showed a 4 percent gap between two copies of the same pack — until it became clear the filesystem was reporting on its own block accounting, not on the repository. In a measurement article, picking the unit is as much work as taking the measurement.&lt;/p&gt;

&lt;p&gt;Other instances of the same reflex: &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/grafi-git-yazdi-3287-commiti-atlamayi-ben-ogrettim/" rel="noopener noreferrer"&gt;the article where Git wrote this repo half a commit-graph&lt;/a&gt;, where a correct and fresh cache was not speeding up the question actually being asked, and &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/uc-satir-istedim-git-36811-girdiyi-taradi/" rel="noopener noreferrer"&gt;the one where &lt;code&gt;git status&lt;/code&gt; walked 36,811 entries for three lines&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A five-minute exercise on your own repository: take a blobless clone, note the pack bytes, run &lt;code&gt;git checkout&lt;/code&gt;, note them again. The gap between those two numbers is the loan you deferred.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/git/git/blob/master/Documentation/technical/partial-clone.adoc" rel="noopener noreferrer"&gt;technical/partial-clone.adoc — promisor remotes, batch pre-fetch and known limitations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/git/git/blob/master/Documentation/git-sparse-checkout.adoc" rel="noopener noreferrer"&gt;git-sparse-checkout — cone mode, skip-worktree and the sparse index&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/git/git/blob/master/Documentation/git-clone.adoc" rel="noopener noreferrer"&gt;git-clone — the &lt;code&gt;--filter&lt;/code&gt;, &lt;code&gt;--sparse&lt;/code&gt; and &lt;code&gt;--depth&lt;/code&gt; options&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/git/git/blob/master/Documentation/config/uploadpack.adoc" rel="noopener noreferrer"&gt;config/uploadpack.adoc — &lt;code&gt;allowFilter&lt;/code&gt; and the &lt;code&gt;uploadpackfilter.*&lt;/code&gt; permissions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://git.kernel.org/pub/scm/git/git.git/tree/Documentation/rev-list-options.adoc" rel="noopener noreferrer"&gt;rev-list-options.adoc — the definition of filter specifications&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/actions/checkout/blob/de0fac2e4500dabe0009e67214ff5f5447ce83dd/action.yml" rel="noopener noreferrer"&gt;actions/checkout action.yml — the &lt;code&gt;filter&lt;/code&gt;, &lt;code&gt;sparse-checkout&lt;/code&gt; and &lt;code&gt;fetch-depth&lt;/code&gt; inputs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>git</category>
      <category>olcum</category>
      <category>performance</category>
      <category>partialclone</category>
    </item>
    <item>
      <title>My Five TODOs Are 160 Days Old; Flutter Wrote the Two That Closed</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Thu, 22 Oct 2026 07:14:39 +0000</pubDate>
      <link>https://dev.to/merbayerp/my-five-todos-are-160-days-old-flutter-wrote-the-two-that-closed-1j0l</link>
      <guid>https://dev.to/merbayerp/my-five-todos-are-160-days-old-flutter-wrote-the-two-that-closed-1j0l</guid>
      <description>&lt;p&gt;Every real TODO marker standing in my repositories was written on a single morning: 15 May 2026.&lt;br&gt;
Four of them at 09:48:57 in one commit, the fifth seven minutes and thirty-six seconds later.&lt;br&gt;
Today is that morning's one hundred and sixtieth day, and all five are still in place.&lt;/p&gt;

&lt;p&gt;That wasn't the real story, though. I counted the history as well: across the entire history of&lt;br&gt;
my five repos, 21 lines carrying a marker were added and 5 were removed. Among those five&lt;br&gt;
removals there are two that genuinely closed, in the sense that the work they described got done&lt;br&gt;
— and I wrote neither of them. Both came out of Flutter's project template.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I scanned, and what I left out
&lt;/h2&gt;

&lt;p&gt;Let me state the scope up front, because numbers like these are meaningless without one. I&lt;br&gt;
scanned only my own projects: this blog's repository, köprü, vpsman, burncpu and gercekveri. I&lt;br&gt;
never scanned the client repositories at all. Across these five, git tracks 8,090 files in total;&lt;br&gt;
after the exclusions, the set I scanned is 2,021 files. On the history side there are 4,417&lt;br&gt;
commits, merge commits excluded. I ran the measurement not in my local working copies but in&lt;br&gt;
clean trees checked out from each repo's &lt;code&gt;origin/main&lt;/code&gt; — a branch sitting a day behind will&lt;br&gt;
quietly shift every denominator you publish.&lt;/p&gt;

&lt;p&gt;The scanner is simple: it looks for the words &lt;code&gt;TODO&lt;/code&gt;, &lt;code&gt;FIXME&lt;/code&gt;, &lt;code&gt;HACK&lt;/code&gt; and &lt;code&gt;XXX&lt;/code&gt; on every line of&lt;br&gt;
every tracked text file. What I left out: &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, lock files, compiled assets and&lt;br&gt;
third-party &lt;code&gt;vendor&lt;/code&gt; directories. In the blog repo three more directories are excluded —&lt;br&gt;
&lt;code&gt;src/content/&lt;/code&gt;, &lt;code&gt;public/&lt;/code&gt; and &lt;code&gt;src/assets/&lt;/code&gt; — because that is published prose and assets, not&lt;br&gt;
code. I counted that separately out of curiosity: of the 3,119 files under &lt;code&gt;src/content/&lt;/code&gt;, 14&lt;br&gt;
contain these words, on 16 lines in total. So what I excluded is not some vast pile; it is about&lt;br&gt;
the same size as the 15 markers in my code. The reason for excluding it isn't the count, it's the&lt;br&gt;
kind: the word "TODO" in an article is the subject matter, not technical debt.&lt;/p&gt;

&lt;p&gt;Before trusting the scanner I tried to catch it lying. I wrote a small file holding three markers&lt;br&gt;
that had to be found and three traps that had to be ignored: a &lt;code&gt;todos&lt;/code&gt; variable, an &lt;code&gt;autodoc&lt;/code&gt;&lt;br&gt;
function, a &lt;code&gt;TODO_LIST&lt;/code&gt; constant. It found the three and skipped the three. Skipping this step is&lt;br&gt;
very tempting, but a text-scanning script has a habit of manufacturing its own findings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five of the fifteen hits are real
&lt;/h2&gt;

&lt;p&gt;The raw result is 15 lines. Reading them by hand split them into three groups.&lt;/p&gt;

&lt;p&gt;Six are placeholders: &lt;code&gt;urn:li:person:XXX&lt;/code&gt;, &lt;code&gt;token=XXX&lt;/code&gt;, &lt;code&gt;ADR-XXX&lt;/code&gt;, and a &lt;code&gt;me-veo-XXX&lt;/code&gt; inside a&lt;br&gt;
filename template. None of them is debt; each is shorthand for "your value goes here."&lt;/p&gt;

&lt;p&gt;Two are my own joke about TODOs. In the punchline list I wrote for the shorts pipeline, entry 26&lt;br&gt;
reads: "WROTE A TODO. 2022. STILL TODO." The scanner reported my joke about technical debt as&lt;br&gt;
technical debt — a measuring instrument that cannot recognise its own subject is a brutal summary&lt;br&gt;
of everything that follows.&lt;/p&gt;

&lt;p&gt;One isn't even code: a note in a speaker-proposal draft telling me to get a professional&lt;br&gt;
photograph taken. And one is a line I never wrote — the stock TODO Flutter generates in the&lt;br&gt;
&lt;code&gt;CMakeLists.txt&lt;/code&gt; on vpsman's Windows side.&lt;/p&gt;

&lt;p&gt;The remaining five are real, and all from one family: the four Remotion templates of the&lt;br&gt;
shorts/video pipeline, plus &lt;code&gt;generate-short.ts&lt;/code&gt;. All five open with the same phrase,&lt;br&gt;
&lt;code&gt;// TODO Phase 1:&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One small reconciliation gap turned up here. In the köprü repo the history showed two markers&lt;br&gt;
while today's scan found one. I went looking for a deleted line; there wasn't one. The second&lt;br&gt;
marker sits in a third-party CSS file under &lt;code&gt;src/web/public/vendor/&lt;/code&gt;, and my scanner excludes&lt;br&gt;
&lt;code&gt;vendor&lt;/code&gt; directories by design. Both numbers are right — they answer different questions. Waving&lt;br&gt;
a gap like that away as rounding is the easiest way to break a measurement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Someone wrote this trap down in 2005
&lt;/h2&gt;

&lt;p&gt;Placeholder &lt;code&gt;XXX&lt;/code&gt;s inflating a scan is not a new discovery. &lt;a href="https://peps.python.org/pep-0350/" rel="noopener noreferrer"&gt;PEP 350, "Codetags"&lt;/a&gt;,&lt;br&gt;
written in the Python community in 2005, discusses this exact word: it gives markers like &lt;code&gt;TODO&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;FIXME&lt;/code&gt;, &lt;code&gt;HACK&lt;/code&gt; and &lt;code&gt;BUG&lt;/code&gt; the collective name &lt;em&gt;codetag&lt;/em&gt;, and it does not treat &lt;code&gt;XXX&lt;/code&gt; as a&lt;br&gt;
canonical tag — it lists it among the synonyms of &lt;code&gt;FIXME&lt;/code&gt;. One of its reasons describes my six&lt;br&gt;
false positives precisely: &lt;code&gt;XXX&lt;/code&gt; is a useful placeholder where a value is unknown, which is why&lt;br&gt;
&lt;code&gt;FIXME&lt;/code&gt; is the preferred spelling.&lt;/p&gt;

&lt;p&gt;The PEP's real proposal is more ambitious: put the answers to who wrote this, on what date, with&lt;br&gt;
what due date and what priority next to every marker. Its suggested form looks like&lt;br&gt;
&lt;code&gt;&amp;lt;MDE 2005-09-04 d:14w p:2&amp;gt;&lt;/code&gt;. My five TODOs carry none of those fields — no date, no priority, no&lt;br&gt;
owner. The odd part is that the PEP itself was never adopted; its page opens with "This PEP has&lt;br&gt;
been rejected," on the grounds that there was no desire to make the standard library conform to&lt;br&gt;
it. The industry has known about writing a date next to the marker for two decades, and has&lt;br&gt;
declined to do it for two decades. So have I.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three scanners, three different numbers
&lt;/h2&gt;

&lt;p&gt;I would like to treat my own grep as &lt;em&gt;the&lt;/em&gt; measurement, but the result changed beyond recognition&lt;br&gt;
as I moved the settings.&lt;/p&gt;

&lt;p&gt;First I turned off case sensitivity: 15 hits became 30. All 15 newcomers were noise —&lt;br&gt;
&lt;code&gt;ADMIN_API_TOKEN=xxx&lt;/code&gt; examples, a &lt;code&gt;const todo = rows.filter(...)&lt;/code&gt; variable, some &lt;code&gt;xxx-form.tsx&lt;/code&gt;&lt;br&gt;
filename templates in a CLAUDE.md. Then I made the term list resemble Visual Studio's task list;&lt;br&gt;
in that environment &lt;code&gt;HACK&lt;/code&gt;, &lt;code&gt;TODO&lt;/code&gt;, &lt;code&gt;UNDONE&lt;/code&gt; and &lt;code&gt;UnresolvedMergeConflict&lt;/code&gt; are predefined, and the&lt;br&gt;
documentation states those token labels are case insensitive. Adding &lt;code&gt;UNDONE&lt;/code&gt; took it to 31: one&lt;br&gt;
new line, the phrase "escapes are undone" in a Rust comment. I changed the two settings in two&lt;br&gt;
separate steps; flipping both at once and then blaming case sensitivity alone would have been&lt;br&gt;
slander against my own measurement.&lt;/p&gt;

&lt;p&gt;Then I went to the other end of the scale. ESLint's &lt;code&gt;no-warning-comments&lt;/code&gt; rule exists to do&lt;br&gt;
exactly this job, and its documented defaults read as follows: the terms searched for are&lt;br&gt;
&lt;code&gt;["todo", "fixme", "xxx"]&lt;/code&gt;, matching is case-insensitive and whole-word, and the location is&lt;br&gt;
&lt;code&gt;"start"&lt;/code&gt; — the term must sit at the beginning of the comment. In the rule's own source the&lt;br&gt;
&lt;code&gt;recommended&lt;/code&gt; field is &lt;code&gt;false&lt;/code&gt;, so it ships switched off in every preset.&lt;/p&gt;

&lt;p&gt;I couldn't run that rule, because ESLint isn't installed in the blog repo where the five TODOs&lt;br&gt;
live. Three of my repos do have it — two mobile sub-apps and the root of gercekveri — but none of&lt;br&gt;
the markers sits within those three. So rather than the rule, I rebuilt its documented default in&lt;br&gt;
a script of my own and limited it to the file family ESLint would look at: 831 JS/TS files, 5&lt;br&gt;
reports. The same five real TODOs.&lt;/p&gt;

&lt;p&gt;This does not mean the tool is smarter than me; part of why ESLint looks precise is that its scope&lt;br&gt;
is narrow. It never looks at markdown, CSS, Rust or Gradle files, and most of my noise was sitting&lt;br&gt;
in exactly those. Visual Studio's four tokens are themselves defined for C/C++, C# and Visual&lt;br&gt;
Basic, while I applied them to every file type. Still, one lesson is clean: requiring the marker&lt;br&gt;
to sit at the &lt;strong&gt;start&lt;/strong&gt; of the comment wipes out the whole placeholder class on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  All of them inside seven and a half minutes
&lt;/h2&gt;

&lt;p&gt;I asked &lt;code&gt;git blame&lt;/code&gt; for the age of the five. The four templates were written at 09:48:57 in a&lt;br&gt;
single commit, the fifth — &lt;code&gt;generate-short.ts&lt;/code&gt; — at 09:56:33. Seven minutes and thirty-six seconds&lt;br&gt;
between them. Today is 22 October; 160 days have passed since that morning, and to these five&lt;br&gt;
standing markers nothing has been added and nothing has closed since. (The rest of the history&lt;br&gt;
does move; I'll count that shortly.)&lt;/p&gt;

&lt;p&gt;The date says something on its own. Those two commits are the commits in which the shorts pipeline&lt;br&gt;
was born. The moment I wrote the markers was the moment the pipeline first ran, and I put the&lt;br&gt;
intention to prettify it into the code as a note. The minute you finish the first version of&lt;br&gt;
something is the minute you hold the most optimistic possible estimate of your future energy.&lt;/p&gt;

&lt;p&gt;Did "Phase 1" happen somewhere else instead? No — and rather than assume it, I went and counted.&lt;br&gt;
The contents of the four marker-bearing templates have not changed since the day they were born;&lt;br&gt;
after the two commits of that morning, the only commit to land in &lt;code&gt;remotion/&lt;/code&gt; is a version bump on&lt;br&gt;
28 September. Meanwhile the pipeline is live: the repo holds 20 content files for 10 short videos&lt;br&gt;
across Turkish and English, and the templates are called from &lt;code&gt;Composition.tsx&lt;/code&gt;. What I left&lt;br&gt;
undone never stopped the pipeline from working. Which is the real finding: those markers were not&lt;br&gt;
a record of debt, they were a wish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Twenty-one markers, five deletions
&lt;/h2&gt;

&lt;p&gt;The current state is a snapshot; the history was what interested me. Across the full history of&lt;br&gt;
the five repos I counted lines containing a marker being added and removed: &lt;strong&gt;21 lines added, 5&lt;br&gt;
removed.&lt;/strong&gt; The honest denominator here is 21, not the commit count — a line that was never written&lt;br&gt;
cannot be deleted. Opening those five deletions one at a time turned out to be the whole point.&lt;/p&gt;

&lt;p&gt;Two are genuine closures, and I wrote neither: the &lt;code&gt;build.gradle.kts&lt;/code&gt; file on vpsman's Android&lt;br&gt;
side carried two stock TODOs generated by Flutter — "specify your own unique application ID" and&lt;br&gt;
"add your own signing config for the release build." I really did both; one on 8 May, the other on&lt;br&gt;
29 August.&lt;/p&gt;

&lt;p&gt;One is fake. On 1 June I rewrote the header comment of &lt;code&gt;unsubscribe.ts&lt;/code&gt;; the same commit deleted&lt;br&gt;
one line and added a near-identical one. The counters show an add and a delete while nothing&lt;br&gt;
closed. If you don't filter out add-and-delete pairs inside a single commit, this measurement&lt;br&gt;
inflates in both directions at once.&lt;/p&gt;

&lt;p&gt;One fell off. On the morning of 16 May at 10:43 I wrote a line into burncpu's README: "local&lt;br&gt;
Docker: (TODO)". At 11:14, thirty-one minutes later, a commit reorganising the README took that&lt;br&gt;
line with it. The marker went — so the work was done? No: the local Docker instructions landed on&lt;br&gt;
14 July. There are 59 days between the death of the marker and the completion of the work, and no&lt;br&gt;
causal link whatsoever between them.&lt;/p&gt;

&lt;p&gt;One was solved by another route. On 15 May I established that video upload to X wasn't working and&lt;br&gt;
wrote this next to it: "or implement Twitter v1.1 chunked upload directly (TODO)". On 11 June I&lt;br&gt;
fixed the problem — but not that way. I wrote a patch for the library I was using, adding the&lt;br&gt;
missing parameters and the status polling. When the fix arrived, the whole paragraph containing&lt;br&gt;
the TODO was deleted. The problem closed; the plan the marker proposed was never carried out.&lt;/p&gt;

&lt;p&gt;The denominator is small, so let me put it on the table. Eight TODOs written by my own hand mean&lt;br&gt;
actual work: the five still standing, the photograph note in the speaker proposal, burncpu's README&lt;br&gt;
line, and the X upload line. Only two of them have ever been deleted — the last two cases. So the&lt;br&gt;
sentence "no TODO I wrote was ever closed by doing what it said" rests on two deletions; the other&lt;br&gt;
six are still standing and haven't had their chance yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the two closed TODOs have in common
&lt;/h2&gt;

&lt;p&gt;To see whether a mechanism falls out of this, I counted how many commits touched the files&lt;br&gt;
carrying the markers. The four templates that hold four of the five living TODOs have been touched&lt;br&gt;
by one commit each since birth — the commit that created them. The fifth, &lt;code&gt;generate-short.ts&lt;/code&gt;, got&lt;br&gt;
two, both on the same day (one at 09:56, the other at 13:33). In those same 160 days that repo&lt;br&gt;
took 2,759 commits, 1,208 of them under &lt;code&gt;scripts/&lt;/code&gt;. The repo was working like mad; those five&lt;br&gt;
files were never opened.&lt;/p&gt;

&lt;p&gt;The file of the TODO that closed lives a different life: &lt;code&gt;build.gradle.kts&lt;/code&gt; has taken four&lt;br&gt;
commits, the most recent on 12 September. My first explanation was this: what gets a comment read&lt;br&gt;
is not good intentions, it's the file being reopened for an unrelated reason.&lt;/p&gt;

&lt;p&gt;But to be honest, three explanations come out of this data and two closures can't tell me which is&lt;br&gt;
right. The second: at least one of the closed items blocked a path — a signing configuration is&lt;br&gt;
required to ship to a store. (I can't claim the same for the application ID; I may have done that&lt;br&gt;
because its turn came, not because it blocked me.) The third, and perhaps the strongest: both&lt;br&gt;
closed markers were generated by a tool, and both sat immediately above a line I had to change&lt;br&gt;
anyway while preparing that file for release. All three land in the same place: there is no such&lt;br&gt;
thing as a self-reading comment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ledger lives outside the code
&lt;/h2&gt;

&lt;p&gt;So where do my open items sit? If there are five lines in the code, where is the rest? I looked at&lt;br&gt;
the notebook belonging to this project: 57 files, holding 22 lines that say "open item," "not&lt;br&gt;
fixed yet," "waiting" or "remaining steps," spread across 12 of them. Those 12 files also carry 84&lt;br&gt;
lines bearing a warning mark; across the whole notebook that mark appears on 116 lines, and most&lt;br&gt;
of them are traps rather than open work. (That is one project's notebook alone; across every&lt;br&gt;
project notebook on the machine there are 497 note files.)&lt;/p&gt;

&lt;p&gt;Here's the interesting part: PEP 350's author argues the exact opposite. Answering objections, he&lt;br&gt;
says he would rather have the information in one place — the source code — than in various other&lt;br&gt;
documents likely to get duplicated or forgotten. My measurement partly inverts that sentence: the&lt;br&gt;
thing that got forgotten was the marker in the source code. But his deeper point survives, because&lt;br&gt;
what keeps my notebook alive isn't its format either — it's the habit of returning to it every&lt;br&gt;
session. What makes a ledger a ledger is its reader.&lt;/p&gt;

&lt;p&gt;When I &lt;a href="https://mustafaerbay.com.tr/en/blog/career/nobetcilerimi-denetledim-1973-kosu-defterde-15-satir/" rel="noopener noreferrer"&gt;audited 1,973 runs of my scheduled jobs&lt;/a&gt;&lt;br&gt;
I found that no record was being kept. Here a record is kept but has no reader — the same&lt;br&gt;
blindness I saw when I &lt;a href="https://mustafaerbay.com.tr/en/blog/career/on-dokuz-betigimi-kod-cagirmiyor-dokuzunu-hic-kimse/" rel="noopener noreferrer"&gt;counted the scripts nobody calls&lt;/a&gt;.&lt;br&gt;
In all three the mistake sits in the same place: I took a thing's existence for its working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm changing
&lt;/h2&gt;

&lt;p&gt;The conclusion of this measurement is not "stop writing TODOs." All five markers told me&lt;br&gt;
something; this article exists because I read them. What I'm changing is the question I ask while&lt;br&gt;
writing one.&lt;/p&gt;

&lt;p&gt;If I'm about to write a TODO, I now ask first: what force will get this line read? Will the file be&lt;br&gt;
reopened for another reason, or does the work block a gate? If either is true, the marker earns&lt;br&gt;
its place. If neither is, what I'm writing is a wish, and there is no point hiding a wish inside&lt;br&gt;
the code.&lt;/p&gt;

&lt;p&gt;Second: I put the marker at the start of the comment — one line of rule, and the entire&lt;br&gt;
placeholder noise class is gone. Third: no undated TODOs. I don't need to make PEP 350's rejected&lt;br&gt;
field list mandatory, but dropping a date next to the marker stops a hundred and sixty days from&lt;br&gt;
passing unnoticed; today I had to run &lt;code&gt;git blame&lt;/code&gt; to count those days.&lt;/p&gt;

&lt;p&gt;I'll also keep my open-item ledger in prose, because the measurement showed that is the side which&lt;br&gt;
actually gets read. I'll just give it the name it has earned: that is my issue tracker. The marker&lt;br&gt;
inside the code is not a reminder, it's a decision — and writing a decision in the costume of a&lt;br&gt;
comment is the polite way of not making it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://peps.python.org/pep-0350/" rel="noopener noreferrer"&gt;PEP 350 – Codetags&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://learn.microsoft.com/en-us/visualstudio/ide/using-the-task-list" rel="noopener noreferrer"&gt;Microsoft Learn — Use Task List to track and use code comments&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/eslint/eslint/blob/main/docs/src/rules/no-warning-comments.md" rel="noopener noreferrer"&gt;ESLint — no-warning-comments rule docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/git/git/blob/master/Documentation/git-blame.adoc" rel="noopener noreferrer"&gt;Git — git-blame documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>yazilimkariyeri</category>
      <category>olcum</category>
      <category>process</category>
      <category>teknikborc</category>
    </item>
    <item>
      <title>My Firewall Is Off, 42 of 55 Sockets Unreachable from the Network</title>
      <dc:creator>Mustafa ERBAY</dc:creator>
      <pubDate>Wed, 21 Oct 2026 15:04:41 +0000</pubDate>
      <link>https://dev.to/merbayerp/my-firewall-is-off-42-of-55-sockets-unreachable-from-the-network-2ede</link>
      <guid>https://dev.to/merbayerp/my-firewall-is-off-42-of-55-sockets-unreachable-from-the-network-2ede</guid>
      <description>&lt;p&gt;The question was as harmless as a coffee break: is the firewall on this machine&lt;br&gt;
enabled? The known answer runs through a plist file: read the &lt;code&gt;globalstate&lt;/code&gt; key,&lt;br&gt;
0 means off, 1 means on. It is settled enough practice that Apple's own release&lt;br&gt;
notes speak to workflows built on modifying that file. I typed it into the&lt;br&gt;
terminal and got back "no such file".&lt;/p&gt;

&lt;p&gt;The file is not on this machine. Apple took the settings out of the plist with&lt;br&gt;
macOS 15; exactly when the file itself disappeared is something neither the&lt;br&gt;
release note states nor I can measure. More interesting still, when I asked the&lt;br&gt;
same question in three different places I got three different answers. And in&lt;br&gt;
the end this came out: the question I was asking was wrong too.&lt;/p&gt;

&lt;p&gt;Every measurement was taken on the evening of 21 October 2026, on a single&lt;br&gt;
machine: macOS 26.6.2 (build 25G83), arm64, no MDM enrollment&lt;br&gt;
(&lt;code&gt;profiles status&lt;/code&gt; → "MDM enrollment: No"). Every command is read-only; I did&lt;br&gt;
not change a single setting on this machine for this article. The numbers are a&lt;br&gt;
snapshot and they drift as the machine runs — what matters is not the&lt;br&gt;
individual figures but the ratio between them.&lt;/p&gt;
&lt;h2&gt;
  
  
  The file is gone, the tool still answers
&lt;/h2&gt;

&lt;p&gt;The known path is &lt;code&gt;/Library/Preferences/com.apple.alf.plist&lt;/code&gt;.&lt;br&gt;
"alf" stands for Application Firewall — the long-standing internal name of&lt;br&gt;
macOS's application-based firewall. I asked about the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ stat /Library/Preferences/com.apple.alf.plist
stat: /Library/Preferences/com.apple.alf.plist: stat: No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Yes, &lt;code&gt;stat:&lt;/code&gt; is printed twice — that is BSD &lt;code&gt;stat&lt;/code&gt;'s own habit, and you will&lt;br&gt;
see it too.) The detail here matters: the error is not "Operation not permitted", it is "No&lt;br&gt;
such file or directory". On macOS, trying to read a protected file gives you a&lt;br&gt;
permission error; a missing file is a different message. So this is not an&lt;br&gt;
access barrier — the file genuinely is not there.&lt;/p&gt;

&lt;p&gt;What does the tool say, then? The binary carrying the same name is still in&lt;br&gt;
place and — the part that surprised me — it answers without &lt;code&gt;sudo&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate
Firewall is disabled. (State = 0)
$ ... --getblockall
Firewall has block all state set to disabled.
$ ... --getstealthmode
Firewall stealth mode is off
$ ... --getallowsigned
Automatically allow built-in signed software ENABLED.
Automatically allow downloaded signed software ENABLED.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four questions, four answers, no root. That is a small security note in its own&lt;br&gt;
right: a process running on my machine with ordinary user privileges can read&lt;br&gt;
the state of my firewall without being asked for a single permission.&lt;/p&gt;

&lt;p&gt;Apple wrote down where the gap comes from, in its own release notes. Under the&lt;br&gt;
"Application Firewall" heading of the macOS Sequoia 15 developer notes, in the&lt;br&gt;
"Deprecations" section, there is a single item, and it names the file exactly:&lt;br&gt;
the settings are no longer held in a property list, and any app or workflow that&lt;br&gt;
worked by modifying that file needs to move to the &lt;code&gt;socketfilterfw&lt;/code&gt; command-line&lt;br&gt;
tool.&lt;/p&gt;

&lt;p&gt;So the check in circulation is not stale, it is retired. And the only way to&lt;br&gt;
find that out is to ask for the file one more time; nobody sends you a notice.&lt;/p&gt;
&lt;h2&gt;
  
  
  So where does the state live?
&lt;/h2&gt;

&lt;p&gt;I wanted to chase this, because in a security review "the tool says so" is not&lt;br&gt;
a sufficient answer; to verify something you need to know where you are&lt;br&gt;
looking. I scanned the strings inside the binary: &lt;code&gt;socketfilterfw&lt;/code&gt; carries two&lt;br&gt;
plist paths — one is the factory template I will open in a moment, the other is&lt;br&gt;
the no-longer-existing &lt;code&gt;/Library/Preferences/com.apple.alf.plist&lt;/code&gt;. A path&lt;br&gt;
occurring inside a binary does not mean the binary reads it; but a retired path&lt;br&gt;
still sitting there shows the distance between the tool's history and its&lt;br&gt;
present.&lt;/p&gt;

&lt;p&gt;In the places I can read, I could not find the new location: there is no&lt;br&gt;
firewall-related file under &lt;code&gt;/Library/Preferences&lt;/code&gt;, &lt;code&gt;/Library/Managed&lt;br&gt;
Preferences&lt;/code&gt; is empty, and &lt;code&gt;defaults read com.apple.alf&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;defaults read com.apple.security.firewall&lt;/code&gt; do not come back empty — they come&lt;br&gt;
back with an error: &lt;code&gt;Domain com.apple.security.firewall does not exist&lt;/code&gt;.&lt;br&gt;
&lt;code&gt;pfctl -s info&lt;/code&gt; wants root; macOS does ship &lt;code&gt;pf&lt;/code&gt;, a real packet filter, but that&lt;br&gt;
is another evening's subject.&lt;/p&gt;

&lt;p&gt;What ended the search was an answer from an Apple engineer. In a developer forum&lt;br&gt;
thread asking exactly my question&lt;br&gt;
(&lt;a href="https://developer.apple.com/forums/thread/760135" rel="noopener noreferrer"&gt;thread 760135&lt;/a&gt;), the reply&lt;br&gt;
from Apple's Developer Technical Support team is short: the location and format&lt;br&gt;
of these preferences is not considered API, so it will not be shared; and there&lt;br&gt;
is no other known way to observe the setting. The developer who asked pressed&lt;br&gt;
on: the release notes point at &lt;code&gt;socketfilterfw&lt;/code&gt;, yet the same support team had&lt;br&gt;
said elsewhere that &lt;code&gt;socketfilterfw&lt;/code&gt; is not API either — so which is the proper&lt;br&gt;
way? That message got no reply.&lt;/p&gt;

&lt;p&gt;Keeping the state of a security control somewhere its own documentation never&lt;br&gt;
names is a technical choice. But the practical consequence is this: you cannot&lt;br&gt;
audit that control, you can only trust it. Those are not the same thing.&lt;/p&gt;
&lt;h2&gt;
  
  
  The template is not my machine
&lt;/h2&gt;

&lt;p&gt;This was the part most likely to mislead me. The file is absent under&lt;br&gt;
&lt;code&gt;/Library/Preferences&lt;/code&gt; — but a file with the same name sits in the&lt;br&gt;
&lt;code&gt;/usr/libexec/ApplicationFirewall/&lt;/code&gt; directory, 4,279 bytes, the factory&lt;br&gt;
template that ships with the system (its &lt;code&gt;version&lt;/code&gt; key reads &lt;code&gt;1.7&lt;/code&gt;). I opened&lt;br&gt;
it, and the contents looked exactly like the answer I had got from the tool:&lt;br&gt;
&lt;code&gt;globalstate&lt;/code&gt; 0, &lt;code&gt;stealthenabled&lt;/code&gt; 0, &lt;code&gt;allowsignedenabled&lt;/code&gt; 1,&lt;br&gt;
&lt;code&gt;allowdownloadsignedenabled&lt;/code&gt; 1.&lt;/p&gt;

&lt;p&gt;I could have stopped there and said "so the live state is in this file". It&lt;br&gt;
would have been wrong, and the template itself says so. Its &lt;code&gt;applications&lt;/code&gt;&lt;br&gt;
array is completely empty. Yet the tool lists seven applications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ /usr/libexec/ApplicationFirewall/socketfilterfw --listapps
Total number of apps = 7
1 : /usr/libexec/remoted            (Allow incoming connections)
2 : /usr/bin/python3                (Allow incoming connections)
3 : /usr/bin/ruby                   (Allow incoming connections)
4 : /usr/sbin/cupsd                 (Allow incoming connections)
5 : /usr/libexec/sharingd           (Allow incoming connections)
6 : /usr/libexec/sshd-keygen-wrapper (Allow incoming connections)
7 : /usr/sbin/smbd                  (Allow incoming connections)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I compared those seven paths against every list inside the template, one by&lt;br&gt;
one. The template has three separate places: 10 paths under &lt;code&gt;exceptions&lt;/code&gt; (all&lt;br&gt;
carrying &lt;code&gt;state: 3&lt;/code&gt; — what that value means is not documented by Apple; the name&lt;br&gt;
suggests an exemption, but I could not verify it), 7 bundle identifiers under&lt;br&gt;
&lt;code&gt;explicitauths&lt;/code&gt; (python, ruby, php, nc, ksh, a2p, java), and 10 named sharing&lt;br&gt;
services. Of the seven paths the tool returned, only three (&lt;code&gt;cupsd&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;sshd-keygen-wrapper&lt;/code&gt;, &lt;code&gt;smbd&lt;/code&gt;) match service names in the template. &lt;code&gt;python3&lt;/code&gt;&lt;br&gt;
and &lt;code&gt;ruby&lt;/code&gt; appear in the template not as paths but as bundle identifiers inside&lt;br&gt;
&lt;code&gt;explicitauths&lt;/code&gt; — different form, different list. &lt;code&gt;remoted&lt;/code&gt; and &lt;code&gt;sharingd&lt;/code&gt;&lt;br&gt;
appear in none of them.&lt;/p&gt;

&lt;p&gt;Do not run this comparison with a plain &lt;code&gt;grep&lt;/code&gt;: &lt;code&gt;remoted&lt;/code&gt; occurs inside the&lt;br&gt;
template's &lt;code&gt;xartstorageremoted&lt;/code&gt; path, and &lt;code&gt;sharingd&lt;/code&gt; occurs inside&lt;br&gt;
&lt;code&gt;screensharingd&lt;/code&gt;. Two words, two false positives, one wrong conclusion. Switch&lt;br&gt;
to full-path matching and both disappear.&lt;/p&gt;

&lt;p&gt;The conclusion: the tool is handing me a list it does not read from the&lt;br&gt;
template. So there is live state, and I cannot read it. The template's numbers&lt;br&gt;
agreeing with the tool's answer is not a confirmation but a coincidence — both&lt;br&gt;
may simply be reporting Apple's default.&lt;/p&gt;
&lt;h2&gt;
  
  
  What "on" does not mean
&lt;/h2&gt;

&lt;p&gt;This is where I got the real lesson. All this time I had been chasing the on/off&lt;br&gt;
question, when that switch says far less than I assumed.&lt;/p&gt;

&lt;p&gt;This macOS firewall is not port-based but application-based: it governs not&lt;br&gt;
which port is closed but which program may accept incoming connections. And it&lt;br&gt;
has two switches, both on by default. Apple's own device management&lt;br&gt;
documentation states it plainly: for the &lt;code&gt;AllowSigned&lt;/code&gt; and &lt;code&gt;AllowSignedApp&lt;/code&gt; keys&lt;br&gt;
in the &lt;code&gt;com.apple.security.firewall&lt;/code&gt; payload, "the system ensures that the key&lt;br&gt;
always has a value; if missing from the payload, the system sets it to &lt;code&gt;true&lt;/code&gt;".&lt;br&gt;
The first covers built-in signed software, the second downloaded signed&lt;br&gt;
software.&lt;/p&gt;

&lt;p&gt;Apple's user guide, on the page describing the settings, says the same thing in&lt;br&gt;
everyday language: apps and services signed by a valid certificate authority are&lt;br&gt;
added to the list of allowed apps without your authorization. An "Important"&lt;br&gt;
note on the guide's companion page — the one describing the firewall itself —&lt;br&gt;
completes the picture:&lt;br&gt;
some applications that have access never appear in the list at all — system&lt;br&gt;
apps, services, processes, and signed programs launched automatically by other&lt;br&gt;
applications. If you want to block those, you have to add them to the list by&lt;br&gt;
hand.&lt;/p&gt;

&lt;p&gt;Translate that to my machine. What would happen if I turned the firewall on?&lt;br&gt;
Built-in signed software would go on accepting incoming connections. So would&lt;br&gt;
the signed software I downloaded. What remains — unsigned programs, ones I&lt;br&gt;
blocked by hand, or ones it would ask me about — is a small set. So the "on"&lt;br&gt;
position is not the wall I had imagined; it is more like a doorkeeper who holds&lt;br&gt;
the door for anyone carrying a signature.&lt;/p&gt;

&lt;p&gt;There is stealth mode too: in Apple's words, the machine not responding to&lt;br&gt;
probing requests that could reveal its existence. Mine is off. It is useful,&lt;br&gt;
but not answering probes is not the same as not listening, and those two are&lt;br&gt;
easy to conflate.&lt;/p&gt;
&lt;h2&gt;
  
  
  The real number is in the bind address
&lt;/h2&gt;

&lt;p&gt;I changed the question. Instead of "is the firewall on?": how many sockets on&lt;br&gt;
this machine are waiting for incoming connections, and can they be reached from&lt;br&gt;
the network?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ netstat -an -p tcp | awk '$NF=="LISTEN"{print $4}' | sed 's/\.[0-9]*$//' | sort | uniq -c
  13 *
  37 127.0.0.1
   5 ::1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty-five listening sockets, 46 distinct ports. Thirty-seven bind to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt;, five to &lt;code&gt;::1&lt;/code&gt; — 42 sockets in total, across 39 distinct ports.&lt;br&gt;
Thirteen sockets bind to &lt;code&gt;*&lt;/code&gt;, that is to all interfaces; those come to 7&lt;br&gt;
distinct ports (six of them open one row each on IPv4 and IPv6, one opens a&lt;br&gt;
single row). There is no overlap between the two sets.&lt;/p&gt;

&lt;p&gt;The meaning of the number 42 is independent of the firewall, and its source is&lt;br&gt;
not a setting but a protocol rule. Section 3.2.1.3 of RFC 1122, listing the&lt;br&gt;
special address forms, has a single sentence for loopback: addresses of this&lt;br&gt;
form must not appear outside a host — in the item's own words, "MUST NOT appear&lt;br&gt;
outside a host". On the IPv6 side the rule is more explicit: Section 2.5.3 of&lt;br&gt;
RFC 4291 says a packet whose destination address is loopback must never be sent&lt;br&gt;
outside of a single node and must never be forwarded by a router, and that "a&lt;br&gt;
packet received on an interface with a destination address of loopback must be&lt;br&gt;
dropped". RFC 6890, entering both blocks into the special-purpose address&lt;br&gt;
registry, writes &lt;code&gt;False&lt;/code&gt; into four of their attributes: cannot be a source,&lt;br&gt;
cannot be a destination, not forwardable, not global. The one caveat is the&lt;br&gt;
footnote under the IPv4 table — a few protocols have been granted exceptions to&lt;br&gt;
that rule; the &lt;code&gt;::1/128&lt;/code&gt; row carries no such footnote.&lt;/p&gt;

&lt;p&gt;Let me be explicit about one thing: the number 42 is not a measurement, it is a&lt;br&gt;
derivation. The command reports the bind address of each socket; the&lt;br&gt;
unreachability follows from that address and from the rule above.&lt;/p&gt;

&lt;p&gt;In practice that means this: for a process listening on &lt;code&gt;127.0.0.1:5432&lt;/code&gt;, the&lt;br&gt;
firewall setting is irrelevant. A packet arriving from the network cannot reach&lt;br&gt;
that socket, because a packet carrying that address is not forwarded on the&lt;br&gt;
network — that is what the rule says, and what routers do.&lt;br&gt;
That is the situation for 42 of my 55 sockets. My firewall is off, and for those&lt;br&gt;
42 it makes no difference.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiSW5jb21pbmcgVENQIFNZTiJdIC0tPiBCeyJXaGF0IGFkZHJlc3MgaXMgdGhlIHNvY2tldCBib3VuZCB0bz8ifQogIEIgLS0-fCJsb29wYmFjayJ8IENbIlBhY2tldCBuZXZlciBhcnJpdmVzOjxici8-dGhlc2UgYWRkcmVzc2VzIGRvIG5vdDxici8-YXBwZWFyIG91dHNpZGUgYSBob3N0Il0KICBCIC0tPnwiYWxsIGludGVyZmFjZXMifCBEeyJJcyB0aGUgZmlyZXdhbGwgb24_In0KICBEIC0tPnwib2ZmInwgRVsiQ29ubmVjdGlvbiByZWFjaGVzIHRoZSBzb2NrZXQiXQogIEQgLS0-fCJvbiJ8IEZ7IklzIHRoZSBzb2Z0d2FyZSBzaWduZWQ_In0KICBGIC0tPnwiYnVpbHQtaW4gc2lnbmVkInwgRQogIEYgLS0-fCJkb3dubG9hZGVkIHNpZ25lZCJ8IEUKICBGIC0tPnwidW5zaWduZWQifCBHWyJQcm9tcHRlZCBvciBkZW5pZWQiXQ%3Ftype%3Dpng%26bgColor%3Dwhite" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fmermaid.ink%2Fimg%2FZmxvd2NoYXJ0IFRECiAgQVsiSW5jb21pbmcgVENQIFNZTiJdIC0tPiBCeyJXaGF0IGFkZHJlc3MgaXMgdGhlIHNvY2tldCBib3VuZCB0bz8ifQogIEIgLS0-fCJsb29wYmFjayJ8IENbIlBhY2tldCBuZXZlciBhcnJpdmVzOjxici8-dGhlc2UgYWRkcmVzc2VzIGRvIG5vdDxici8-YXBwZWFyIG91dHNpZGUgYSBob3N0Il0KICBCIC0tPnwiYWxsIGludGVyZmFjZXMifCBEeyJJcyB0aGUgZmlyZXdhbGwgb24_In0KICBEIC0tPnwib2ZmInwgRVsiQ29ubmVjdGlvbiByZWFjaGVzIHRoZSBzb2NrZXQiXQogIEQgLS0-fCJvbiJ8IEZ7IklzIHRoZSBzb2Z0d2FyZSBzaWduZWQ_In0KICBGIC0tPnwiYnVpbHQtaW4gc2lnbmVkInwgRQogIEYgLS0-fCJkb3dubG9hZGVkIHNpZ25lZCJ8IEUKICBGIC0tPnwidW5zaWduZWQifCBHWyJQcm9tcHRlZCBvciBkZW5pZWQiXQ%3Ftype%3Dpng%26bgColor%3Dwhite" alt="Diagram" width="695" height="1095"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The remaining 7 ports are my surface toward the local network — not toward the&lt;br&gt;
internet, which I will come to shortly. I am not writing the port numbers here;&lt;br&gt;
the service names in the &lt;code&gt;--listapps&lt;/code&gt; output above already hint at them, and&lt;br&gt;
adding the numbers gives the reader nothing. The useful part is not my list but&lt;br&gt;
your own. Let me share the question I asked myself: for each of these seven&lt;br&gt;
services, "does it need to be bound to all interfaces, or was it switched on&lt;br&gt;
once and left there?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The number 42 can fool you
&lt;/h2&gt;

&lt;p&gt;On its own, the sentence "it is bound to loopback, so it is safe" is lazy. Let&lt;br&gt;
me write down the objections.&lt;/p&gt;

&lt;p&gt;A socket bound to loopback cannot be reached from the network, true. But put a&lt;br&gt;
proxy bound to all interfaces in front of it and it becomes reachable. On a&lt;br&gt;
developer machine running containers this is not an exception but the normal&lt;br&gt;
arrangement: the service inside the container listens on its own loopback, a&lt;br&gt;
port forwarder on the host side binds to &lt;code&gt;*&lt;/code&gt;, and the two are joined.&lt;br&gt;
&lt;code&gt;netstat&lt;/code&gt; tells you the truth — but you only see the whole truth if you know&lt;br&gt;
which process is holding each socket at that moment.&lt;/p&gt;

&lt;p&gt;Second objection — the question I left hanging above: being bound to &lt;code&gt;*&lt;/code&gt; does&lt;br&gt;
not mean "open to the internet", it means "reachable from the network I am on".&lt;br&gt;
There is a router in between, and I did not measure it anywhere in this article;&lt;br&gt;
for most machines on a home or office network, NAT already meets an inbound&lt;br&gt;
connection at the door. But IPv4 and IPv6 are not the same here: &lt;code&gt;netstat&lt;/code&gt;&lt;br&gt;
prints &lt;code&gt;*&lt;/code&gt; in the address column for both — it keeps the distinction in the&lt;br&gt;
Proto column (&lt;code&gt;tcp4&lt;/code&gt;/&lt;code&gt;tcp6&lt;/code&gt;/&lt;code&gt;tcp46&lt;/code&gt;), and it is the one-liner above that throws&lt;br&gt;
that column away. Yet if you hold a global IPv6 address there is no NAT in front&lt;br&gt;
of that socket at all; what covers you is whatever stateful filter your router&lt;br&gt;
keeps for IPv6 — most home routers default to blocking inbound there, but "most"&lt;br&gt;
is not your device. One line, two different exposures. That is why the&lt;br&gt;
&lt;code&gt;*&lt;/code&gt; count is not an alarm but a list of questions.&lt;/p&gt;

&lt;p&gt;Third objection: the number 42 holds for connections arriving from the network,&lt;br&gt;
not for code running on the machine. Any process running here can connect to&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt;. "Loopback only" is a network boundary, not an authorization&lt;br&gt;
boundary. It is no justification for leaving a locally running service without&lt;br&gt;
authentication.&lt;/p&gt;

&lt;p&gt;Fourth: I only counted TCP. UDP has no listening state — a socket is either bound&lt;br&gt;
or it is not, and &lt;code&gt;netstat&lt;/code&gt;'s LISTEN column stays empty there. So this&lt;br&gt;
measurement is not the whole surface, only the slice I was looking at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six commands, read-only
&lt;/h2&gt;

&lt;p&gt;For anyone who wants to ask the same question of their own machine, here is the&lt;br&gt;
sequence I verified working today — none of it changes anything, none of it needs&lt;br&gt;
root:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;/usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate&lt;/code&gt; — on or
off. If you have a note that reads the old plist path, throw it away.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;... --getallowsigned&lt;/code&gt; — if two lines come back, read both. If you see
"ENABLED", know that turning the firewall on will not stop signed software.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;... --listapps&lt;/code&gt; — do not mistake the list for a complete one; Apple's note
says some programs with access never appear here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;netstat -an -p tcp | awk '$NF=="LISTEN"{print $4}' | sed 's/\.[0-9]*$//' | sort | uniq -c&lt;/code&gt;
— this is the real number. The figure on the &lt;code&gt;*&lt;/code&gt; line is your surface toward
the local network.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lsof -nP -iTCP -sTCP:LISTEN&lt;/code&gt; — it tells you who holds the sockets, the
missing half of the previous step. Do not add its rows up against
&lt;code&gt;netstat&lt;/code&gt;'s: without root it names only your own processes and other users'
sockets drop off the list with no error (48 rows against 55 on this machine).
&lt;code&gt;lsof&lt;/code&gt; also prints per file descriptor where &lt;code&gt;netstat&lt;/code&gt; prints per socket, so
one socket can take two rows.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;profiles status -type enrollment&lt;/code&gt; — if the machine is under MDM, you are not
the owner of these settings; Apple's documented configuration surface is the
&lt;code&gt;com.apple.security.firewall&lt;/code&gt; profile payload.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Put steps four and five side by side and what is left is asking, one by one: why&lt;br&gt;
is this port here? The first places to look are a sharing toggle (the Sharing&lt;br&gt;
section of System Settings) and the service's own bind address; both can come up&lt;br&gt;
empty, and then you are left searching by process name. Once you find it, writing&lt;br&gt;
&lt;code&gt;127.0.0.1&lt;/code&gt; instead of &lt;code&gt;*&lt;/code&gt; is a more definite move than turning the firewall on.&lt;br&gt;
Every line whose answer is "I don't know" is the work of another evening.&lt;/p&gt;

&lt;p&gt;And one more thing: because there is no supported way to observe the state, this&lt;br&gt;
is not something you do once and forget. On a single machine the remedy is to&lt;br&gt;
ask &lt;code&gt;--getglobalstate&lt;/code&gt; on an interval and catch the change; across several&lt;br&gt;
machines, hand ownership to a profile — if you enforce the&lt;br&gt;
&lt;code&gt;com.apple.security.firewall&lt;/code&gt; payload, at least you are the one keeping the&lt;br&gt;
record.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was not where I was looking
&lt;/h2&gt;

&lt;p&gt;What I learned this evening is not that my firewall is off — the template that&lt;br&gt;
ships with the system says &lt;code&gt;globalstate&lt;/code&gt; 0 too, and for 42 of my sockets it&lt;br&gt;
makes no difference. What I learned is that I can ask about the state of one&lt;br&gt;
setting in three places and get three&lt;br&gt;
different answers: the check in circulation points at a retired file, the&lt;br&gt;
factory template is not my machine, and the list the tool hands me is one whose&lt;br&gt;
incompleteness is written into its own documentation.&lt;/p&gt;

&lt;p&gt;There is a shared fallacy underneath all of it: mistaking knowing the name of a&lt;br&gt;
switch for knowing what the switch does. "The firewall is on" is a sentence;&lt;br&gt;
"this socket is bound to &lt;code&gt;127.0.0.1&lt;/code&gt;" is a fact. The first points at a policy,&lt;br&gt;
and the inside of that policy is full of defaults, signature exemptions and&lt;br&gt;
invisible lists. The second rests on a protocol rule and depends on nobody's&lt;br&gt;
default.&lt;/p&gt;

&lt;p&gt;So the next time I wonder how open my machine is to the outside, my first&lt;br&gt;
command will not be &lt;code&gt;--getglobalstate&lt;/code&gt;. It will be &lt;code&gt;netstat&lt;/code&gt;. The switch tells&lt;br&gt;
me what I was trying to do; the bind address tells me what I did.&lt;/p&gt;

&lt;p&gt;I have put similar questions to this machine before — there was &lt;a href="https://mustafaerbay.com.tr/en/blog/tutorials/karantina-defterimde-1945-satir-var-hicbirinde-adres-yok/" rel="noopener noreferrer"&gt;not a single&lt;br&gt;
address in the 1,945 rows of my download&lt;br&gt;
ledger&lt;/a&gt;,&lt;br&gt;
and &lt;a href="https://mustafaerbay.com.tr/en/blog/life/146-dizinin-sahibi-benim-hicbirini-acamiyorum/" rel="noopener noreferrer"&gt;I owned 146 directories I could not&lt;br&gt;
open&lt;/a&gt; — and each&lt;br&gt;
time what produced the answer was not my curiosity but finding the right&lt;br&gt;
command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/macos-release-notes/macos-15-release-notes" rel="noopener noreferrer"&gt;Apple — macOS Sequoia 15 Release Notes (Application Firewall)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.apple.com/documentation/devicemanagement/firewall" rel="noopener noreferrer"&gt;Apple — Device Management: Firewall payload&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/mac-help/change-firewall-settings-on-mac-mh11783/mac" rel="noopener noreferrer"&gt;Apple Support — Change Firewall settings on Mac&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/mac-help/block-connections-to-your-mac-with-a-firewall-mh34041/mac" rel="noopener noreferrer"&gt;Apple Support — Block connections to your Mac with a firewall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc1122" rel="noopener noreferrer"&gt;RFC 1122 — Requirements for Internet Hosts (3.2.1.3)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc4291" rel="noopener noreferrer"&gt;RFC 4291 — IP Version 6 Addressing Architecture (2.5.3)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc6890" rel="noopener noreferrer"&gt;RFC 6890 — Special-Purpose IP Address Registries&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>macos</category>
      <category>security</category>
      <category>guvenlikduvari</category>
      <category>olcum</category>
    </item>
  </channel>
</rss>
