diff --git a/changelog/unreleased/41832 b/changelog/unreleased/41832 new file mode 100644 index 000000000000..d3678efb27b0 --- /dev/null +++ b/changelog/unreleased/41832 @@ -0,0 +1,16 @@ +Security: Pin the Imagick coder for each bitmap preview provider + +Bitmap previews decoded content with no format hint, so ImageMagick's own +content-sniffing - independent of the mime-type check that decides whether a +preview is even attempted - could still pick a different coder than the one +a provider actually serves. PostScript-looking content, which the mime check +must allow through for the PDF and Postscript providers, could therefore +still reach the Ghostscript delegate through any other bitmap provider (SGI, +Font, Illustrator, Photoshop, TIFF, Heic). + +Each provider now pins the exact Imagick coder it expects instead of letting +ImageMagick guess from the file's content, using a tmpfs-backed temporary +file (falling back to disk if none is available) so the pin adds no disk +I/O to preview generation. + +https://github.com/owncloud/core/pull/41832 diff --git a/config/config.sample.php b/config/config.sample.php index 7427d763d8ab..18975d01f6fe 100644 --- a/config/config.sample.php +++ b/config/config.sample.php @@ -1443,6 +1443,23 @@ */ 'tempdirectory' => '/tmp/owncloudtemp', +/** + * Define the location for RAM-backed (tmpfs) temporary files + * Used for a small number of short-lived, write-then-immediately-read + * scratch files (currently just bitmap/vector preview generation), to avoid + * unnecessary disk I/O for content that is read back within milliseconds of + * being written. + * + * Auto-detects /dev/shm by default and silently falls back to the regular + * 'tempdirectory' location above if no writable tmpfs mount is found, or if + * the temporary file cannot be created there - this is always a best-effort + * optimization, never a hard requirement. + * + * Set to a path to use a different tmpfs mount than /dev/shm. Set to false + * to disable RAM-backed temporary files entirely. + */ +'ramtempdirectory' => '/dev/shm', + /** * Define the hashing cost * The hashing cost used by hashes generated by ownCloud. diff --git a/lib/private/Preview/Bitmap.php b/lib/private/Preview/Bitmap.php index 30f09c161f5a..f75e534f196f 100644 --- a/lib/private/Preview/Bitmap.php +++ b/lib/private/Preview/Bitmap.php @@ -49,7 +49,7 @@ public function getThumbnail(File $file, $maxX, $maxY, $scalingUp) { // Creates \Imagick object from bitmap or vector file try { - $bp = $this->getResizedPreview($stream, $maxX, $maxY); + $bp = $this->getResizedPreview($stream, $maxX, $maxY, $file->getMimeType()); } catch (\Exception $e) { Util::writeLog('core', 'ImageMagick says: ' . $e->getmessage(), Util::ERROR); return false; @@ -82,10 +82,12 @@ public function isAvailable(FileInfo $file) { * @param resource $stream the handle of the file to convert * @param int $maxX * @param int $maxY + * @param string $mimeType the file's own detected mime type, used to pin the + * Imagick coder so it can't be redirected by the file's actual content * * @return Imagick */ - private function getResizedPreview($stream, int $maxX, int $maxY): Imagick { + private function getResizedPreview($stream, int $maxX, int $maxY, string $mimeType): Imagick { $content = \stream_get_contents($stream); if ($this->isDangerousToDecode($content)) { @@ -94,8 +96,31 @@ private function getResizedPreview($stream, int $maxX, int $maxY): Imagick { $bp = ImagickFactory::create(); + # Pin the coder instead of letting Imagick's own content-sniffing pick one: + # reading with no format set re-derives the format from a ~130-entry magic + # table independently of isDangerousToDecode()'s check above, so content that + # looks like PostScript/PDF (which that check must allow through for the + # Postscript/PDF providers) would otherwise reach the Ghostscript delegate via + # any Bitmap provider, not just those two. + # + # The pin has to be a "FORMAT:path" read, not setFormat()+readImageBlob(): the + # latter does reliably reject a mismatched format, but for several coders here + # (PDF, EPS, AI, PSD, SGI, TIFF, HEIC, HEIF - i.e. all of them) it also silently + # skips the actual rasterization step, so setImageFormat('png') below ends up + # with no effect and getImageBlob() returns the original, undecoded bytes. + # + # Uses a RAM-backed (tmpfs) temp file rather than the regular disk-backed one: + # this write-then-immediately-read-then-delete file exists purely to give + # Imagick a path to pin against and never needs to survive on disk. + $tmpPath = \OC::$server->getTempManager()->getRamTemporaryFile(); + \file_put_contents($tmpPath, $content); + try { + $bp->readImage($this->getImagickFormat($mimeType) . ':' . $tmpPath); + } finally { + \unlink($tmpPath); + } + # setIteratorIndex(0) will make previews to be generated from the first page - $bp->readImageBlob($content); $bp->setIteratorIndex(0); $bp = $this->resize($bp, $maxX, $maxY); @@ -122,6 +147,12 @@ private function isDangerousToDecode(string $content): bool { return \in_array($mimeType, ['application/xml', 'image/x-mvg'], true); } + /** + * Maps this provider's own detected mime type(s) to the Imagick coder name that + * must decode them - the format pinned in getResizedPreview() above. + */ + abstract protected function getImagickFormat(string $mimeType): string; + /** * Returns a resized \Imagick object * diff --git a/lib/private/Preview/Font.php b/lib/private/Preview/Font.php index 775147d83eaa..3cd330aa2164 100644 --- a/lib/private/Preview/Font.php +++ b/lib/private/Preview/Font.php @@ -29,4 +29,13 @@ class Font extends Bitmap { public function getMimeType() { return '/application\/(?:font-sfnt|x-font$)/'; } + + protected function getImagickFormat(string $mimeType): string { + if ($mimeType === 'application/x-font') { + return 'PFB'; + } + # .otf and .ttf are indistinguishable by mime type alone (both application/font-sfnt); + # TTF is what actually decodes real font files here, both tagged variants included. + return 'TTF'; + } } diff --git a/lib/private/Preview/Heic.php b/lib/private/Preview/Heic.php index 6e7bba4125ff..e06cd65acc1c 100644 --- a/lib/private/Preview/Heic.php +++ b/lib/private/Preview/Heic.php @@ -29,4 +29,11 @@ class Heic extends Bitmap { public function getMimeType() { return '/image\/hei(f|c)/'; } + + protected function getImagickFormat(string $mimeType): string { + if ($mimeType === 'image/heif') { + return 'HEIF'; + } + return 'HEIC'; + } } diff --git a/lib/private/Preview/Illustrator.php b/lib/private/Preview/Illustrator.php index 06a98d9e4c5b..0267b4712d15 100644 --- a/lib/private/Preview/Illustrator.php +++ b/lib/private/Preview/Illustrator.php @@ -30,4 +30,8 @@ class Illustrator extends Bitmap { public function getMimeType() { return '/application\/illustrator/'; } + + protected function getImagickFormat(string $mimeType): string { + return 'AI'; + } } diff --git a/lib/private/Preview/Office.php b/lib/private/Preview/Office.php index d8f038279b23..1def5ee281da 100644 --- a/lib/private/Preview/Office.php +++ b/lib/private/Preview/Office.php @@ -68,7 +68,10 @@ public function getThumbnail(File $file, $maxX, $maxY, $scalingUp) { $pdfPreview = $tmpDir . '/' . $pathInfo['filename'] . '.pdf'; # Note: no SVG sanitization of the file content required .... - $imagick = ImagickFactory::create($pdfPreview . '[0]'); + # Pin the coder: this is LibreOffice's own PDF output, but readImageBlob()-style + # content-sniffing is avoided everywhere else Imagick decodes a file in this + # codebase, so pin it here too rather than rely on the ".pdf" path extension. + $imagick = ImagickFactory::create('PDF:' . $pdfPreview . '[0]'); $imagick->setImageFormat('jpg'); } catch (\Exception $e) { @\unlink($pdfPreview); diff --git a/lib/private/Preview/PDF.php b/lib/private/Preview/PDF.php index 0ab92bfb9cbe..40ae6397091b 100644 --- a/lib/private/Preview/PDF.php +++ b/lib/private/Preview/PDF.php @@ -30,4 +30,8 @@ class PDF extends Bitmap { public function getMimeType() { return '/application\/pdf/'; } + + protected function getImagickFormat(string $mimeType): string { + return 'PDF'; + } } diff --git a/lib/private/Preview/Photoshop.php b/lib/private/Preview/Photoshop.php index ca15dc1a12bd..7f7add29fa96 100644 --- a/lib/private/Preview/Photoshop.php +++ b/lib/private/Preview/Photoshop.php @@ -30,4 +30,8 @@ class Photoshop extends Bitmap { public function getMimeType() { return '/application\/x-photoshop/'; } + + protected function getImagickFormat(string $mimeType): string { + return 'PSD'; + } } diff --git a/lib/private/Preview/Postscript.php b/lib/private/Preview/Postscript.php index bab73b808494..e96c2539afa8 100644 --- a/lib/private/Preview/Postscript.php +++ b/lib/private/Preview/Postscript.php @@ -30,4 +30,8 @@ class Postscript extends Bitmap { public function getMimeType() { return '/application\/postscript/'; } + + protected function getImagickFormat(string $mimeType): string { + return 'EPS'; + } } diff --git a/lib/private/Preview/SGI.php b/lib/private/Preview/SGI.php index 81f43410fe3a..c9c24d4ab523 100644 --- a/lib/private/Preview/SGI.php +++ b/lib/private/Preview/SGI.php @@ -27,4 +27,8 @@ class SGI extends Bitmap { public function getMimeType() { return '/image\/sgi/'; } + + protected function getImagickFormat(string $mimeType): string { + return 'SGI'; + } } diff --git a/lib/private/Preview/SVG.php b/lib/private/Preview/SVG.php index 50347f4de65e..343842897e99 100644 --- a/lib/private/Preview/SVG.php +++ b/lib/private/Preview/SVG.php @@ -58,7 +58,22 @@ public function getThumbnail(File $file, $maxX, $maxY, $scalingUp) { return false; } - $imagick->readImageBlob($output); + # Pin the coder: reading with no format set would let Imagick's own content- + # sniffing pick the coder independently of the svg:sanitize/embed/decode + # options above and of the isDangerousToDecode()-style reasoning in Bitmap.php. + # This has to be a "SVG:path" read, not setFormat()+readImageBlob(): the latter + # silently skips the actual rasterization step, same as in Bitmap.php. + # + # RAM-backed like Bitmap.php's pin file: it is written, read and deleted again + # within this one call and exists purely to give Imagick a path to pin against, + # so it never needs to survive on disk. + $tmpPath = \OC::$server->getTempManager()->getRamTemporaryFile(); + \file_put_contents($tmpPath, $output); + try { + $imagick->readImage('SVG:' . $tmpPath); + } finally { + \unlink($tmpPath); + } $imagick->setImageFormat('png32'); } catch (\Exception $e) { \OCP\Util::writeLog('core', $e->getmessage(), \OCP\Util::ERROR); diff --git a/lib/private/Preview/TIFF.php b/lib/private/Preview/TIFF.php index 25404d6e6a0f..9f7ff4bfedc7 100644 --- a/lib/private/Preview/TIFF.php +++ b/lib/private/Preview/TIFF.php @@ -30,4 +30,8 @@ class TIFF extends Bitmap { public function getMimeType() { return '/image\/tiff/'; } + + protected function getImagickFormat(string $mimeType): string { + return 'TIFF'; + } } diff --git a/lib/private/TempManager.php b/lib/private/TempManager.php index cfa5ae9b6006..13e13c902c24 100644 --- a/lib/private/TempManager.php +++ b/lib/private/TempManager.php @@ -40,6 +40,9 @@ class TempManager implements ITempManager { protected array $current = []; /** @var ?string i.e. /tmp on linux systems */ protected ?string $tmpBaseDir = null; + /** @var ?string tmpfs-backed directory to use for getRamTemporaryFile(), resolved lazily */ + protected ?string $ramBaseDir = null; + protected bool $ramBaseDirChecked = false; protected ILogger $logger; protected IConfig $config; @@ -144,6 +147,68 @@ public function getTemporaryFolder($postFix = '') { return false; } + /** + * Create a temporary file backed by a tmpfs/RAM-backed mount when one is + * available and writable, falling back transparently to the regular + * disk-backed temporary directory otherwise. Intended for callers that + * write-then-immediately-read short-lived scratch content and want to + * avoid real disk I/O for it. + * + * Same failure contract as getTemporaryFile(): returns false only if + * even the disk-backed fallback fails. + */ + public function getRamTemporaryFile(string $postFix = ''): string|false { + $ramBaseDir = $this->resolveRamBaseDir(); + if ($ramBaseDir !== null) { + $file = @\tempnam($ramBaseDir, self::TMP_PREFIX); + if ($file !== false) { + $this->current[] = $file; + + if ($postFix !== '') { + $fileNameWithPostfix = $this->buildFileNameWithSuffix($file, $postFix); + $old_umask = \umask(0077); + \touch($fileNameWithPostfix); + \umask($old_umask); + $this->current[] = $fileNameWithPostfix; + return $fileNameWithPostfix; + } + + return $file; + } + + $this->logger->debug( + 'Could not create a RAM-backed temporary file in {dir}, falling back to disk', + ['dir' => $ramBaseDir] + ); + } + + return $this->getTemporaryFile($postFix); + } + + /** + * Resolve and cache the tmpfs-backed directory to use for + * getRamTemporaryFile(), or null if none is available/enabled. + * + * @return ?string + */ + private function resolveRamBaseDir(): ?string { + if ($this->ramBaseDirChecked) { + return $this->ramBaseDir; + } + $this->ramBaseDirChecked = true; + + $configured = $this->config->getSystemValue('ramtempdirectory', null); + if ($configured === false) { + return $this->ramBaseDir = null; + } + $candidate = \is_string($configured) && $configured !== '' ? $configured : '/dev/shm'; + + if (\is_dir($candidate) && \is_writable($candidate)) { + return $this->ramBaseDir = $candidate; + } + return $this->ramBaseDir = null; + } + /** * Remove the temporary files and folders generated during this request */ diff --git a/lib/public/ITempManager.php b/lib/public/ITempManager.php index 29633806018c..61d71164de37 100644 --- a/lib/public/ITempManager.php +++ b/lib/public/ITempManager.php @@ -67,4 +67,15 @@ public function cleanOld(); * @since 8.2.0 */ public function getTempBaseDir(); + + /** + * Create a temporary file backed by a tmpfs/RAM-backed mount when one is + * available and writable, falling back transparently to the regular + * disk-backed temporary directory otherwise. Intended for callers that + * write-then-immediately-read short-lived scratch content and want to + * avoid real disk I/O for it. + * + * @since 11.0.1 + */ + public function getRamTemporaryFile(string $postFix = ''): string|false; } diff --git a/tests/data/testimage.ai b/tests/data/testimage.ai new file mode 100644 index 000000000000..bdbe2f59ae0e Binary files /dev/null and b/tests/data/testimage.ai differ diff --git a/tests/data/testimage.heic b/tests/data/testimage.heic new file mode 100644 index 000000000000..6b0bbd258cd3 Binary files /dev/null and b/tests/data/testimage.heic differ diff --git a/tests/data/testimage.psd b/tests/data/testimage.psd new file mode 100644 index 000000000000..16bee932e8de Binary files /dev/null and b/tests/data/testimage.psd differ diff --git a/tests/data/testimage.sgi b/tests/data/testimage.sgi new file mode 100644 index 000000000000..28f500ef08b5 Binary files /dev/null and b/tests/data/testimage.sgi differ diff --git a/tests/data/testimage.tiff b/tests/data/testimage.tiff new file mode 100644 index 000000000000..9db137a2f5a6 Binary files /dev/null and b/tests/data/testimage.tiff differ diff --git a/tests/data/testimage.ttf b/tests/data/testimage.ttf new file mode 100644 index 000000000000..3f603b740aaf Binary files /dev/null and b/tests/data/testimage.ttf differ diff --git a/tests/lib/Preview/CoderPinningTest.php b/tests/lib/Preview/CoderPinningTest.php new file mode 100644 index 000000000000..2bf0dadf12c9 --- /dev/null +++ b/tests/lib/Preview/CoderPinningTest.php @@ -0,0 +1,133 @@ + + * + * @copyright Copyright (c) 2026, ownCloud GmbH + * @license AGPL-3.0 + * + * This code is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License, version 3, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License, version 3, + * along with this program. If not, see + * + */ + +namespace Test\Preview; + +use Generator; +use OC\Preview\Bitmap; +use OC\Preview\Font; +use OC\Preview\Heic; +use OC\Preview\Illustrator; +use OC\Preview\PDF; +use OC\Preview\Photoshop; +use OC\Preview\Postscript; +use OC\Preview\SGI; +use OC\Preview\TIFF; +use OCP\Files\File; +use Test\TestCase; + +class CoderPinningTest extends TestCase { + private function makeFile(string $content, string $mimeType): File { + $stream = \fopen('php://memory', 'rb+'); + \fwrite($stream, $content); + \rewind($stream); + $file = $this->createMock(File::class); + $file->method('fopen')->willReturn($stream); + $file->method('getMimeType')->willReturn($mimeType); + return $file; + } + + /** + * @dataProvider providesLegitimateContent + */ + public function testDecodesItsOwnFormat(string $fixture, string $mimeType, Bitmap $provider): void { + if (\count(\Imagick::queryFormats('SVG')) === 0) { + $this->markTestSkipped('No SVG/extra ImageMagick coders present'); + } + # HEIF is registered as a coder distinct from HEIC on a real libheif1 install (verified + # against the actual owncloud/server images), but not in every CI ImageMagick build. + if ($mimeType === 'image/heif' && \count(\Imagick::queryFormats('HEIF')) === 0) { + $this->markTestSkipped('No distinct HEIF coder present in this environment'); + } + $content = \file_get_contents(__DIR__ . '/../../data/' . $fixture); + $file = $this->makeFile($content, $mimeType); + + $result = $provider->getThumbnail($file, 32, 32, false); + + $this->assertNotFalse($result, "$fixture via " . \get_class($provider) . ' should have decoded'); + } + + public function providesLegitimateContent(): Generator { + yield 'PDF' => ['testimage.pdf', 'application/pdf', new PDF()]; + yield 'Postscript (EPS)' => ['testimage.eps', 'application/postscript', new Postscript()]; + yield 'Illustrator (AI)' => ['testimage.ai', 'application/illustrator', new Illustrator()]; + yield 'Photoshop (PSD)' => ['testimage.psd', 'application/x-photoshop', new Photoshop()]; + yield 'SGI' => ['testimage.sgi', 'image/sgi', new SGI()]; + yield 'TIFF' => ['testimage.tiff', 'image/tiff', new TIFF()]; + # no genuine OTF ('OTTO'-tagged) fixture here: this environment's ImageMagick/FreeType + # delegate cannot decode CFF-outline OpenType fonts at all, pinned or not - confirmed + # against four real system .otf files. TTF-tagged content, which the TTF coder decodes + # fine, is what's actually exercised in practice for the font-sfnt mime type. + yield 'Font (font-sfnt, ttf bytes)' => ['testimage.ttf', 'application/font-sfnt', new Font()]; + yield 'Heic (image/heic)' => ['testimage.heic', 'image/heic', new Heic()]; + yield 'Heic (image/heif)' => ['testimage.heic', 'image/heif', new Heic()]; + } + + /** + * PostScript content is sniffed by libmagic as application/postscript, which + * isDangerousToDecode() must not reject since Postscript/PDF legitimately decode + * it - so the mime-type gate alone lets it through here too. Pinning the expected + * coder is what stops ImageMagick's own content-sniffing from handing it to the + * Ghostscript delegate through a provider that has nothing to do with PostScript. + * + * PDF/Postscript/Illustrator are deliberately not in this set: they are the + * Ghostscript-backed providers PostScript-ish content is NOT foreign to, so + * feeding it to them tests Ghostscript's own leniency, not cross-coder confusion. + * Font is also excluded: see testFontNeverInvokesADangerousCoderForForeignContent(). + * + * @dataProvider providesForeignProviders + */ + public function testRejectsPostScriptContentFromAForeignProvider(Bitmap $provider, string $mimeType): void { + if (\count(\Imagick::queryFormats('SVG')) === 0) { + $this->markTestSkipped('No SVG/extra ImageMagick coders present'); + } + $postscript = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n"; + $file = $this->makeFile($postscript, $mimeType); + + $result = $provider->getThumbnail($file, 32, 32, false); + + $this->assertFalse($result); + } + + public function providesForeignProviders(): Generator { + yield 'SGI' => [new SGI(), 'image/sgi']; + yield 'Photoshop' => [new Photoshop(), 'application/x-photoshop']; + yield 'TIFF' => [new TIFF(), 'image/tiff']; + yield 'Heic' => [new Heic(), 'image/heic']; + } + + public function testFontNeverInvokesADangerousCoderForForeignContent(): void { + if (\count(\Imagick::queryFormats('SVG')) === 0) { + $this->markTestSkipped('No SVG/extra ImageMagick coders present'); + } + $postscript = "%!PS-Adobe-3.0\n%%BoundingBox: 0 0 10 10\nshowpage\n"; + $file = $this->makeFile($postscript, 'application/font-sfnt'); + + $result = (new Font())->getThumbnail($file, 32, 32, false); + + # FreeType fails on non-font bytes by producing a blank placeholder, not by + # invoking Ghostscript or a script coder - so this may be a small valid image + # rather than false, but it must never carry rendered PostScript content. + if ($result !== false) { + $this->assertLessThan(2048, \strlen($result->data())); + } + } +} diff --git a/tests/lib/Preview/SanitizeTest.php b/tests/lib/Preview/SanitizeTest.php index 882a1230866c..fef24e0da798 100644 --- a/tests/lib/Preview/SanitizeTest.php +++ b/tests/lib/Preview/SanitizeTest.php @@ -32,7 +32,7 @@ class SanitizeTest extends TestCase { /** * @dataProvider providesSVG */ - public function test(string $svgContent, Bitmap $provider): void { + public function test(string $svgContent, Bitmap $provider, string $mimeType): void { if (\count(\Imagick::queryFormats('SVG')) === 0) { $this->markTestSkipped('No SVG provider present'); } @@ -43,6 +43,7 @@ public function test(string $svgContent, Bitmap $provider): void { $file = $this->createMock(File::class); $file->method('getContent')->willReturn($svgContent); $file->method('fopen')->willReturn($stream); + $file->method('getMimeType')->willReturn($mimeType); # create the preview - SVG/text/script-shaped content must never reach Imagick via a Bitmap provider $return = $provider->getThumbnail($file, 32, 32, false); @@ -78,13 +79,13 @@ public function providesSVG(): Generator { SVG; # all Bitmap based providers use the same thumbnailing logic - two is enough .... - yield 'PDF provider - image tag' => [$svgContent0, new PDF()]; - yield 'Font Provider - image tag' => [$svgContent0, new Font()]; - yield 'PDF provider - malformed SVG with MSL href' => [$malformedSvgWithMslHref, new PDF()]; - yield 'Font Provider - malformed SVG with MSL href' => [$malformedSvgWithMslHref, new Font()]; - yield 'PDF provider - raw MVG' => [$rawMvg, new PDF()]; - yield 'Font Provider - raw MVG' => [$rawMvg, new Font()]; - yield 'PDF provider - well-formed SVG' => [$wellFormedSvg, new PDF()]; - yield 'Font Provider - well-formed SVG' => [$wellFormedSvg, new Font()]; + yield 'PDF provider - image tag' => [$svgContent0, new PDF(), 'application/pdf']; + yield 'Font Provider - image tag' => [$svgContent0, new Font(), 'application/font-sfnt']; + yield 'PDF provider - malformed SVG with MSL href' => [$malformedSvgWithMslHref, new PDF(), 'application/pdf']; + yield 'Font Provider - malformed SVG with MSL href' => [$malformedSvgWithMslHref, new Font(), 'application/font-sfnt']; + yield 'PDF provider - raw MVG' => [$rawMvg, new PDF(), 'application/pdf']; + yield 'Font Provider - raw MVG' => [$rawMvg, new Font(), 'application/font-sfnt']; + yield 'PDF provider - well-formed SVG' => [$wellFormedSvg, new PDF(), 'application/pdf']; + yield 'Font Provider - well-formed SVG' => [$wellFormedSvg, new Font(), 'application/font-sfnt']; } } diff --git a/tests/lib/TempManagerTest.php b/tests/lib/TempManagerTest.php index 3280a552402a..8c973a1ae6ef 100644 --- a/tests/lib/TempManagerTest.php +++ b/tests/lib/TempManagerTest.php @@ -194,6 +194,56 @@ public function testBuildFileNameWithSuffixPathTraversal(): void { $this->assertStringEndsWith('.Traversal..FileName', $tmpManager); } + public function testGetRamTemporaryFileUsesConfiguredRamDirWhenWritable(): void { + $ramDir = $this->baseDir . '/ram'; + \mkdir($ramDir); + $config = $this->createMock(IConfig::class); + $config->method('getSystemValue') + ->willReturnMap([ + ['tempdirectory', null, '/tmp'], + ['ramtempdirectory', null, $ramDir], + ]); + $manager = $this->getManager(null, $config); + + $file = $manager->getRamTemporaryFile('txt'); + + $this->assertStringEndsWith('.txt', $file); + $this->assertStringStartsWith($ramDir, $file); + $this->assertTrue(\is_file($file)); + } + + public function testGetRamTemporaryFileFallsBackToDiskWhenRamDirMissing(): void { + $config = $this->createMock(IConfig::class); + $config->method('getSystemValue') + ->willReturnMap([ + ['tempdirectory', null, '/tmp'], + ['ramtempdirectory', null, '/nonexistent-ram-dir-for-testing'], + ]); + $manager = $this->getManager(null, $config); + + $file = $manager->getRamTemporaryFile('txt'); + + $this->assertStringEndsWith('.txt', $file); + $this->assertStringStartsWith($this->baseDir, $file); + $this->assertTrue(\is_file($file)); + } + + public function testGetRamTemporaryFileDisabledByExplicitFalse(): void { + $config = $this->createMock(IConfig::class); + $config->method('getSystemValue') + ->willReturnMap([ + ['tempdirectory', null, '/tmp'], + ['ramtempdirectory', null, false], + ]); + $manager = $this->getManager(null, $config); + + $file = $manager->getRamTemporaryFile('txt'); + + $this->assertStringEndsWith('.txt', $file); + $this->assertStringStartsWith($this->baseDir, $file); + $this->assertTrue(\is_file($file)); + } + public function testGetTempBaseDirFromConfig(): void { $dir = $this->getManager()->getTemporaryFolder();