Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.82% covered (warning)
81.82%
27 / 33
40.00% covered (danger)
40.00%
2 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
FrozenFilePolicySnapshotResolver
81.82% covered (warning)
81.82%
27 / 33
40.00% covered (danger)
40.00%
2 / 5
17.54
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
 findSnapshot
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 findSnapshotOnChildren
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 findSnapshotOnEnvelope
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
3.71
 extractSnapshot
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
1<?php
2
3declare(strict_types=1);
4/**
5 * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors
6 * SPDX-License-Identifier: AGPL-3.0-or-later
7 */
8
9namespace OCA\Libresign\Service\Policy;
10
11use Closure;
12use OCA\Libresign\Db\File as FileEntity;
13use OCA\Libresign\Db\FileMapper;
14
15/**
16 * Shared envelope/child resolution for write-once file policy snapshots.
17 *
18 * An envelope is created before the file policy appliers run, so a freshly
19 * created envelope may carry no value of its own and the one the request was
20 * created with lives on the documents it contains. A requester editing the
21 * envelope later writes the new value on the envelope itself.
22 *
23 * Every document of an envelope therefore answers with the value of the
24 * envelope, so that documents added to an envelope after it was created cannot
25 * end up governed by different rules than the ones already in it.
26 *
27 * @template T of array<string, mixed>
28 */
29final class FrozenFilePolicySnapshotResolver {
30    /**
31     * @param Closure(mixed): T $normalizeEffectiveValue
32     */
33    public function __construct(
34        private FileMapper $fileMapper,
35        private string $policyKey,
36        private Closure $normalizeEffectiveValue,
37    ) {
38    }
39
40    /**
41     * @return T|null
42     */
43    public function findSnapshot(?FileEntity $file): ?array {
44        if (!$file instanceof FileEntity) {
45            return null;
46        }
47
48        $ownSnapshot = $this->extractSnapshot($file->getMetadata() ?? []);
49
50        if ($file->isEnvelope()) {
51            return $ownSnapshot ?? $this->findSnapshotOnChildren($file);
52        }
53
54        if ($file->hasParent()) {
55            return $this->findSnapshotOnEnvelope($file) ?? $ownSnapshot;
56        }
57
58        return $ownSnapshot;
59    }
60
61    /**
62     * The oldest document of the envelope carries the value the request was
63     * created with, so it is the one that answers for the whole envelope.
64     *
65     * @return T|null
66     */
67    private function findSnapshotOnChildren(FileEntity $envelope): ?array {
68        $envelopeId = $envelope->getId();
69        if ($envelopeId === null) {
70            return null;
71        }
72
73        $children = $this->fileMapper->getChildrenFiles($envelopeId);
74        usort($children, static fn (FileEntity $a, FileEntity $b): int => ($a->getId() ?? 0) <=> ($b->getId() ?? 0));
75
76        foreach ($children as $child) {
77            $childSnapshot = $this->extractSnapshot($child->getMetadata() ?? []);
78            if ($childSnapshot !== null) {
79                return $childSnapshot;
80            }
81        }
82
83        return null;
84    }
85
86    /**
87     * @return T|null
88     */
89    private function findSnapshotOnEnvelope(FileEntity $file): ?array {
90        $parentId = $file->getParentFileId();
91        if ($parentId === null) {
92            return null;
93        }
94
95        try {
96            $envelope = $this->fileMapper->getById($parentId);
97        } catch (\Throwable) {
98            return null;
99        }
100
101        return $this->findSnapshot($envelope);
102    }
103
104    /**
105     * @param array<string, mixed> $fileMetadata
106     * @return T|null
107     */
108    private function extractSnapshot(array $fileMetadata): ?array {
109        $policySnapshot = $fileMetadata['policy_snapshot'] ?? null;
110        if (!is_array($policySnapshot)) {
111            return null;
112        }
113
114        $entry = $policySnapshot[$this->policyKey] ?? null;
115        if (!is_array($entry) || !array_key_exists('effectiveValue', $entry)) {
116            return null;
117        }
118
119        return ($this->normalizeEffectiveValue)($entry['effectiveValue']);
120    }
121}