Skip to content

Commit 536608f

Browse files
committed
Lock hard-mode schedules against direct periods.json edits
The watchdog trusted periods.json unconditionally, so hard mode on a schedule was only as strong as "don't open the JSON file" - disabling it, deleting it, or moving its endTime earlier all lifted the block instantly. The watchdog now remembers each hard-mode period's legitimate end time in memory once it sees it active, and keeps enforcing its apps/sites regardless of what the file says until that time passes. No new escape hatch - killing the elevated watchdog process remains the intentional way out.
1 parent 2aab654 commit 536608f

2 files changed

Lines changed: 182 additions & 3 deletions

File tree

‎Umbra.Core/WatchdogLoop.cs‎

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,25 @@ public class Enforcer
7373
private DateTime? _periodWindowStartUtc;
7474
private string? _periodWindowLabel;
7575

76+
// Verrou de confiance pour les plages hard mode : éditer periods.json
77+
// à la main pendant qu'une plage bloque activement (désactiver,
78+
// supprimer, ou simplement reculer son heure de fin) n'a plus d'effet
79+
// tant que l'heure de fin légitimement observée n'est pas atteinte.
80+
// Pas de porte de secours par code ici (volontairement, plus simple
81+
// que ce qu'on avait testé) - la vraie échappatoire reste de fermer
82+
// le watchdog élevé depuis le Gestionnaire des tâches (déjà le cas
83+
// avant ce verrou, ça continue de marcher pareil). En mémoire
84+
// seulement : un redémarrage du watchdog réinitialise la confiance,
85+
// même compromis assumé qu'ailleurs dans cette classe.
86+
private readonly Dictionary<string, TrustedHardPeriod> _trustedHardPeriods = new();
87+
88+
internal class TrustedHardPeriod
89+
{
90+
public required List<string> Apps;
91+
public required List<string> Sites;
92+
public required DateTime LockedUntil;
93+
}
94+
7695
private readonly Action<string> _notify; // "done" | "break" | "work"
7796

7897
public Enforcer(Action<string> notify)
@@ -105,20 +124,22 @@ public async Task TickAsync()
105124
{
106125
var s = Session.Load(); // fait aussi avancer les phases pomodoro dues
107126
var periodsData = Periods.Load();
108-
var activePeriods = Periods.GetActivePeriods(periodsData, DateTime.Now);
127+
var now = DateTime.Now;
128+
var activePeriods = Periods.GetActivePeriods(periodsData, now);
109129
// Comme Session.IsBlockingActive pour une session manuelle : une
110130
// plage en PomodoroMode ne doit pas bloquer pendant sa pause,
111131
// même si elle reste "active" au sens fenêtre horaire (l'anneau
112132
// continue de tourner côté UI, seul le blocage s'arrête).
113-
var blockingPeriods = activePeriods.Where(p => !p.PomodoroMode || !Periods.GetPomodoroTiming(p, DateTime.Now).IsBreak).ToList();
133+
var blockingPeriods = activePeriods.Where(p => !p.PomodoroMode || !Periods.GetPomodoroTiming(p, now).IsBreak).ToList();
134+
var phantomHardPeriods = ResolveHardLockedPeriods(periodsData, now);
114135
var sessionBlocking = Session.IsBlockingActive(s);
115136
// Indépendante de toute session/plage - "je ne veux jamais
116137
// pouvoir aller sur X" (voir AlwaysBlocklist). Un Load() de plus
117138
// par tick est négligeable : Blocklist.Load() tourne déjà tout
118139
// aussi souvent dès qu'une session manuelle est active, sur un
119140
// fichier tout aussi petit.
120141
var always = AlwaysBlocklist.Load();
121-
var shouldBlock = sessionBlocking || blockingPeriods.Count > 0 || always.Apps.Count > 0 || always.Sites.Count > 0;
142+
var shouldBlock = sessionBlocking || blockingPeriods.Count > 0 || always.Apps.Count > 0 || always.Sites.Count > 0 || phantomHardPeriods.Count > 0;
122143

123144
TrackPeriodHistory(s, activePeriods);
124145

@@ -150,6 +171,11 @@ public async Task TickAsync()
150171
}
151172
foreach (var a in always.Apps) apps.Add(a);
152173
foreach (var st in always.Sites) sites.Add(st);
174+
foreach (var p in phantomHardPeriods)
175+
{
176+
foreach (var a in p.Apps) apps.Add(a);
177+
foreach (var st in p.Sites) sites.Add(st);
178+
}
153179

154180
try
155181
{
@@ -233,6 +259,41 @@ public async Task TickAsync()
233259
_lastPhase = curPhase;
234260
}
235261

262+
// Plusieurs plages hard mode peuvent être verrouillées à la fois
263+
// (clé = Period.Id, stable même si la plage est renommée ensuite).
264+
// Retourne les plages "fantômes" - verrouillées en mémoire mais plus
265+
// présentes/actives légitimement dans periods.json (désactivée,
266+
// supprimée, horaire reculé...) - dont il faut quand même continuer
267+
// à bloquer apps/sites jusqu'à l'heure de fin retenue.
268+
internal List<TrustedHardPeriod> ResolveHardLockedPeriods(PeriodsData periodsData, DateTime now)
269+
{
270+
var seenIds = new HashSet<string>();
271+
foreach (var p in periodsData.Periods)
272+
{
273+
if (!p.HardMode || !Periods.PeriodCoversNow(p, now)) continue;
274+
seenIds.Add(p.Id);
275+
var end = now.AddSeconds(Periods.GetTiming(p, now).RemainingSeconds);
276+
if (!_trustedHardPeriods.TryGetValue(p.Id, out var trusted) || end >= trusted.LockedUntil)
277+
{
278+
_trustedHardPeriods[p.Id] = new TrustedHardPeriod
279+
{
280+
Apps = new List<string>(p.Apps),
281+
Sites = new List<string>(p.Sites),
282+
LockedUntil = end,
283+
};
284+
}
285+
}
286+
287+
var phantoms = new List<TrustedHardPeriod>();
288+
foreach (var (id, trusted) in _trustedHardPeriods.ToList())
289+
{
290+
if (seenIds.Contains(id)) continue; // toujours légitimement actif, rien à défendre
291+
if (now < trusted.LockedUntil) phantoms.Add(trusted);
292+
else _trustedHardPeriods.Remove(id); // engagement écoulé
293+
}
294+
return phantoms;
295+
}
296+
236297
// Une session manuelle a sa propre fenêtre (Session.Stop / fin de
237298
// pomodoro), donc on ne suit une fenêtre de plage que quand aucune
238299
// session n'est active du tout - sinon le même temps se

‎Umbra.Tests/WatchdogLoopTests.cs‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using Umbra.Core;
2+
3+
namespace Umbra.Tests;
4+
5+
// Couvre le verrou de confiance des plages hard mode (WatchdogLoop.Enforcer) :
6+
// une fois qu'une plage hard mode est observée active, un periods.json
7+
// modifié à la main (désactivée, supprimée, horaire reculé) ne doit plus
8+
// pouvoir la débloquer avant son heure de fin légitime. Teste la méthode de
9+
// résolution directement (internal, voir InternalsVisibleTo) plutôt que
10+
// TickAsync en entier, qui touche le fichier hosts/pare-feu réels.
11+
public class WatchdogLoopTests : IDisposable
12+
{
13+
private readonly string _tempDir;
14+
15+
public WatchdogLoopTests()
16+
{
17+
_tempDir = Path.Combine(Path.GetTempPath(), "umbra-tests-" + Guid.NewGuid());
18+
Config.DataDir = _tempDir;
19+
}
20+
21+
public void Dispose()
22+
{
23+
try { Directory.Delete(_tempDir, recursive: true); } catch { }
24+
}
25+
26+
private static WatchdogLoop.Enforcer MakeEnforcer() => new(_ => { });
27+
28+
private static Period MakeHardPeriod(string id, DateTime now, int startOffsetMin, int endOffsetMin) => new()
29+
{
30+
Id = id,
31+
Name = "test",
32+
Enabled = true,
33+
Recurring = true,
34+
Days = new List<int> { (int)now.DayOfWeek },
35+
Date = Periods.TodayKey(now),
36+
StartTime = now.AddMinutes(startOffsetMin).ToString("HH:mm"),
37+
EndTime = now.AddMinutes(endOffsetMin).ToString("HH:mm"),
38+
HardMode = true,
39+
Apps = new List<string> { "app.exe" },
40+
Sites = new List<string> { "example.com" },
41+
};
42+
43+
[Fact]
44+
public void ResolveHardLockedPeriods_CurrentlyActivePeriod_ReturnsNoPhantom()
45+
{
46+
var enforcer = MakeEnforcer();
47+
var now = DateTime.Now;
48+
var data = new PeriodsData { Periods = new List<Period> { MakeHardPeriod("p1", now, -10, 10) } };
49+
50+
Assert.Empty(enforcer.ResolveHardLockedPeriods(data, now));
51+
}
52+
53+
[Fact]
54+
public void ResolveHardLockedPeriods_DisabledWhileLocked_StaysEnforced()
55+
{
56+
var enforcer = MakeEnforcer();
57+
var now = DateTime.Now;
58+
var p = MakeHardPeriod("p1", now, -10, 10);
59+
enforcer.ResolveHardLockedPeriods(new PeriodsData { Periods = new List<Period> { p } }, now);
60+
61+
var disabled = MakeHardPeriod("p1", now, -10, 10);
62+
disabled.Enabled = false; // "edition" directe : desactivee alors qu'il reste largement du temps
63+
var phantoms = enforcer.ResolveHardLockedPeriods(new PeriodsData { Periods = new List<Period> { disabled } }, now);
64+
Assert.Single(phantoms);
65+
Assert.Contains("app.exe", phantoms[0].Apps);
66+
Assert.Contains("example.com", phantoms[0].Sites);
67+
}
68+
69+
[Fact]
70+
public void ResolveHardLockedPeriods_DeletedWhileLocked_StaysEnforced()
71+
{
72+
var enforcer = MakeEnforcer();
73+
var now = DateTime.Now;
74+
var data = new PeriodsData { Periods = new List<Period> { MakeHardPeriod("p1", now, -10, 10) } };
75+
enforcer.ResolveHardLockedPeriods(data, now);
76+
77+
var phantoms = enforcer.ResolveHardLockedPeriods(new PeriodsData(), now);
78+
Assert.Single(phantoms);
79+
}
80+
81+
[Fact]
82+
public void ResolveHardLockedPeriods_EndTimeMovedEarlier_IsIgnored()
83+
{
84+
var enforcer = MakeEnforcer();
85+
var now = DateTime.Now;
86+
var p = MakeHardPeriod("p1", now, -10, 60); // se termine dans 1h
87+
enforcer.ResolveHardLockedPeriods(new PeriodsData { Periods = new List<Period> { p } }, now);
88+
89+
// "edition" directe de endTime pour faire croire que la plage vient de se terminer
90+
var edited = MakeHardPeriod("p1", now, -10, -1);
91+
var phantoms = enforcer.ResolveHardLockedPeriods(new PeriodsData { Periods = new List<Period> { edited } }, now);
92+
Assert.Single(phantoms); // l'ancienne echeance (dans ~1h) fait toujours foi
93+
}
94+
95+
[Fact]
96+
public void ResolveHardLockedPeriods_DeletedAfterWindowEnds_NoLongerEnforced()
97+
{
98+
var enforcer = MakeEnforcer();
99+
var now = DateTime.Now;
100+
var covering = MakeHardPeriod("p1", now, -10, 1); // se termine dans 1 min
101+
enforcer.ResolveHardLockedPeriods(new PeriodsData { Periods = new List<Period> { covering } }, now);
102+
103+
var later = now.AddMinutes(2); // après la fin légitime
104+
Assert.Empty(enforcer.ResolveHardLockedPeriods(new PeriodsData(), later));
105+
}
106+
107+
[Fact]
108+
public void ResolveHardLockedPeriods_NonHardModePeriod_NeverLocked()
109+
{
110+
var enforcer = MakeEnforcer();
111+
var now = DateTime.Now;
112+
var p = MakeHardPeriod("p1", now, -10, 10);
113+
p.HardMode = false;
114+
enforcer.ResolveHardLockedPeriods(new PeriodsData { Periods = new List<Period> { p } }, now);
115+
116+
Assert.Empty(enforcer.ResolveHardLockedPeriods(new PeriodsData(), now)); // rien a defendre, ce n'etait pas hard mode
117+
}
118+
}

0 commit comments

Comments
 (0)