Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.10% covered (success)
97.10%
67 / 69
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
SignatureRejectionPolicyValidator
97.10% covered (success)
97.10%
67 / 69
77.78% covered (warning)
77.78%
7 / 9
23
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
 validateRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateLayer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
8
 assertNoDependentSettingSubmitted
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 assertCommentAudienceNotSubmitted
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 assertCommentAudienceIsNotWider
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 isDefaultValue
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
1.00
 translate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Provider\SignatureRejection;
10
11use OCA\Libresign\Enum\SignatureRejectionCommentMode;
12use OCP\IL10N;
13
14/**
15 * Enforces the rules that no single rejection setting can express on its own.
16 *
17 * Each of the five settings is validated by its own policy key, which is enough
18 * to know that a value exists and is allowed, but not to know that the settings
19 * make sense together: a comment audience wider than the rejection audience
20 * would let the comment disclose a rejection that must stay hidden, and a
21 * dependent setting means nothing while the setting it depends on is off.
22 *
23 * The validator therefore always receives the final combined configuration,
24 * never a single key, so the outcome does not depend on the order in which the
25 * keys were saved. It is used on both writing paths: when an administrator
26 * saves a policy layer and when a signature request is configured.
27 *
28 * Values that were merely inherited are corrected rather than refused: an
29 * administrator lowering the rejection audience must not break every request
30 * that already resolved a wider comment audience. Only what the caller is
31 * submitting right now is refused, so a client never gets a silent result other
32 * than the one it asked for.
33 *
34 * A single key written on its own therefore carries no cross-setting rule: the
35 * caller is not stating what the other settings should be, and refusing it
36 * would make the outcome depend on which key was saved first. What a stored
37 * combination cannot do is disclose more than intended, and that is guaranteed
38 * elsewhere: {@see SignatureRejectionPolicyConfig::fromValues()} narrows the
39 * comment audience to the rejection audience whenever the configuration is
40 * built.
41 */
42class SignatureRejectionPolicyValidator {
43    public function __construct(
44        private readonly ?IL10N $l10n = null,
45    ) {
46    }
47
48    /**
49     * A signature request is configured as a whole, so every dependency applies:
50     * a setting that cannot take effect on this document is refused rather than
51     * silently dropped.
52     *
53     * @param array<string, mixed> $combinedValues The five values, keyed by policy key
54     * @param list<string> $submittedKeys The keys the caller is writing right now
55     * @throws \InvalidArgumentException when the submitted combination breaks a dependency
56     */
57    public function validateRequest(array $combinedValues, array $submittedKeys = []): SignatureRejectionPolicyConfig {
58        return $this->validate($combinedValues, $submittedKeys, true);
59    }
60
61    /**
62     * A policy layer describes a standing rule rather than a document, so an
63     * administrator may configure the comment mode before enabling rejection,
64     * in whichever order they like. What cannot be allowed in any order is
65     * disclosing the comment to an audience that may not see the rejection, and
66     * that is decided on the configuration the write produces as a whole.
67     *
68     * @param array<string, mixed> $combinedValues The five values, keyed by policy key
69     * @param list<string> $submittedKeys The keys the administrator is writing right now
70     * @throws \InvalidArgumentException when the resulting combination would widen a disclosure
71     */
72    public function validateLayer(array $combinedValues, array $submittedKeys): void {
73        $this->validate($combinedValues, $submittedKeys, false);
74    }
75
76    /**
77     * @param array<string, mixed> $combinedValues
78     * @param list<string> $submittedKeys
79     */
80    private function validate(array $combinedValues, array $submittedKeys, bool $dependentSettingsApply): SignatureRejectionPolicyConfig {
81        $enabled = SignatureRejectionPolicyConfig::normalizeEnabled(
82            $combinedValues[SignatureRejectionPolicy::KEY_ENABLED] ?? null,
83        );
84        $commentMode = SignatureRejectionPolicyConfig::normalizeCommentMode(
85            $combinedValues[SignatureRejectionPolicy::KEY_COMMENT_MODE] ?? null,
86        );
87        $visibility = SignatureRejectionPolicyConfig::normalizeVisibility(
88            $combinedValues[SignatureRejectionPolicy::KEY_VISIBILITY] ?? null,
89            SignatureRejectionPolicyConfig::DEFAULT_VISIBILITY,
90        );
91        $commentVisibility = SignatureRejectionPolicyConfig::normalizeVisibility(
92            $combinedValues[SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY] ?? null,
93            SignatureRejectionPolicyConfig::DEFAULT_COMMENT_VISIBILITY,
94        );
95
96        if ($enabled && $commentMode !== SignatureRejectionCommentMode::DISABLED && !$visibility->covers($commentVisibility)) {
97            $this->assertCommentAudienceIsNotWider($submittedKeys);
98        } elseif ($dependentSettingsApply && !$enabled) {
99            $this->assertNoDependentSettingSubmitted($combinedValues, $submittedKeys);
100        } elseif ($dependentSettingsApply && $commentMode === SignatureRejectionCommentMode::DISABLED) {
101            $this->assertCommentAudienceNotSubmitted($combinedValues, $submittedKeys);
102        }
103
104        return SignatureRejectionPolicyConfig::fromValues(
105            $enabled,
106            $combinedValues[SignatureRejectionPolicy::KEY_BEHAVIOR] ?? null,
107            $commentMode,
108            $visibility,
109            $commentVisibility,
110        );
111    }
112
113    /**
114     * @param array<string, mixed> $combinedValues
115     * @param list<string> $submittedKeys
116     */
117    private function assertNoDependentSettingSubmitted(array $combinedValues, array $submittedKeys): void {
118        foreach (SignatureRejectionPolicy::DEPENDENT_KEYS as $dependentKey) {
119            if (!in_array($dependentKey, $submittedKeys, true)) {
120                continue;
121            }
122
123            if ($this->isDefaultValue($dependentKey, $combinedValues[$dependentKey] ?? null)) {
124                continue;
125            }
126
127            throw new \InvalidArgumentException($this->translate(
128                // TRANSLATORS Error shown when a rejection setting is configured while signature rejection itself is turned off.
129                'Signature rejection is disabled, so the other rejection settings cannot be configured.',
130            ));
131        }
132    }
133
134    /**
135     * @param array<string, mixed> $combinedValues
136     * @param list<string> $submittedKeys
137     */
138    private function assertCommentAudienceNotSubmitted(array $combinedValues, array $submittedKeys): void {
139        if (!in_array(SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY, $submittedKeys, true)) {
140            return;
141        }
142
143        if ($this->isDefaultValue(
144            SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY,
145            $combinedValues[SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY] ?? null,
146        )) {
147            return;
148        }
149
150        throw new \InvalidArgumentException($this->translate(
151            // TRANSLATORS Error shown when the audience of a rejection comment is configured while rejection comments are turned off.
152            'Rejection comments are disabled, so the audience of the comment cannot be configured.',
153        ));
154    }
155
156    /** @param list<string> $submittedKeys */
157    private function assertCommentAudienceIsNotWider(array $submittedKeys): void {
158        $submitsAudience = in_array(SignatureRejectionPolicy::KEY_VISIBILITY, $submittedKeys, true)
159            || in_array(SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY, $submittedKeys, true);
160        if (!$submitsAudience) {
161            return;
162        }
163
164        throw new \InvalidArgumentException($this->translate(
165            // TRANSLATORS Error shown when the rejection comment would be visible to a wider audience than the rejection itself.
166            'The rejection comment cannot be visible to a wider audience than the rejection itself.',
167        ));
168    }
169
170    private function isDefaultValue(string $policyKey, mixed $value): bool {
171        $defaults = SignatureRejectionPolicyConfig::defaults()->toKeyedValues();
172
173        return match ($policyKey) {
174            SignatureRejectionPolicy::KEY_BEHAVIOR
175                => SignatureRejectionPolicyConfig::normalizeBehavior($value)->value === $defaults[$policyKey],
176            SignatureRejectionPolicy::KEY_COMMENT_MODE
177                => SignatureRejectionPolicyConfig::normalizeCommentMode($value)->value === $defaults[$policyKey],
178            SignatureRejectionPolicy::KEY_VISIBILITY, SignatureRejectionPolicy::KEY_COMMENT_VISIBILITY
179                => SignatureRejectionPolicyConfig::normalizeVisibility(
180                    $value,
181                    SignatureRejectionPolicyConfig::DEFAULT_VISIBILITY,
182                )->value === $defaults[$policyKey],
183            default => SignatureRejectionPolicyConfig::normalizeEnabled($value) === $defaults[$policyKey],
184        };
185    }
186
187    private function translate(string $message): string {
188        return $this->l10n?->t($message) ?? $message;
189    }
190}