GCC Code Coverage Report


Directory: ./
File: CapeLicenseNotify.cpp
Date: 2026-10-29 08:11:58
Exec Total Coverage
Lines: 84 102 82.4%
Functions: 8 8 100.0%
Branches: 149 314 47.5%

Line Branch Exec Source
1 /*
2 * This file is part of the Falcon Player (FPP) and is Copyright (C)
3 * 2013-2026 by the Falcon Player Developers.
4 *
5 * The Falcon Player (FPP) is free software, and is covered under
6 * multiple Open Source licenses. Please see the included 'LICENSES'
7 * file for descriptions of what files are covered by each license.
8 *
9 * This source file is covered under the LGPL v2.1 as described in the
10 * included LICENSE.LGPL file.
11 */
12
13 #include "fpp-pch.h"
14
15 #include <chrono>
16 #include <string>
17
18 #include "CurlManager.h"
19 #include "common.h"
20 // Needed explicitly: fpp-pch.h stopped force-including jsoncpp, so under a
21 // NOPCH build (DISTRIBUTED_COMPILE, i.e. distcc or nocc) every translation unit
22 // that names Json:: has to ask for it. With the PCH it compiles either way,
23 // which is exactly why the omission survived.
24 #include "fpp-json.h"
25 #include "log.h"
26 #include "settings.h"
27
28 #include "CapeLicenseNotify.h"
29
30 // How long to wait before trying again when nothing has been delivered yet. A
31 // box that is offline at boot and gains a network later must still report, so
32 // this cannot be "once at startup"; a day is often enough to catch that and
33 // rare enough not to be a beacon.
34 #define CAPE_LICENSE_RETRY_HOURS 24
35
36 // How long after a successful notification before sending another. The vendor
37 // wants to know the situation is still current, not to hear about it daily.
38 // Same shape and same floor as the statistics publish interval.
39 #define CAPE_LICENSE_REPEAT_DAYS 7
40
41 6 std::string CapeLicenseStateFile() {
42
3/6
✓ Branch 0 taken 6 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 6 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 6 times.
✗ Branch 5 not taken.
12 return FPP_DIR_CONFIG("/cape_license_state.json");
43 }
44
45 5 static std::string urlEscape(const std::string& s) {
46 static const char* HEX = "0123456789ABCDEF";
47 5 std::string out;
48
2/2
✓ Branch 0 taken 23 times.
✓ Branch 1 taken 5 times.
28 for (unsigned char c : s) {
49
1/10
✗ Branch 0 not taken.
✓ Branch 1 taken 23 times.
✗ Branch 2 not taken.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✗ Branch 7 not taken.
✗ Branch 8 not taken.
✗ Branch 9 not taken.
23 if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') {
50
1/2
✓ Branch 0 taken 23 times.
✗ Branch 1 not taken.
23 out += (char)c;
51 } else {
52 ✗ out += '%';
53 ✗ out += HEX[c >> 4];
54 ✗ out += HEX[c & 0xF];
55 }
56 }
57 5 return out;
58 ✗ }
59
60 5 static void appendParam(std::string& url, const char* name, const std::string& value) {
61
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 5 times.
5 if (value.empty()) {
62 ✗ return;
63 }
64
2/2
✓ Branch 0 taken 1 times.
✓ Branch 1 taken 4 times.
5 url += (url.find('?') == std::string::npos) ? '?' : '&';
65 5 url += name;
66 5 url += '=';
67
2/4
✓ Branch 0 taken 5 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 5 times.
✗ Branch 3 not taken.
5 url += urlEscape(value);
68 }
69
70 // Which vendor URL the notification goes to.
71 //
72 // `licenseCheckUrl` is the endpoint a vendor can declare in their EEPROM image
73 // to receive this and nothing else. Where it is absent -- every EEPROM in the
74 // field today -- the logo URL is used, because that is the host the old
75 // mechanism reached by hijacking the <img> and is therefore known to be the
76 // vendor's own and known to be in the CSP. Coverage never regresses just
77 // because an EEPROM predates the endpoint.
78 1 static std::string notifyURL(const Json::Value& capeInfo) {
79
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✗ Branch 2 not taken.
✓ Branch 3 taken 1 times.
1 if (!capeInfo.isMember("vendor")) {
80 ✗ return "";
81 }
82
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 const Json::Value& vendor = capeInfo["vendor"];
83 1 std::string url;
84
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (vendor.isMember("licenseCheckUrl")) {
85
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 url = vendor["licenseCheckUrl"].asString();
86 ✗ } else if (vendor.isMember("image")) {
87 ✗ url = vendor["image"].asString();
88 ✗ } else if (vendor.isMember("url")) {
89 ✗ url = vendor["url"].asString();
90 }
91
7/18
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✗ Branch 6 not taken.
✓ Branch 7 taken 1 times.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1 times.
✗ Branch 11 not taken.
✗ Branch 12 not taken.
✓ Branch 13 taken 1 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
✗ Branch 16 not taken.
✗ Branch 17 not taken.
3 if (url.empty() || !startsWith(url, "http")) {
92 ✗ return "";
93 }
94
95 // What the vendor needs to match this against their order book, and no more.
96 // It is the same set the hijacked logo fetch already sent, so nothing new is
97 // disclosed by naming the request for what it is -- and data minimisation is
98 // part of what makes the Art 6(1)(f) balance come out this way (see the
99 // header), so this list does not grow without that being reconsidered.
100
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 appendParam(url, "lc", "1");
101
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (capeInfo.isMember("id")) {
102
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 appendParam(url, "id", capeInfo["id"].asString());
103 }
104
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (capeInfo.isMember("serialNumber")) {
105
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 appendParam(url, "sn", capeInfo["serialNumber"].asString());
106 }
107
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (capeInfo.isMember("cs")) {
108
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 appendParam(url, "cs", capeInfo["cs"].asString());
109 }
110
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (capeInfo.isMember("verifiedKeyId")) {
111
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 appendParam(url, "key", capeInfo["verifiedKeyId"].asString());
112 }
113 1 return url;
114 1 }
115
116 // Identifies the cape this state refers to. A different cape -- or the same
117 // cape re-signed -- is a new detection and reports immediately rather than
118 // inheriting the previous one's quiet period.
119 2 static std::string capeIdentity(const Json::Value& capeInfo) {
120 2 std::string id;
121
2/2
✓ Branch 0 taken 8 times.
✓ Branch 1 taken 2 times.
10 for (const char* k : { "id", "serialNumber", "cs", "verifiedKeyId" }) {
122
2/4
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
8 if (capeInfo.isMember(k)) {
123
3/6
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 8 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 8 times.
✗ Branch 5 not taken.
8 id += capeInfo[k].asString();
124 }
125
1/2
✓ Branch 0 taken 8 times.
✗ Branch 1 not taken.
8 id += "/";
126 }
127 2 return id;
128 ✗ }
129
130 1 static void recordAttempt(const std::string& identity, bool delivered) {
131
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 Json::Value state;
132
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✗ Branch 4 not taken.
✓ Branch 5 taken 1 times.
1 if (FileExists(CapeLicenseStateFile())) {
133 ✗ LoadJsonFromFile(CapeLicenseStateFile(), state);
134 }
135
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (!state.isObject()) {
136
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 state = Json::Value(Json::objectValue);
137 }
138
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 state["cape"] = identity;
139
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 state["lastAttempt"] = (Json::Int64)time(nullptr);
140
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (delivered) {
141
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 state["lastSuccess"] = (Json::Int64)time(nullptr);
142
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 state["source"] = "device";
143 }
144
3/6
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
1 SaveJsonToFile(state, CapeLicenseStateFile());
145
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 SetFilePerms(CapeLicenseStateFile());
146 1 }
147
148 25736 void CapeLicenseNotifyBackground() {
149 // Nothing here is expensive, but the idle tick runs about once a second and
150 // this needs to run about once a day, so hold the file reads behind a clock
151 // check. The counter is deliberately in-process: a restart may re-check
152 // sooner than the interval, which is harmless -- the state file below is
153 // what actually decides whether anything is sent.
154
3/4
✓ Branch 0 taken 1194 times.
✓ Branch 1 taken 24542 times.
✓ Branch 2 taken 1194 times.
✗ Branch 3 not taken.
25736 static auto nextCheck = std::chrono::steady_clock::now();
155 static bool inFlight = false;
156
6/8
✓ Branch 0 taken 25736 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 25736 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 24542 times.
✓ Branch 5 taken 1194 times.
✓ Branch 6 taken 24542 times.
✓ Branch 7 taken 1194 times.
25736 if (inFlight || std::chrono::steady_clock::now() < nextCheck) {
157 25735 return;
158 }
159
1/2
✓ Branch 0 taken 1194 times.
✗ Branch 1 not taken.
1194 nextCheck = std::chrono::steady_clock::now() + std::chrono::hours(1);
160
161
1/2
✓ Branch 0 taken 1194 times.
✗ Branch 1 not taken.
1194 Json::Value capeInfo;
162
2/4
✓ Branch 0 taken 1194 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1194 times.
✗ Branch 3 not taken.
1194 std::string capeFile = FPP_DIR_MEDIA("/tmp/cape-info.json");
163
10/14
✓ Branch 0 taken 1194 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 4 times.
✓ Branch 3 taken 1190 times.
✓ Branch 4 taken 4 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 4 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 4 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 2 times.
✓ Branch 11 taken 2 times.
✓ Branch 12 taken 1192 times.
✓ Branch 13 taken 2 times.
1194 if (!FileExists(capeFile) || !LoadJsonFromFile(capeFile, capeInfo) || !capeInfo.isMember("licenseCheck")) {
164 // The overwhelmingly common case: no cape, or a cape whose signature is
165 // bound to real hardware. Nothing to report, now or later.
166 1192 return;
167 }
168
169 // Deliberately NOT gated on hideExternalURLs. That setting is kiosk mode:
170 // it hides links so somebody at the kiosk cannot navigate off the box, and
171 // it says nothing about whether FPP itself may make a request. Reading it
172 // as "the user asked for no outbound traffic" would be inventing a privacy
173 // control out of a navigation one -- and it would leave the vendor hearing
174 // nothing from precisely the installs least likely to be administered
175 // through a browser.
176
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 std::string identity = capeIdentity(capeInfo);
177
1/2
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
2 Json::Value state;
178
4/6
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 2 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 1 times.
2 if (FileExists(CapeLicenseStateFile())) {
179
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 LoadJsonFromFile(CapeLicenseStateFile(), state);
180 }
181 2 time_t now = time(nullptr);
182
10/16
✓ Branch 0 taken 2 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
✓ Branch 10 taken 1 times.
✓ Branch 11 taken 1 times.
✓ Branch 12 taken 1 times.
✓ Branch 13 taken 1 times.
✗ Branch 14 not taken.
✗ Branch 15 not taken.
2 if (state.isObject() && state["cape"].asString() == identity) {
183
4/8
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
1 time_t lastSuccess = state.isMember("lastSuccess") ? (time_t)state["lastSuccess"].asInt64() : 0;
184
4/8
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
1 time_t lastAttempt = state.isMember("lastAttempt") ? (time_t)state["lastAttempt"].asInt64() : 0;
185
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 if (lastSuccess && (now - lastSuccess) < (CAPE_LICENSE_REPEAT_DAYS * 24 * 60 * 60)) {
186 1 return;
187 }
188 // Either transport counts as an attempt, so a browser that reaches the
189 // vendor daily keeps this quiet even where the device never can.
190 ✗ if (!lastSuccess && (now - lastAttempt) < (CAPE_LICENSE_RETRY_HOURS * 60 * 60)) {
191 ✗ return;
192 }
193 }
194
195
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string url = notifyURL(capeInfo);
196
1/2
✗ Branch 0 not taken.
✓ Branch 1 taken 1 times.
1 if (url.empty()) {
197 ✗ LogDebug(VB_GENERAL, "Cape signature is not bound to this hardware, but the cape declares no vendor URL to report it to\n");
198 ✗ return;
199 }
200
201 // Logged either way. The point of moving this out of the logo fetch is that
202 // the device's own logs show it happened; a detection nobody can tell from a
203 // coincidence is what the old mechanism left behind.
204
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 LogInfo(VB_GENERAL, "Cape signature verifies but is not bound to this hardware; notifying the cape vendor\n");
205 1 inFlight = true;
206
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 std::string identityCopy = identity;
207
5/10
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
✓ Branch 4 taken 1 times.
✗ Branch 5 not taken.
✓ Branch 6 taken 1 times.
✗ Branch 7 not taken.
✓ Branch 8 taken 1 times.
✗ Branch 9 not taken.
3 CurlManager::INSTANCE.addGet(url, [identityCopy](int rc, const std::string& resp) {
208 1 inFlight = false;
209
2/4
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✗ Branch 3 not taken.
1 bool delivered = (rc >= 200 && rc < 400);
210
1/2
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
1 if (delivered) {
211 1 LogInfo(VB_GENERAL, "Cape vendor notified of unbound cape signature (HTTP %d)\n", rc);
212 } else {
213 ✗ LogInfo(VB_GENERAL, "Could not notify the cape vendor of an unbound cape signature (HTTP %d); will retry\n", rc);
214 }
215 1 recordAttempt(identityCopy, delivered);
216 1 });
217
9/10
✓ Branch 0 taken 1 times.
✗ Branch 1 not taken.
✓ Branch 2 taken 1 times.
✓ Branch 3 taken 1 times.
✓ Branch 4 taken 1 times.
✓ Branch 5 taken 1 times.
✓ Branch 6 taken 1 times.
✓ Branch 7 taken 1193 times.
✓ Branch 8 taken 1 times.
✓ Branch 9 taken 1193 times.
2389 }
218