Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
63.57% |
89 / 140 |
|
45.45% |
10 / 22 |
CRAP | |
0.00% |
0 / 1 |
| GeneratedCrlStorageService | |
63.57% |
89 / 140 |
|
45.45% |
10 / 22 |
291.53 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getScopeKey | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| read | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| readMetadata | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
| getMTime | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| write | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
3 | |||
| delete | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| markStale | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
| getScopeRelativePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getScopeAbsolutePath | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| ensureFolderExists | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
5.03 | |||
| getScopeFolder | |
87.50% |
7 / 8 |
|
0.00% |
0 / 1 |
4.03 | |||
| getAppDataRootFolder | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
3.07 | |||
| getScopeFolderNode | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| getFileNode | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
12 | |||
| writeScopeFile | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
30 | |||
| writeFileWithSimpleFolder | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| writeFileAtomically | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
| replaceFileAtomicallyOnLocalStorage | |
0.00% |
0 / 13 |
|
0.00% |
0 / 1 |
72 | |||
| getChildInternalPath | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
| isKnownAppDataBootstrapFailure | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| normalizeEngineType | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** |
| 6 | * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors |
| 7 | * SPDX-License-Identifier: AGPL-3.0-or-later |
| 8 | */ |
| 9 | |
| 10 | namespace OCA\Libresign\Service\Crl; |
| 11 | |
| 12 | use OCA\Libresign\AppInfo\Application; |
| 13 | use OCA\Libresign\Enum\CertificateEngineType; |
| 14 | use OCP\Files\AppData\IAppDataFactory; |
| 15 | use OCP\Files\File; |
| 16 | use OCP\Files\Folder; |
| 17 | use OCP\Files\IRootFolder; |
| 18 | use OCP\Files\NotFoundException; |
| 19 | use OCP\Files\SimpleFS\ISimpleFolder; |
| 20 | |
| 21 | class 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 | } |