Skip to content

Commit 0b3cd2b

Browse files
committed
Add NetLogWriter
1 parent 549b89c commit 0b3cd2b

8 files changed

Lines changed: 641 additions & 49 deletions

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using System.IO;
3+
using System.Text.Json;
4+
using BrowserGuard;
5+
using Xunit;
6+
7+
namespace BrowserGuard.Tests
8+
{
9+
public class MessageHandlerTests : IDisposable
10+
{
11+
readonly string tempDir;
12+
13+
public MessageHandlerTests()
14+
{
15+
tempDir = Path.Combine(Path.GetTempPath(), "browserguard-handler-" + Guid.NewGuid().ToString("N"));
16+
}
17+
18+
public void Dispose()
19+
{
20+
try { Directory.Delete(tempDir, true); } catch { }
21+
}
22+
23+
string LogPath => Path.Combine(tempDir, "netlog.jsonl");
24+
25+
// The configuration is handed in so the test does not go through the
26+
// registry to find out where it lives.
27+
MessageHandler Handler(bool enabled = true) =>
28+
new(null, new NetLogFileConfig { Enabled = enabled, Directory = tempDir });
29+
30+
const string Entry = """{"operation":"browsing","url":"https://example.com/"}""";
31+
32+
// An entry arrives for every request, so acknowledging each one would
33+
// double the traffic over the port.
34+
[Fact]
35+
public void AnswersNothingWhenAnEntryIsWritten()
36+
{
37+
var handler = Handler();
38+
39+
Assert.Null(handler.Handle("L " + Entry));
40+
41+
var line = Assert.Single(File.ReadAllLines(LogPath));
42+
Assert.Equal("browsing", JsonDocument.Parse(line).RootElement
43+
.GetProperty("operation").GetString());
44+
}
45+
46+
[Fact]
47+
public void WritesEveryEntryItIsGiven()
48+
{
49+
var handler = Handler();
50+
51+
handler.Handle("L " + Entry);
52+
handler.Handle("L " + Entry);
53+
handler.Handle("L " + Entry);
54+
55+
Assert.Equal(3, File.ReadAllLines(LogPath).Length);
56+
}
57+
58+
[Fact]
59+
public void ReportsAnEntryItCannotWrite()
60+
{
61+
var handler = Handler();
62+
63+
var response = handler.Handle("L this is not json");
64+
65+
Assert.NotNull(response);
66+
Assert.False(response.Success);
67+
Assert.Contains("not valid JSON", response.Error);
68+
}
69+
70+
// Nothing is written and nothing is said, because the browser knows from
71+
// the same configuration that it should not be sending these.
72+
[Fact]
73+
public void StaysSilentWhenLocalLoggingIsTurnedOff()
74+
{
75+
var handler = Handler(enabled: false);
76+
77+
Assert.Null(handler.Handle("L " + Entry));
78+
79+
Assert.False(File.Exists(LogPath));
80+
}
81+
82+
[Fact]
83+
public void LeavesTheOtherCommandsAlone()
84+
{
85+
var handler = Handler();
86+
87+
// Anything unrecognised still gets a plain acknowledgement.
88+
var response = handler.Handle("X something");
89+
90+
Assert.NotNull(response);
91+
Assert.True(response.Success);
92+
}
93+
}
94+
}
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
using System;
2+
using System.IO;
3+
using System.Linq;
4+
using System.Text.Json;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
using BrowserGuard;
8+
using Xunit;
9+
10+
namespace BrowserGuard.Tests
11+
{
12+
public class NetLogWriterTests : IDisposable
13+
{
14+
readonly string tempDir;
15+
16+
public NetLogWriterTests()
17+
{
18+
tempDir = Path.Combine(Path.GetTempPath(), "browserguard-netlog-" + Guid.NewGuid().ToString("N"));
19+
}
20+
21+
public void Dispose()
22+
{
23+
try { Directory.Delete(tempDir, true); } catch { }
24+
}
25+
26+
NetLogWriter Writer(int maxSizeMB = 10, int maxGenerations = 10) =>
27+
new(new NetLogFileConfig
28+
{
29+
Enabled = true,
30+
Directory = tempDir,
31+
MaxSizeMB = maxSizeMB,
32+
MaxGenerations = maxGenerations,
33+
});
34+
35+
string LogPath => Path.Combine(tempDir, "netlog.jsonl");
36+
37+
string GenerationPath(int generation) =>
38+
Path.Combine(tempDir, $"netlog_{generation}.jsonl");
39+
40+
static string Entry(string operation) =>
41+
$$"""{"operation":"{{operation}}","url":"https://example.com/"}""";
42+
43+
[Fact]
44+
public void WritesOneLinePerEntry()
45+
{
46+
var writer = Writer();
47+
48+
Assert.Null(writer.Write(Entry("browsing")));
49+
Assert.Null(writer.Write(Entry("download")));
50+
51+
var lines = File.ReadAllLines(LogPath);
52+
Assert.Equal(2, lines.Length);
53+
Assert.Equal("browsing", JsonDocument.Parse(lines[0]).RootElement
54+
.GetProperty("operation").GetString());
55+
Assert.Equal("download", JsonDocument.Parse(lines[1]).RootElement
56+
.GetProperty("operation").GetString());
57+
}
58+
59+
[Fact]
60+
public void CreatesTheDirectory()
61+
{
62+
var writer = new NetLogWriter(new NetLogFileConfig
63+
{
64+
Enabled = true,
65+
Directory = Path.Combine(tempDir, "nested"),
66+
});
67+
68+
Assert.Null(writer.Write(Entry("browsing")));
69+
70+
Assert.True(File.Exists(Path.Combine(tempDir, "nested", "netlog.jsonl")));
71+
}
72+
73+
// A sender that pretty printed its entry must not break the one line per
74+
// entry rule the file depends on.
75+
[Fact]
76+
public void PutsAPrettyPrintedEntryOnASingleLine()
77+
{
78+
var writer = Writer();
79+
80+
writer.Write("{\n \"operation\": \"browsing\",\n \"url\": \"https://example.com/\"\n}");
81+
82+
Assert.Single(File.ReadAllLines(LogPath));
83+
}
84+
85+
[Fact]
86+
public void LeavesJapaneseLegible()
87+
{
88+
var writer = Writer();
89+
90+
writer.Write("""{"operation":"browsing","name":"ページ名"}""");
91+
92+
Assert.Contains("ページ名", File.ReadAllText(LogPath));
93+
}
94+
95+
[Fact]
96+
public void RefusesSomethingThatIsNotJson()
97+
{
98+
var writer = Writer();
99+
100+
var failure = writer.Write("not json at all");
101+
102+
Assert.Contains("not valid JSON", failure);
103+
Assert.False(File.Exists(LogPath));
104+
}
105+
106+
// A bare string or number is valid JSON but not a log entry.
107+
[Fact]
108+
public void RefusesJsonThatIsNotAnObject()
109+
{
110+
var writer = Writer();
111+
112+
Assert.Contains("not a JSON object", writer.Write("\"browsing\""));
113+
Assert.Contains("not a JSON object", writer.Write("[1,2,3]"));
114+
Assert.False(File.Exists(LogPath));
115+
}
116+
117+
[Fact]
118+
public void KeepsTheEarlierEntriesWhenItRotates()
119+
{
120+
var writer = Writer(maxSizeMB: 1);
121+
var padding = new string('a', 200_000);
122+
123+
// Six entries of 200KB take the file past the one megabyte limit.
124+
for (var i = 0; i < 6; i++)
125+
{
126+
writer.Write($$"""{"operation":"browsing","name":"{{padding}}"}""");
127+
}
128+
writer.Write(Entry("after"));
129+
130+
Assert.True(File.Exists(GenerationPath(1)), "the log should have been moved aside");
131+
var kept = File.ReadAllLines(GenerationPath(1));
132+
Assert.Equal(6, kept.Length);
133+
Assert.Equal("after", JsonDocument.Parse(File.ReadAllLines(LogPath)[0]).RootElement
134+
.GetProperty("operation").GetString());
135+
}
136+
137+
[Fact]
138+
public void KeepsTheGenerationsBesideTheLog()
139+
{
140+
var writer = Writer(maxSizeMB: 1, maxGenerations: 2);
141+
var padding = new string('a', 600_000);
142+
143+
for (var i = 0; i < 8; i++)
144+
{
145+
writer.Write($$"""{"operation":"browsing","name":"{{padding}}"}""");
146+
}
147+
148+
var kept = Directory.GetFiles(tempDir)
149+
.Select(Path.GetFileName)
150+
.OrderBy(name => name, StringComparer.Ordinal)
151+
.ToArray();
152+
Assert.Equal(
153+
new[] { "netlog.jsonl", "netlog_1.jsonl", "netlog_2.jsonl" },
154+
kept);
155+
}
156+
157+
[Fact]
158+
public void DiscardsTheLogWhenNoGenerationIsKept()
159+
{
160+
var writer = Writer(maxSizeMB: 1, maxGenerations: 0);
161+
var padding = new string('a', 600_000);
162+
163+
for (var i = 0; i < 4; i++)
164+
{
165+
writer.Write($$"""{"operation":"browsing","name":"{{padding}}"}""");
166+
}
167+
168+
Assert.Equal(new[] { "netlog.jsonl" },
169+
Directory.GetFiles(tempDir).Select(Path.GetFileName).ToArray());
170+
}
171+
172+
// The browser can be killed at any moment, so nothing may sit in a buffer.
173+
[Fact]
174+
public void LeavesNothingUnwrittenBetweenEntries()
175+
{
176+
var writer = Writer();
177+
178+
writer.Write(Entry("browsing"));
179+
180+
Assert.Single(File.ReadAllLines(LogPath));
181+
}
182+
183+
// The log is collected while the browser is running. Holding the file
184+
// open would block anything that opens it the ordinary way, which is
185+
// what File.ReadAllText, copy and Notepad all do.
186+
[Fact]
187+
public void LeavesTheLogReadableByAnOrdinaryReader()
188+
{
189+
var writer = Writer();
190+
writer.Write(Entry("browsing"));
191+
192+
using var reader = new FileStream(
193+
LogPath, FileMode.Open, FileAccess.Read, FileShare.Read);
194+
195+
Assert.NotEqual(0, reader.Length);
196+
}
197+
198+
// The reader locks writers out for as long as it holds the file, so an
199+
// entry that arrives during a collection has to wait rather than be lost.
200+
[Fact]
201+
public void WaitsForAReaderRatherThanLosingTheEntry()
202+
{
203+
var writer = Writer();
204+
writer.Write(Entry("browsing"));
205+
206+
using (var held = new FileStream(
207+
LogPath, FileMode.Open, FileAccess.Read, FileShare.Read))
208+
{
209+
var releasing = Task.Run(() =>
210+
{
211+
Thread.Sleep(150);
212+
held.Dispose();
213+
});
214+
215+
Assert.Null(writer.Write(Entry("download")));
216+
releasing.Wait();
217+
}
218+
219+
Assert.Equal(2, File.ReadAllLines(LogPath).Length);
220+
}
221+
222+
[Fact]
223+
public void ReportsAFailureRatherThanThrowing()
224+
{
225+
// A file where the directory has to go, so it can never be created.
226+
Directory.CreateDirectory(tempDir);
227+
var blocked = Path.Combine(tempDir, "blocked");
228+
File.WriteAllText(blocked, "x");
229+
var writer = new NetLogWriter(new NetLogFileConfig
230+
{
231+
Enabled = true,
232+
Directory = blocked,
233+
});
234+
235+
Assert.NotNull(writer.Write(Entry("browsing")));
236+
}
237+
238+
[Fact]
239+
public void FallsBackToProgramDataWhenNoDirectoryIsGiven()
240+
{
241+
var writer = new NetLogWriter(new NetLogFileConfig { Enabled = true });
242+
243+
var expected = Path.Combine(
244+
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
245+
"BrowserGuard", "netlog", "netlog.jsonl");
246+
Assert.Equal(expected, writer.FilePath);
247+
}
248+
}
249+
}

BrowserGuard/BrowserGuard.sample.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@
77
"Upload": false,
88
"Download": false,
99
"Auth": false,
10-
"Print": false
10+
"Print": false,
11+
"LocalFile": {
12+
"Enabled": true,
13+
"Directory": "C:\\ProgramData\\BrowserGuard\\netlog",
14+
"MaxSizeMB": 10,
15+
"MaxGenerations": 10
16+
}
1117
},
1218
"UploadGuard": {
1319
"Enabled": false,

BrowserGuard/ConfigLoader.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ internal class NetLoggerConfig
3636
public bool Print { get; set; }
3737
public string UserName { get; set; } = Environment.UserName;
3838
public string MachineName { get; set; } = Environment.MachineName;
39+
public NetLogFileConfig LocalFile { get; set; } = new();
40+
}
41+
42+
// Keeping the log on this machine, as one JSON object per line.
43+
internal class NetLogFileConfig
44+
{
45+
public bool Enabled { get; set; }
46+
// Empty means %ProgramData%\BrowserGuard\netlog.
47+
public string Directory { get; set; } = "";
48+
public int MaxSizeMB { get; set; } = 10;
49+
// How many rotated files are kept. 0 discards the log instead.
50+
public int MaxGenerations { get; set; } = 10;
3951
}
4052

4153
// Controls which local files may be uploaded.

0 commit comments

Comments
 (0)