<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:blog="https://jonesrussell.github.io/blog/ns"><channel><title>Storage on Web Developer Blog</title><link>https://jonesrussell.github.io/blog/tags/storage/</link><description>Recent content in Storage on Web Developer Blog</description><image><title>Web Developer Blog</title><url>https://jonesrussell.github.io/blog/images/og-default.png</url><link>https://jonesrussell.github.io/blog/images/og-default.png</link></image><generator>Hugo -- 0.166.0</generator><language>en-us</language><lastBuildDate>Fri, 18 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://jonesrussell.github.io/blog/tags/storage/feed.xml" rel="self" type="application/rss+xml"/><item><title>Fixing a metadata collision bug in Waaseyaa's file repository</title><link>https://jonesrussell.github.io/blog/media-sidecar-uri-collision/</link><pubDate>Fri, 18 Sep 2026 00:00:00 +0000</pubDate><guid>https://jonesrussell.github.io/blog/media-sidecar-uri-collision/</guid><category>php</category><category>waaseyaa</category><blog:tag>php</blog:tag><blog:tag>waaseyaa</blog:tag><blog:tag>filesystem</blog:tag><blog:tag>storage</blog:tag><description>How Waaseyaa&amp;rsquo;s LocalFileRepository silently collided metadata for different stream-wrapper URIs that shared a trailing path segment, and the fix that preserves full URI identity plus atomic writes and a reconciliation tool.</description><content:encoded><![CDATA[<p>Ahnii!</p>
<p><a href="https://github.com/waaseyaa/framework">Waaseyaa</a>&rsquo;s <code>packages/media</code> package stores uploaded files behind stream-wrapper URIs like <code>public://images/photo.jpg</code>, and <code>LocalFileRepository</code> keeps each file&rsquo;s metadata (filename, MIME type, owner, size) in a JSON sidecar next to the derived path. The sidecar path came from <code>parse_url()</code>, and <code>parse_url()</code> doesn&rsquo;t know these URIs aren&rsquo;t real hierarchical URLs. That mismatch let two completely different files quietly overwrite each other&rsquo;s metadata. Here&rsquo;s the bug, the fix, and the reconciliation problem a fix like this creates.</p>
<h2 id="the-bug-parse_url-doesnt-know-about-stream-wrappers">The Bug: <code>parse_url()</code> Doesn&rsquo;t Know About Stream Wrappers</h2>
<p><code>resolveMetadataPath()</code> used to build a sidecar path from just the <code>scheme</code> and <code>path</code> components <code>parse_url()</code> returned:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span>$parsed <span style="color:#f92672">=</span> <span style="color:#a6e22e">parse_url</span>($uri);
</span></span><span style="display:flex;"><span>$scheme <span style="color:#f92672">=</span> <span style="color:#a6e22e">isset</span>($parsed[<span style="color:#e6db74">&#39;scheme&#39;</span>]) <span style="color:#f92672">?</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">sanitizeSegment</span>($parsed[<span style="color:#e6db74">&#39;scheme&#39;</span>]) <span style="color:#f92672">:</span> <span style="color:#e6db74">&#39;public&#39;</span>;
</span></span><span style="display:flex;"><span>$path <span style="color:#f92672">=</span> <span style="color:#a6e22e">isset</span>($parsed[<span style="color:#e6db74">&#39;path&#39;</span>]) <span style="color:#f92672">?</span> <span style="color:#a6e22e">trim</span>($parsed[<span style="color:#e6db74">&#39;path&#39;</span>], <span style="color:#e6db74">&#39;/&#39;</span>) <span style="color:#f92672">:</span> <span style="color:#a6e22e">trim</span>($uri, <span style="color:#e6db74">&#39;/&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>$segments <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_filter</span>(<span style="color:#a6e22e">explode</span>(<span style="color:#e6db74">&#39;/&#39;</span>, $path), <span style="color:#66d9ef">static</span> <span style="color:#a6e22e">fn</span>(<span style="color:#a6e22e">string</span> $segment)<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span> <span style="color:#f92672">=&gt;</span> $segment <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;&#39;</span>);
</span></span></code></pre></div><p>That looks reasonable until you feed it a stream-wrapper URI. <code>public://images/shared.pdf</code> isn&rsquo;t a hierarchical URL — it&rsquo;s a scheme plus a flat, ordered path — but <code>parse_url()</code> still applies RFC 3986 grammar to it, and under that grammar the first segment after <code>//</code> is a <strong>host</strong>, not a path component. So <code>public://images/shared.pdf</code> parses into <code>host: images</code>, <code>path: /shared.pdf</code>, and <code>resolveMetadataPath()</code> only ever looked at <code>path</code>.</p>
<p>That silently dropped the host segment. Two distinct, documented URIs that happened to share a trailing filename under different directories:</p>
<ul>
<li><code>public://images/shared.pdf</code></li>
<li><code>public://docs/shared.pdf</code></li>
</ul>
<p>collided onto the exact same <code>.../shared.pdf.meta.json</code> sidecar. Save the second file and it silently overwrote the first file&rsquo;s metadata. Delete either one&rsquo;s metadata and both lost it. No exception, no log line — just metadata for a file you never touched disappearing.</p>
<h2 id="the-fix-treat-every-segment-after-the-scheme-as-one-flat-path">The Fix: Treat Every Segment After the Scheme as One Flat Path</h2>
<p>The fix stops using <code>parse_url()</code>&rsquo;s host/path split entirely and instead treats everything after <code>scheme://</code> as one ordered list of segments:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">resolveMetadataPath</span>(<span style="color:#a6e22e">string</span> $uri)<span style="color:#f92672">:</span> <span style="color:#a6e22e">string</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $scheme <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;public&#39;</span>;
</span></span><span style="display:flex;"><span>    $rest <span style="color:#f92672">=</span> $uri;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">preg_match</span>(<span style="color:#e6db74">&#39;#^([A-Za-z][A-Za-z0-9+.-]*)://(.*)$#s&#39;</span>, $uri, $matches) <span style="color:#f92672">===</span> <span style="color:#ae81ff">1</span>) {
</span></span><span style="display:flex;"><span>        $scheme <span style="color:#f92672">=</span> $this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">sanitizeSegment</span>($matches[<span style="color:#ae81ff">1</span>]);
</span></span><span style="display:flex;"><span>        $rest <span style="color:#f92672">=</span> $matches[<span style="color:#ae81ff">2</span>];
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    $segments <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_filter</span>(<span style="color:#a6e22e">explode</span>(<span style="color:#e6db74">&#39;/&#39;</span>, <span style="color:#a6e22e">trim</span>($rest, <span style="color:#e6db74">&#39;/&#39;</span>)), <span style="color:#66d9ef">static</span> <span style="color:#a6e22e">fn</span>(<span style="color:#a6e22e">string</span> $segment)<span style="color:#f92672">:</span> <span style="color:#a6e22e">bool</span> <span style="color:#f92672">=&gt;</span> $segment <span style="color:#f92672">!==</span> <span style="color:#e6db74">&#39;&#39;</span>);
</span></span><span style="display:flex;"><span>    $safeSegments <span style="color:#f92672">=</span> <span style="color:#a6e22e">array_map</span>([$this, <span style="color:#e6db74">&#39;sanitizeSegment&#39;</span>], $segments);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    $target <span style="color:#f92672">=</span> <span style="color:#a6e22e">implode</span>(<span style="color:#e6db74">&#39;/&#39;</span>, $safeSegments);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> ($target <span style="color:#f92672">===</span> <span style="color:#e6db74">&#39;&#39;</span>) {
</span></span><span style="display:flex;"><span>        $target <span style="color:#f92672">=</span> <span style="color:#e6db74">&#39;file&#39;</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">return</span> <span style="color:#a6e22e">rtrim</span>($this<span style="color:#f92672">-&gt;</span><span style="color:#a6e22e">rootDir</span>, <span style="color:#e6db74">&#39;/&#39;</span>) <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/&#39;</span> <span style="color:#f92672">.</span> $scheme <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;/&#39;</span> <span style="color:#f92672">.</span> $target <span style="color:#f92672">.</span> <span style="color:#e6db74">&#39;.meta.json&#39;</span>;
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>Every segment gets sanitized individually and stays in order, so <code>images</code> and <code>docs</code> are preserved as distinct path components instead of one being silently discarded. Traversal confinement under the repository root is unchanged — sanitized <code>..</code> segments still collapse to <code>_</code> and can&rsquo;t escape <code>rootDir</code>.</p>
<p>That&rsquo;s a good fix, but it changes the on-disk layout for any URI with more than one segment after the scheme. The reviewers caught two problems that a fix like this can&rsquo;t just wave away.</p>
<h2 id="problem-1-upgrading-doesnt-migrate-existing-sidecars">Problem 1: Upgrading Doesn&rsquo;t Migrate Existing Sidecars</h2>
<p>Change how a path is derived and every sidecar an existing install already wrote sits at the <em>old</em> location. On upgrade, <code>load()</code> and <code>delete()</code> would look at the new path and find nothing, silently losing access to metadata that&rsquo;s still sitting on disk one directory over.</p>
<p>The fix deliberately does <strong>not</strong> add an automatic fallback to the old path on read — a fallback would have to pick a winner among the URIs that used to collide there, which is exactly the silent-data-loss failure mode being fixed in the first place. Instead, <code>reconcileLegacySidecars()</code> is a one-time migration operators run explicitly:</p>
<ul>
<li>It scans every <code>*.meta.json</code> sidecar under the repository root and reads the <code>uri</code> each one recorded at save time.</li>
<li>If a sidecar is already at its current-layout location, it&rsquo;s left alone.</li>
<li>If it isn&rsquo;t, and nothing already exists at the new location, it&rsquo;s relocated there — the common case, since there&rsquo;s exactly one candidate on disk.</li>
<li>If something already exists at the new location (a real conflict — two live candidates for one URI), the legacy sidecar is left untouched and reported as a <code>conflict</code>, never silently overwritten.</li>
</ul>
<p>It&rsquo;s idempotent, so running it twice on an already-reconciled tree reports nothing to do. The candidate list is collected up front before any renaming happens, because mutating files mid-walk on a <code>RecursiveDirectoryIterator</code> has undefined visitation order.</p>
<h2 id="problem-2-save-wasnt-atomic">Problem 2: <code>save()</code> Wasn&rsquo;t Atomic</h2>
<p>The original <code>save()</code> wrote sidecars with a direct <code>file_put_contents()</code> to the existing path — a truncate-then-rewrite of the same inode. A concurrent <code>load()</code> opening that file mid-write could read a partial, possibly non-JSON-decodable body.</p>
<p>The fix is the standard write-to-temp-then-rename pattern:</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-php" data-lang="php"><span style="display:flex;"><span><span style="color:#66d9ef">private</span> <span style="color:#66d9ef">function</span> <span style="color:#a6e22e">writeAtomically</span>(<span style="color:#a6e22e">string</span> $path, <span style="color:#a6e22e">string</span> $payload)<span style="color:#f92672">:</span> <span style="color:#a6e22e">void</span>
</span></span><span style="display:flex;"><span>{
</span></span><span style="display:flex;"><span>    $directory <span style="color:#f92672">=</span> <span style="color:#a6e22e">dirname</span>($path);
</span></span><span style="display:flex;"><span>    $temporary <span style="color:#f92672">=</span> <span style="color:#a6e22e">tempnam</span>($directory, <span style="color:#e6db74">&#39;.meta-&#39;</span>);
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">is_string</span>($temporary)) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(<span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;Unable to create a temporary file beside %s.&#39;</span>, $path));
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">file_put_contents</span>($temporary, $payload) <span style="color:#f92672">!==</span> <span style="color:#a6e22e">strlen</span>($payload)) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(<span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;Unable to write file metadata: %s&#39;</span>, $path));
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#f92672">!</span><span style="color:#a6e22e">rename</span>($temporary, $path)) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">throw</span> <span style="color:#66d9ef">new</span> <span style="color:#a6e22e">\RuntimeException</span>(<span style="color:#a6e22e">sprintf</span>(<span style="color:#e6db74">&#39;Unable to move file metadata into place: %s&#39;</span>, $path));
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    } <span style="color:#66d9ef">catch</span> (<span style="color:#a6e22e">\Throwable</span> $exception) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">if</span> (<span style="color:#a6e22e">is_file</span>($temporary)) {
</span></span><span style="display:flex;"><span>            <span style="color:#a6e22e">unlink</span>($temporary);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">throw</span> $exception;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>The temp file lives in the same directory as the target, so <code>rename()</code> stays on one filesystem and is a single atomic directory-entry swap. A reader only ever sees the complete old sidecar or the complete new one — never a partial write. Any failure along the way cleans up the temp file instead of leaving it behind.</p>
<h2 id="verifying-it">Verifying It</h2>
<p>The test suite added <strong>eight cases</strong> specifically to close coverage gaps the CI gate flagged (<strong>73.21%</strong> on the new code), on top of the regression tests for the original collision fix:</p>
<table>
	<thead>
			<tr>
					<th>Case</th>
					<th>What it proves</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Distinct authorities, same relative path</td>
					<td><code>public://images/shared.pdf</code> and <code>public://docs/shared.pdf</code> save, load, and delete independently</td>
			</tr>
			<tr>
					<td>Traversal-sanitized paths</td>
					<td><code>..</code> segments still collapse and stay confined to the repository root</td>
			</tr>
			<tr>
					<td>Empty root directory</td>
					<td><code>reconcileLegacySidecars()</code> on a not-yet-created root returns <code>[]</code></td>
			</tr>
			<tr>
					<td>Corrupt or missing <code>uri</code> in a legacy sidecar</td>
					<td>Reported as <code>unreadable</code>, doesn&rsquo;t abort the pass</td>
			</tr>
			<tr>
					<td>Legacy sidecar with a conflicting target</td>
					<td>Left untouched, reported as <code>conflict</code>, never overwritten</td>
			</tr>
			<tr>
					<td>Already-reconciled tree</td>
					<td>A second run reports nothing to do (idempotent)</td>
			</tr>
			<tr>
					<td><code>save()</code> against an existing sidecar</td>
					<td>The write swaps the file&rsquo;s inode, proving rename-based replacement</td>
			</tr>
			<tr>
					<td>Rename failure during <code>writeAtomically()</code></td>
					<td>Cleans up the temp file, throws <code>RuntimeException</code></td>
			</tr>
	</tbody>
</table>
<h2 id="the-general-lesson">The General Lesson</h2>
<p><code>parse_url()</code> is built for real URLs, and stream-wrapper URIs like <code>scheme://</code> only <em>look</em> like them. Feeding a flat, ordered identifier through a parser that assumes host/path semantics will happily produce a result — it just won&rsquo;t be the result you meant, and nothing will tell you that at runtime. The two-line diff that dropped one path segment didn&rsquo;t fail loudly; it just started routing some writes to the wrong file.</p>
<p>The other lesson is about what &ldquo;fix the derivation&rdquo; actually obligates you to do. Changing how a path is computed is easy. Owning up to the fact that existing installs have data sitting at the <em>old</em> derivation is the harder part. Giving them an explicit, conflict-reporting way to bring it forward — instead of a silent fallback that would repeat the same bug in miniature — is the work that&rsquo;s easy to punt to a follow-up ticket and never ship.</p>
<p>Baamaapii</p>
]]></content:encoded></item></channel></rss>