Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.42% covered (success)
92.42%
61 / 66
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
UsageStatisticsSchema
92.42% covered (success)
92.42%
61 / 66
77.78% covered (warning)
77.78%
7 / 9
36.56
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
11
 load
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fromArray
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 readShippedSchema
60.00% covered (warning)
60.00%
6 / 10
0.00% covered (danger)
0.00%
0 / 1
6.60
 parseMetric
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
14
 application
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 version
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 definitions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
2 / 2
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\UsageStatistics;
10
11use OCA\Libresign\AppInfo\Application;
12use OCA\Libresign\Service\UsageStatistics\Model\MetricDefinition;
13
14/**
15 * The versioned schema shipped in appinfo/usage-statistics/. The checks
16 * mirror the usage statistics server, so a schema it would refuse fails here.
17 */
18final class UsageStatisticsSchema {
19    public const VERSION = 1;
20
21    private const NAME_PATTERN = '/^[A-Za-z0-9_.:-]+$/D';
22    private const METRIC_FIELDS = ['category', 'key', 'type', 'kind', 'aggregation', 'description', 'required'];
23    private const TYPES = ['integer', 'number', 'boolean', 'string'];
24    private const KINDS = ['snapshot', 'period', 'counter', 'categorical'];
25    private const AGGREGATIONS = ['distribution', 'numerical', 'none'];
26    private const MAX_METRICS = 256;
27    private const MAX_DESCRIPTION_BYTES = 512;
28
29    private readonly string $application;
30    private readonly int $version;
31    /** @var array<string, MetricDefinition> */
32    private array $definitions = [];
33
34    /**
35     * @param array<mixed>|null $data a decoded schema; null reads the one shipped with the app
36     */
37    public function __construct(?array $data = null) {
38        $data ??= self::readShippedSchema();
39
40        $unknownFields = array_diff(array_keys($data), ['application', 'schemaVersion', 'metrics']);
41        if ($unknownFields !== []) {
42            throw new UsageStatisticsException('Unknown schema field: ' . implode(', ', $unknownFields));
43        }
44        if (($data['application'] ?? null) !== Application::APP_ID) {
45            throw new UsageStatisticsException('Schema application must be ' . Application::APP_ID);
46        }
47        $version = $data['schemaVersion'] ?? null;
48        if (!is_int($version) || $version < 1) {
49            throw new UsageStatisticsException('Schema version must be a positive integer');
50        }
51        $metrics = $data['metrics'] ?? null;
52        if (!is_array($metrics) || !array_is_list($metrics) || $metrics === [] || count($metrics) > self::MAX_METRICS) {
53            throw new UsageStatisticsException('Schema must define between 1 and ' . self::MAX_METRICS . ' metrics');
54        }
55
56        foreach ($metrics as $metric) {
57            $definition = self::parseMetric($metric);
58            if (isset($this->definitions[$definition->id()])) {
59                throw new UsageStatisticsException('Duplicate metric: ' . $definition->id());
60            }
61            $this->definitions[$definition->id()] = $definition;
62        }
63        $this->application = Application::APP_ID;
64        $this->version = $version;
65    }
66
67    public static function load(): self {
68        return new self();
69    }
70
71    public static function fromArray(array $data): self {
72        return new self($data);
73    }
74
75    private static function readShippedSchema(): array {
76        $path = dirname(__DIR__, 3) . '/appinfo/usage-statistics/schema-v' . self::VERSION . '.json';
77        $content = is_readable($path) ? file_get_contents($path) : false;
78        if ($content === false) {
79            throw new UsageStatisticsException('Usage statistics schema not found: ' . $path);
80        }
81        try {
82            $data = json_decode($content, true, 16, JSON_THROW_ON_ERROR);
83        } catch (\JsonException $e) {
84            throw new UsageStatisticsException('Usage statistics schema is not valid JSON', 0, $e);
85        }
86        if (!is_array($data)) {
87            throw new UsageStatisticsException('Usage statistics schema must be a JSON object');
88        }
89        return $data;
90    }
91
92    private static function parseMetric(mixed $metric): MetricDefinition {
93        if (!is_array($metric)) {
94            throw new UsageStatisticsException('Each metric must be an object');
95        }
96        $fields = array_keys($metric);
97        sort($fields);
98        $expected = self::METRIC_FIELDS;
99        sort($expected);
100        if ($fields !== $expected) {
101            throw new UsageStatisticsException('A metric must have exactly the fields: ' . implode(', ', self::METRIC_FIELDS));
102        }
103        foreach (['category', 'key'] as $name) {
104            if (!is_string($metric[$name]) || preg_match(self::NAME_PATTERN, $metric[$name]) !== 1) {
105                throw new UsageStatisticsException('Invalid metric ' . $name);
106            }
107        }
108        if (!in_array($metric['type'], self::TYPES, true)
109            || !in_array($metric['kind'], self::KINDS, true)
110            || !in_array($metric['aggregation'], self::AGGREGATIONS, true)) {
111            throw new UsageStatisticsException('Invalid type, kind or aggregation for ' . $metric['category'] . '.' . $metric['key']);
112        }
113        if ($metric['aggregation'] === 'numerical' && !in_array($metric['type'], ['integer', 'number'], true)) {
114            throw new UsageStatisticsException('Numerical aggregation requires a numeric type');
115        }
116        if (!is_string($metric['description']) || strlen($metric['description']) > self::MAX_DESCRIPTION_BYTES) {
117            throw new UsageStatisticsException('Invalid metric description');
118        }
119        if (!is_bool($metric['required'])) {
120            throw new UsageStatisticsException('Metric required flag must be a boolean');
121        }
122
123        return new MetricDefinition(
124            $metric['category'],
125            $metric['key'],
126            $metric['type'],
127            $metric['kind'],
128            $metric['aggregation'],
129            $metric['description'],
130            $metric['required'],
131        );
132    }
133
134    public function application(): string {
135        return $this->application;
136    }
137
138    public function version(): int {
139        return $this->version;
140    }
141
142    /** @return list<MetricDefinition> */
143    public function definitions(): array {
144        return array_values($this->definitions);
145    }
146
147    public function get(string $metricId): MetricDefinition {
148        return $this->definitions[$metricId]
149            ?? throw new UsageStatisticsException('Unknown usage statistics metric: ' . $metricId);
150    }
151}