Skip to content

[13.x] Fix FileStore cache deserialization with sub-10-digit timestamps#59327

Closed
JoshSalway wants to merge 10 commits intolaravel:13.xfrom
JoshSalway:fix/file-cache-timestamp-padding
Closed

[13.x] Fix FileStore cache deserialization with sub-10-digit timestamps#59327
JoshSalway wants to merge 10 commits intolaravel:13.xfrom
JoshSalway:fix/file-cache-timestamp-padding

Conversation

@JoshSalway
Copy link
Copy Markdown

@JoshSalway JoshSalway commented Mar 22, 2026

Problem

FileStore::getPayload() reads cache files by splitting at a hardcoded 10-character boundary:

$expire = substr($contents, 0, 10);
$data   = substr($contents, 10);

expiration() returns a plain int, and timestamps before 2001-09-09 are only 9 digits (e.g. 990464460). When a 9-digit timestamp is concatenated with the serialized value, the read side grabs 10 characters and eats the first byte of the data.

Fixes #56075.

Prior discussion

@murrant reported this in #56075. @crynobone noted it would be hard to fix without a breaking change.

This approach avoids a breaking change. expiration() is untouched (same signature, same return type, same behavior). The padding is applied at the call sites only.

How it breaks (before)

Writing a cache entry while time-traveling to May 2001:

Carbon::setTestNow('2001-05-21 17:00:00')
Cache::put('key', 'hello', 60)

expiration(60) returns 990464460 (9 digits)

File written: 990464460s:5:"hello";

Reading it back:

Cache::get('key')

substr($contents, 0, 10) = "990464460s"    <- grabbed 's' from serialized data
substr($contents, 10)    = ":5:\"hello\";" <- missing the 's:', broken

unserialize(":5:\"hello\";") fails silently, returns null

The user gets null. No error, no exception. The data was written but can never be read.

How it works (after)

New files get padded to 10 digits on write with sprintf('%010d', ...):

sprintf('%010d', 990464460) = "0990464460"

File written: 0990464460s:5:"hello";

strspn("0990464460s:5:...", '0123456789') = 10
substr($contents, 0, 10) = "0990464460"    <- clean timestamp
substr($contents, 10)    = "s:5:\"hello\";" <- valid serialized data

Old files (written before the fix) are recovered. strspn detects the short timestamp:

File on disk: 990464460s:5:"hello";

strspn("990464460s:5:...", '0123456789') = 9
substr($contents, 0, 9)  = "990464460"     <- correct timestamp
substr($contents, 9)     = "s:5:\"hello\";" <- valid serialized data

Self-healing: old files get corrected on the next write. No cache:clear needed after upgrading.

The fix

Fixing only the write side would prevent new broken files but leave old ones unreadable. Fixing only the read side would recover old files but keep writing broken ones. Fixing both makes the cache self-healing: any file written before or after this fix is handled correctly.

Write side (put() and add()): wraps $this->expiration($seconds) with sprintf('%010d', ...) so new cache files always have 10-digit timestamps.

Read side (getPayload() and add()): uses strspn($contents, '0123456789') to count leading digits and find the boundary between timestamp and serialized data. Serialized PHP always starts with a type-prefix letter (s:, i:, a:, b:, d:, N;, O:, C:), so the boundary is unambiguous. Works for any timestamp length.

When would someone hit this? Carbon::setTestNow() or Date::withTestNow() with a date before September 9, 2001. This is the scenario @murrant reported: time-traveling in tests.

Test plan

Two new tests:

All existing FileStore tests pass.

Scope and safety

This change is limited to FileStore.php, affecting put(), add(), and getPayload() only. No other cache drivers are affected. No method signatures change. The expiration() method is untouched.

Existing cache files cannot be corrupted by this change. The read side is backwards-compatible with both old (unpadded) and new (padded) formats. Worst case if anything goes wrong is a cache miss, which is the same failure mode as before.

Note on timestamp range

strspn handles any digit length (8, 9, 10, 11+) and sprintf('%010d', ...) pads anything shorter than 10 digits. Timestamps outside the typical range (pre-1973 for 8-digit, post-2286 for 11-digit) work at the serialization level. Edge cases like negative timestamps (pre-1970) are outside the scope of this fix since they involve broader limitations in Unix timestamp representation.

@github-actions
Copy link
Copy Markdown

Thanks for submitting a PR!

Note that draft PRs are not reviewed. If you would like a review, please mark your pull request as ready for review in the GitHub user interface.

Pull requests that are abandoned in draft may be closed due to inactivity.

@JoshSalway

This comment was marked as spam.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FileStore: issue with deserialization when changing Date::now()

2 participants