From 132be7fdd52f147ef3914403b226e882befdd1d0 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 03:48:03 +0200 Subject: [PATCH] Leave the archive readable by whoever collects from it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported as "the folder arrives empty". It was not empty. The folder was 0700 and owned by the account that wrote it, so the account that COLLECTS — an rsync over ssh, a backup agent, anything that is not the writer — could not enter it, and an unreadable directory looks exactly like an empty one. The invoice had been sitting in it the whole time. The cause is that the default follows the process umask, so under a restrictive one 0755 quietly becomes 0700. The permissions are declared on the disk now rather than left to inherit, and a test writes under umask 0077 and asserts the mode — the ordinary umask hides this completely, which is why it was found on a server and not here. Two mistakes of mine on the way to it, both worth recording. I read the folder as the wrong user, got no entries back, and concluded the file had been deleted — it was a permission failure reading as an unprivileged account, not a missing file. And the first fix was a chmod on a variable that does not exist in that method, silenced by an @ so it failed without a word. Flysystem takes its permissions from configuration; a chmod afterwards was the wrong layer even before it was the wrong variable. Co-Authored-By: Claude Opus 5 --- app/Services/Billing/InvoiceArchive.php | 12 ++++++++++ tests/Feature/Billing/InvoiceArchiveTest.php | 23 ++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/app/Services/Billing/InvoiceArchive.php b/app/Services/Billing/InvoiceArchive.php index 90dc632..0018438 100644 --- a/app/Services/Billing/InvoiceArchive.php +++ b/app/Services/Billing/InvoiceArchive.php @@ -148,6 +148,18 @@ final class InvoiceArchive 'driver' => 'local', 'root' => $target->path, 'throw' => false, + // Explicit, because the default follows the process umask and a + // restrictive one leaves the folder 0700. That is not cosmetic: + // whoever COLLECTS from a handover directory is a different account + // — an rsync over ssh, a backup agent — and a directory it cannot + // enter looks to it exactly like an empty one. Reported as "the + // folder arrives empty", with the invoice sitting in it the whole + // time. + 'visibility' => 'public', + 'permissions' => [ + 'file' => ['public' => 0o644, 'private' => 0o600], + 'dir' => ['public' => 0o755, 'private' => 0o700], + ], ]); } } diff --git a/tests/Feature/Billing/InvoiceArchiveTest.php b/tests/Feature/Billing/InvoiceArchiveTest.php index a6facf9..8a7675e 100644 --- a/tests/Feature/Billing/InvoiceArchiveTest.php +++ b/tests/Feature/Billing/InvoiceArchiveTest.php @@ -216,3 +216,26 @@ it('does not prune a destination whose mount has gone away', function () { expect(is_dir($gone))->toBeFalse(); }); + +it('leaves the folder readable by whoever collects from it', function () { + // Not cosmetic. mkdir's mode is masked by the process umask, and a + // restrictive one leaves the folder 0700 — at which point the account that + // collects (an rsync over ssh, a backup agent, anything that is not the + // account that wrote it) cannot enter it, and an unreadable directory looks + // exactly like an empty one. Reported as "the folder arrives empty", with + // the invoice sitting in it the whole time. + $invoice = anInvoice(); + $old = umask(0o077); + + try { + (new ArchiveInvoice($invoice, $this->target))->handle(app(InvoiceArchive::class)); + } finally { + umask($old); + } + + $folder = $this->archive.'/'.now()->format('Y'); + $file = $folder.'/'.$invoice->number.'.pdf'; + + expect(substr(sprintf('%o', fileperms($folder)), -3))->toBe('755') + ->and(substr(sprintf('%o', fileperms($file)), -3))->toBe('644'); +});