Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
63.57% covered (warning)
63.57%
89 / 140
45.45% covered (danger)
45.45%
10 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
GeneratedCrlStorageService
63.57% covered (warning)
63.57%
89 / 140
45.45% covered (danger)
45.45%
10 / 22
291.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScopeKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 read
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 readMetadata
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 getMTime
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 write
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 delete
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 markStale
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 getScopeRelativePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getScopeAbsolutePath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 ensureFolderExists
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 getScopeFolder
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 getAppDataRootFolder
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getScopeFolderNode
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getFileNode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 writeScopeFile
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 writeFileWithSimpleFolder
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 writeFileAtomically
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 replaceFileAtomicallyOnLocalStorage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
72
 getChildInternalPath
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 isKnownAppDataBootstrapFailure
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalizeEngineType
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
1<?php
2
3declare(strict_types=1);
4
5/**
6 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
7 * SPDX-License-Identifier: AGPL-3.0-or-later
8 */
9
10namespace OCA\Libresign\Service\Crl;
11
12use OCA\Libresign\AppInfo\Application;
13use OCA\Libresign\Enum\CertificateEngineType;
14use OCP\Files\AppData\IAppDataFactory;
15use OCP\Files\File;
16use OCP\Files\Folder;
17use OCP\Files\IRootFolder;
18use OCP\Files\NotFoundException;
19use OCP\Files\SimpleFS\ISimpleFolder;
20
21class GeneratedCrlStorageService {
22    private const STORAGE_ROOT = 'generated_crl';
23    private const GENERATED_CRL_FILE = 'crl.der';
24    private const METADATA_FILE = 'meta.json';
25
26    public function __construct(
27        private IAppDataFactory $appDataFactory,
28        private IRootFolder $rootFolder,
29    ) {
30    }
31
32    public function getScopeKey(string $instanceId, int $generation, string $engineType): string {
33        $normalizedEngineType = $this->normalizeEngineType($engineType)->value;
34
35        return implode('/', [
36            $instanceId,
37            (string)$generation,
38            $normalizedEngineType,
39        ]);
40    }
41
42    public function read(string $instanceId, int $generation, string $engineType): ?string {
43        try {
44            $scopeFolder = $this->getScopeFolder($this->getScopeRelativePath($instanceId, $generation, $engineType));
45        } catch (NotFoundException) {
46            return null;
47        }
48
49        if ($scopeFolder === null) {
50            return null;
51        }
52
53        if (!$scopeFolder->fileExists(self::GENERATED_CRL_FILE)) {
54            return null;
55        }
56
57        return $scopeFolder->getFile(self::GENERATED_CRL_FILE)->getContent();
58    }
59
60    /**
61     * @return array<string, mixed>|null
62     */
63    public function readMetadata(string $instanceId, int $generation, string $engineType): ?array {
64        try {
65            $scopeFolder = $this->getScopeFolder($this->getScopeRelativePath($instanceId, $generation, $engineType));
66        } catch (NotFoundException) {
67            return null;
68        }
69
70        if ($scopeFolder === null) {
71            return null;
72        }
73
74        if (!$scopeFolder->fileExists(self::METADATA_FILE)) {
75            return null;
76        }
77
78        $rawMetadata = $scopeFolder->getFile(self::METADATA_FILE)->getContent();
79        $decoded = json_decode($rawMetadata, true);
80        if (!is_array($decoded)) {
81            return null;
82        }
83
84        return $decoded;
85    }
86
87    public function getMTime(string $instanceId, int $generation, string $engineType): ?int {
88        try {
89            $scopeFolder = $this->getScopeFolder($this->getScopeRelativePath($instanceId, $generation, $engineType));
90        } catch (NotFoundException) {
91            return null;
92        }
93
94        if ($scopeFolder === null) {
95            return null;
96        }
97
98        if (!$scopeFolder->fileExists(self::GENERATED_CRL_FILE)) {
99            return null;
100        }
101
102        return $scopeFolder->getFile(self::GENERATED_CRL_FILE)->getMTime();
103    }
104
105    /**
106     * @param array<string, mixed> $metadata
107     */
108    public function write(string $instanceId, int $generation, string $engineType, string $crlDer, array $metadata = []): void {
109        $relativePath = $this->getScopeRelativePath($instanceId, $generation, $engineType);
110        $scopeFolder = $this->ensureFolderExists($relativePath);
111        if ($scopeFolder === null) {
112            return;
113        }
114
115        // Use the SimpleFS API directly: it is the correct abstraction for AppData
116        // writes and avoids low-level Nextcloud VFS cache inconsistencies that can
117        // occur with wrapped storages (e.g. Files_Trashbin) on atomic rename paths.
118        $this->writeFileWithSimpleFolder($scopeFolder, self::GENERATED_CRL_FILE, $crlDer);
119
120        if ($metadata !== []) {
121            $this->writeFileWithSimpleFolder(
122                $scopeFolder,
123                self::METADATA_FILE,
124                json_encode($metadata, JSON_THROW_ON_ERROR),
125            );
126        }
127    }
128
129    public function delete(string $instanceId, int $generation, string $engineType): void {
130        try {
131            $scopeFolder = $this->getScopeFolder($this->getScopeRelativePath($instanceId, $generation, $engineType));
132        } catch (NotFoundException) {
133            // Nothing persisted for this scope yet.
134            return;
135        }
136
137        if ($scopeFolder === null) {
138            return;
139        }
140
141        $scopeFolder->delete();
142    }
143
144    /**
145     * Mark the persisted CRL as stale without deleting the DER content.
146     *
147     * Sets refreshDate to a past value so `isPersistedGeneratedCrlFresh()` returns
148     * false, triggering a background regeneration on the next request that needs the
149     * CRL. Concurrent requests can still read the existing (slightly outdated) DER
150     * via the `isReusable` path, preventing cold-start delays after each revocation.
151     */
152    public function markStale(string $instanceId, int $generation, string $engineType): void {
153        try {
154            $scopeFolder = $this->getScopeFolder($this->getScopeRelativePath($instanceId, $generation, $engineType));
155        } catch (NotFoundException) {
156            return;
157        }
158
159        if ($scopeFolder === null) {
160            return;
161        }
162
163        $meta = $this->readMetadata($instanceId, $generation, $engineType) ?? [];
164        $meta['refreshDate'] = '1970-01-01';
165        try {
166            $this->writeFileWithSimpleFolder($scopeFolder, self::METADATA_FILE, json_encode($meta, JSON_THROW_ON_ERROR));
167        } catch (\Exception) {
168            // Write failure is non-critical: the CRL DER is still available for
169            // concurrent requests and will be fully refreshed on the next request
170            // that sees isFresh=false (by mtime fallback) or on the next daily cron.
171        }
172    }
173
174    private function getScopeRelativePath(string $instanceId, int $generation, string $engineType): string {
175        return self::STORAGE_ROOT . '/' . $this->getScopeKey($instanceId, $generation, $engineType);
176    }
177
178    private function getScopeAbsolutePath(string $relativePath): string {
179        return '/' . $this->rootFolder->getAppDataDirectoryName() . '/' . Application::APP_ID . '/' . ltrim($relativePath, '/');
180    }
181
182    private function ensureFolderExists(string $relativePath): ?ISimpleFolder {
183        $folder = $this->getAppDataRootFolder();
184        if ($folder === null) {
185            return null;
186        }
187
188        foreach (explode('/', trim($relativePath, '/')) as $segment) {
189            if ($segment === '') {
190                continue;
191            }
192
193            try {
194                $folder = $folder->getFolder($segment);
195            } catch (NotFoundException) {
196                $folder = $folder->newFolder($segment);
197            }
198        }
199
200        return $folder;
201    }
202
203    private function getScopeFolder(string $relativePath): ?ISimpleFolder {
204        $folder = $this->getAppDataRootFolder();
205        if ($folder === null) {
206            return null;
207        }
208
209        foreach (explode('/', trim($relativePath, '/')) as $segment) {
210            if ($segment === '') {
211                continue;
212            }
213
214            $folder = $folder->getFolder($segment);
215        }
216
217        return $folder;
218    }
219
220    private function getAppDataRootFolder(): ?ISimpleFolder {
221        try {
222            return $this->appDataFactory->get(Application::APP_ID)->getFolder('/');
223        } catch (\TypeError $exception) {
224            if (!$this->isKnownAppDataBootstrapFailure($exception)) {
225                throw $exception;
226            }
227
228            return null;
229        }
230    }
231
232    private function getScopeFolderNode(string $relativePath): Folder {
233        $scopeFolder = $this->rootFolder->get($this->getScopeAbsolutePath($relativePath));
234        if (!$scopeFolder instanceof Folder) {
235            throw new \RuntimeException('Generated CRL storage scope is not a folder');
236        }
237
238        return $scopeFolder;
239    }
240
241    private function getFileNode(Folder $folder, string $fileName): ?File {
242        if (!$folder->nodeExists($fileName)) {
243            return null;
244        }
245
246        $file = $folder->get($fileName);
247        if (!$file instanceof File) {
248            return null;
249        }
250
251        return $file;
252    }
253
254    private function writeScopeFile(string $relativePath, ISimpleFolder $simpleFolder, string $fileName, string $content): void {
255        try {
256            $this->writeFileAtomically($this->getScopeFolderNode($relativePath), $fileName, $content);
257        } catch (NotFoundException $exception) {
258            // Some unit-test/bootstrap environments cannot resolve AppData root nodes,
259            // so fall back to the SimpleFS handle we already created for this scope.
260            $this->writeFileWithSimpleFolder($simpleFolder, $fileName, $content);
261        } catch (\TypeError $exception) {
262            if (!$this->isKnownAppDataBootstrapFailure($exception)) {
263                throw $exception;
264            }
265
266            // Some unit-test/bootstrap environments cannot resolve AppData root nodes,
267            // so fall back to the SimpleFS handle we already created for this scope.
268            $this->writeFileWithSimpleFolder($simpleFolder, $fileName, $content);
269        } catch (\Exception) {
270            // The atomic write path can fail in environments where AppData is exposed
271            // through a wrapped storage (e.g. Files_Trashbin) whose file-cache does
272            // not track the temporary file created here. Fall back to a simple write.
273            $this->writeFileWithSimpleFolder($simpleFolder, $fileName, $content);
274        }
275    }
276
277    private function writeFileWithSimpleFolder(ISimpleFolder $folder, string $fileName, string $content): void {
278        if ($folder->fileExists($fileName)) {
279            $folder->getFile($fileName)->putContent($content);
280            return;
281        }
282
283        $folder->newFile($fileName, $content);
284    }
285
286    private function writeFileAtomically(Folder $folder, string $fileName, string $content): void {
287        $tmpName = sprintf('.%s.tmp.%s', $fileName, bin2hex(random_bytes(8)));
288        $tmpFile = $folder->newFile($tmpName, $content);
289
290        try {
291            if ($this->replaceFileAtomicallyOnLocalStorage($folder, $tmpFile, $fileName)) {
292                return;
293            }
294
295            if ($folder->nodeExists($fileName)) {
296                $folder->get($fileName)->delete();
297            }
298
299            $tmpFile->move($folder->getFullPath($fileName));
300        } finally {
301            if ($folder->nodeExists($tmpName)) {
302                $folder->get($tmpName)->delete();
303            }
304        }
305    }
306
307    private function replaceFileAtomicallyOnLocalStorage(Folder $folder, File $tmpFile, string $fileName): bool {
308        $storage = $folder->getStorage();
309        if (!$storage->isLocal() || !($storage instanceof \OC\Files\Storage\Local)) {
310            return false;
311        }
312
313        $tmpInternalPath = $tmpFile->getInternalPath();
314        $targetInternalPath = $this->getChildInternalPath($folder, $fileName);
315
316        $tmpLocalPath = $storage->getLocalFile($tmpInternalPath);
317        $targetLocalPath = $storage->getLocalFile($targetInternalPath);
318        if (!is_string($tmpLocalPath) || $tmpLocalPath === '' || !is_string($targetLocalPath) || $targetLocalPath === '') {
319            return false;
320        }
321
322        if (!@rename($tmpLocalPath, $targetLocalPath)) {
323            return false;
324        }
325
326        $storage->getUpdater()->renameFromStorage($storage, $tmpInternalPath, $targetInternalPath);
327        return true;
328    }
329
330    private function getChildInternalPath(Folder $folder, string $childName): string {
331        $folderInternalPath = trim($folder->getInternalPath(), '/');
332        if ($folderInternalPath === '') {
333            return $childName;
334        }
335
336        return $folderInternalPath . '/' . $childName;
337    }
338
339    private function isKnownAppDataBootstrapFailure(\TypeError $exception): bool {
340        return str_contains($exception->getMessage(), 'OC\\Files\\Cache\\Scanner::$connection');
341    }
342
343    private function normalizeEngineType(string $engineType): CertificateEngineType {
344        $normalizedEngineType = CertificateEngineType::tryFromValue($engineType);
345        if ($normalizedEngineType === null) {
346            throw new \InvalidArgumentException("Invalid engine type: $engineType");
347        }
348
349        return $normalizedEngineType;
350    }
351}