Skip to content

Commit 992b5de

Browse files
committed
net-logger: implement client side file logging logic
1 parent 0b3cd2b commit 992b5de

6 files changed

Lines changed: 379 additions & 18 deletions

File tree

‎BrowserGuard.Tests/MessageHandlerTests.cs‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ public void Dispose()
2424

2525
// The configuration is handed in so the test does not go through the
2626
// registry to find out where it lives.
27-
MessageHandler Handler(bool enabled = true) =>
28-
new(null, new NetLogFileConfig { Enabled = enabled, Directory = tempDir });
27+
MessageHandler Handler(bool enabled = true, bool localFile = true) =>
28+
new(null, new NetLoggerConfig
29+
{
30+
Enabled = enabled,
31+
LocalFile = new NetLogFileConfig { Enabled = localFile, Directory = tempDir },
32+
});
2933

3034
const string Entry = """{"operation":"browsing","url":"https://example.com/"}""";
3135

@@ -72,7 +76,18 @@ public void ReportsAnEntryItCannotWrite()
7276
[Fact]
7377
public void StaysSilentWhenLocalLoggingIsTurnedOff()
7478
{
75-
var handler = Handler(enabled: false);
79+
var handler = Handler(localFile: false);
80+
81+
Assert.Null(handler.Handle("L " + Entry));
82+
83+
Assert.False(File.Exists(LogPath));
84+
}
85+
86+
// NetLogger turns the whole feature off, whatever LocalFile says.
87+
[Fact]
88+
public void StaysSilentWhenTheWholeLoggerIsTurnedOff()
89+
{
90+
var handler = Handler(enabled: false, localFile: true);
7691

7792
Assert.Null(handler.Handle("L " + Entry));
7893

‎BrowserGuard/MessageHandler.cs‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,14 @@ internal class MessageHandler
2626

2727
// The logging configuration can be handed in so that it does not have to
2828
// be found through the registry.
29-
internal MessageHandler(Logger? logger = null, NetLogFileConfig? netLogConfig = null)
29+
internal MessageHandler(Logger? logger = null, NetLoggerConfig? netLoggerConfig = null)
3030
{
3131
this.logger = logger;
32-
if (netLogConfig is null)
32+
if (netLoggerConfig is null)
3333
{
3434
return;
3535
}
36-
netLog = Create(netLogConfig);
36+
netLog = Create(netLoggerConfig);
3737
netLogResolved = true;
3838
}
3939

@@ -91,18 +91,20 @@ internal MessageHandler(Logger? logger = null, NetLogFileConfig? netLogConfig =
9191
return netLog;
9292
}
9393
netLogResolved = true;
94-
netLog = Create(ConfigLoader.LoadConfig().NetLogger.LocalFile);
94+
netLog = Create(ConfigLoader.LoadConfig().NetLogger);
9595
return netLog;
9696
}
9797

98-
private NetLogWriter? Create(NetLogFileConfig config)
98+
// Both switches have to be on: NetLogger turns the whole feature off,
99+
// LocalFile only the copy kept on this machine.
100+
private NetLogWriter? Create(NetLoggerConfig config)
99101
{
100-
if (!config.Enabled)
102+
if (!config.Enabled || !config.LocalFile.Enabled)
101103
{
102104
logger?.Log("Command: log entry, but local logging is disabled");
103105
return null;
104106
}
105-
var writer = new NetLogWriter(config, logger);
107+
var writer = new NetLogWriter(config.LocalFile, logger);
106108
logger?.Log($"Command: log entry, writing to {writer.FilePath}");
107109
return writer;
108110
}

‎webextensions/edge/net-log-port.js‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use strict';
2+
3+
import { SERVER_NAME } from './constants.js';
4+
5+
// A log entry arrives for every request, and sendNativeMessage starts a host
6+
// process per message, so the entries go over a port instead: one process for
7+
// as long as it is connected.
8+
//
9+
// The port is opened on the first entry rather than at start up. The service
10+
// worker is unloaded when idle, which takes the port and the host process with
11+
// it; the next entry simply opens a new one.
12+
export const NetLogPort = {
13+
port: null,
14+
15+
connect() {
16+
if (this.port) return this.port;
17+
18+
const port = chrome.runtime.connectNative(SERVER_NAME);
19+
port.onDisconnect.addListener(() => {
20+
// Only forget it if it is still the one in use, so a disconnect arriving
21+
// late cannot discard a port opened after it.
22+
if (this.port === port) this.port = null;
23+
const error = chrome.runtime.lastError;
24+
if (error) console.error('Net log port disconnected', error.message);
25+
});
26+
// The host answers only when it could not write the entry.
27+
port.onMessage.addListener(response => {
28+
if (response?.Success === false) {
29+
console.error('Cannot write the log entry', response.Error);
30+
}
31+
});
32+
33+
this.port = port;
34+
return port;
35+
},
36+
37+
send(entry) {
38+
try {
39+
this.connect().postMessage({ message: 'L ' + JSON.stringify(entry) });
40+
return true;
41+
} catch (error) {
42+
// Opening the port fails when the host is not registered, and posting
43+
// fails on a port that has just gone away. Either way the next entry
44+
// starts over rather than reusing something broken.
45+
this.port = null;
46+
console.error('Cannot reach the native host', error?.message);
47+
return false;
48+
}
49+
},
50+
}

‎webextensions/edge/net-logger.js‎

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
import { loadConfig } from './config-loader.js';
4+
import { NetLogPort } from './net-log-port.js';
45

56
export const NetLogger = {
67
init() {
@@ -15,11 +16,34 @@ export const NetLogger = {
1516
`${p(date.getHours())}:${p(date.getMinutes())}:${p(date.getSeconds())}`;
1617
},
1718

19+
// Where an entry should go, or null when it should not be recorded at all.
20+
// The two destinations are independent, so either one on its own is enough.
21+
_targets(netLogger) {
22+
if (!netLogger?.Enabled) return null;
23+
if (!netLogger.MachineName || !netLogger.UserName) return null;
24+
const endpoint = Boolean(netLogger.Endpoint);
25+
const localFile = Boolean(netLogger.LocalFile?.Enabled);
26+
if (!endpoint && !localFile) return null;
27+
return { endpoint, localFile };
28+
},
29+
1830
async _getConfig() {
1931
const config = await loadConfig();
2032
const netLogger = config?.NetLogger;
21-
if (!netLogger?.Endpoint || !netLogger?.MachineName || !netLogger?.UserName) return null;
22-
return netLogger;
33+
return this._targets(netLogger) ? netLogger : null;
34+
},
35+
36+
async _send(config, payload) {
37+
const targets = this._targets(config);
38+
if (!targets) return;
39+
if (targets.endpoint) await this.sendToEndpoint(config.Endpoint, payload);
40+
// Not awaited: the port is fire and forget, and the host answers only on
41+
// a failure.
42+
if (targets.localFile) this.sendToHost(payload);
43+
},
44+
45+
sendToHost(payload) {
46+
NetLogPort.send(payload);
2347
},
2448

2549
_buildPayload(config, operation, name, url, timestamp) {
@@ -64,7 +88,7 @@ export const NetLogger = {
6488
}
6589
for (const part of details.requestBody.raw) {
6690
if (part.file) {
67-
await this.sendToEndpoint(config.Endpoint,
91+
await this._send(config,
6892
this._buildPayload(config, 'upload',
6993
part.file.split(/[/\\]/).pop() || '',
7094
uploadUrl,
@@ -74,7 +98,7 @@ export const NetLogger = {
7498
}
7599

76100
if (config.UrlAccess && (url.protocol === 'http:' || url.protocol === 'https:')) {
77-
await this.sendToEndpoint(config.Endpoint,
101+
await this._send(config,
78102
this._buildPayload(config, 'urlaccess',
79103
url.hostname,
80104
url.href,
@@ -98,7 +122,7 @@ export const NetLogger = {
98122

99123
if (config.Browsing) {
100124
const tab = await chrome.tabs.get(details.tabId);
101-
await this.sendToEndpoint(config.Endpoint,
125+
await this._send(config,
102126
this._buildPayload(config, 'browsing',
103127
tab.title || url.hostname,
104128
url.href,
@@ -111,7 +135,7 @@ export const NetLogger = {
111135
if (!config) return;
112136

113137
if (config.Print) {
114-
await this.sendToEndpoint(config.Endpoint,
138+
await this._send(config,
115139
this._buildPayload(config, 'print',
116140
msg.title || '',
117141
msg.url,
@@ -124,7 +148,7 @@ export const NetLogger = {
124148
if (!config) return;
125149

126150
if (config.Auth) {
127-
await this.sendToEndpoint(config.Endpoint,
151+
await this._send(config,
128152
this._buildPayload(config, 'auth',
129153
details.scheme || '',
130154
details.url,
@@ -142,7 +166,7 @@ export const NetLogger = {
142166
if (!item) return;
143167

144168
if (config.Download) {
145-
await this.sendToEndpoint(config.Endpoint,
169+
await this._send(config,
146170
this._buildPayload(config, 'download',
147171
item.filename || '',
148172
item.url || '',
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
'use strict';
2+
3+
import { describe, it, beforeEach } from 'node:test';
4+
import assert from 'node:assert/strict';
5+
6+
// A stand in for the port chrome.runtime.connectNative hands back, recording
7+
// what was posted and letting a test fire the listeners the real one would.
8+
const calls = { connected: [], posted: [], errors: [] };
9+
let failToConnect = null;
10+
let failToPost = null;
11+
12+
function makePort() {
13+
const port = {
14+
posted: [],
15+
disconnectListeners: [],
16+
messageListeners: [],
17+
onDisconnect: { addListener: fn => port.disconnectListeners.push(fn) },
18+
onMessage: { addListener: fn => port.messageListeners.push(fn) },
19+
postMessage: message => {
20+
if (failToPost) throw new Error(failToPost);
21+
port.posted.push(message);
22+
calls.posted.push(message);
23+
},
24+
disconnect: () => port.disconnectListeners.forEach(fn => fn()),
25+
reply: message => port.messageListeners.forEach(fn => fn(message)),
26+
};
27+
return port;
28+
}
29+
30+
globalThis.chrome = {
31+
runtime: {
32+
lastError: undefined,
33+
connectNative: name => {
34+
calls.connected.push(name);
35+
if (failToConnect) throw new Error(failToConnect);
36+
return makePort();
37+
},
38+
},
39+
};
40+
41+
console.error = (...args) => calls.errors.push(args.join(' '));
42+
43+
const { NetLogPort } = await import('../edge/net-log-port.js');
44+
45+
const ENTRY = { operation: 'browsing', url: 'https://example.com/' };
46+
47+
beforeEach(() => {
48+
calls.connected = [];
49+
calls.posted = [];
50+
calls.errors = [];
51+
failToConnect = null;
52+
failToPost = null;
53+
chrome.runtime.lastError = undefined;
54+
NetLogPort.port = null;
55+
});
56+
57+
describe('send', () => {
58+
it('carries the entry as the L command', () => {
59+
assert.equal(NetLogPort.send(ENTRY), true);
60+
61+
assert.deepEqual(calls.posted, [{ message: 'L ' + JSON.stringify(ENTRY) }]);
62+
});
63+
64+
it('names the native host it connects to', () => {
65+
NetLogPort.send(ENTRY);
66+
67+
assert.deepEqual(calls.connected, ['com.clear_code.browser_guard']);
68+
});
69+
70+
// Opening a process per entry is the thing the port exists to avoid.
71+
it('opens the port once and keeps using it', () => {
72+
NetLogPort.send(ENTRY);
73+
NetLogPort.send(ENTRY);
74+
NetLogPort.send(ENTRY);
75+
76+
assert.equal(calls.connected.length, 1);
77+
assert.equal(calls.posted.length, 3);
78+
});
79+
80+
it('opens a new port after the host has gone away', () => {
81+
NetLogPort.send(ENTRY);
82+
NetLogPort.port.disconnect();
83+
84+
NetLogPort.send(ENTRY);
85+
86+
assert.equal(calls.connected.length, 2);
87+
assert.equal(calls.posted.length, 2);
88+
});
89+
90+
it('reports that it could not open the port', () => {
91+
failToConnect = 'host not found';
92+
93+
assert.equal(NetLogPort.send(ENTRY), false);
94+
95+
assert.equal(NetLogPort.port, null);
96+
assert.match(calls.errors.join('\n'), /host not found/);
97+
});
98+
99+
// A port can go away between the last entry and this one.
100+
it('starts over when posting fails', () => {
101+
NetLogPort.send(ENTRY);
102+
failToPost = 'port closed';
103+
104+
assert.equal(NetLogPort.send(ENTRY), false);
105+
106+
assert.equal(NetLogPort.port, null);
107+
failToPost = null;
108+
assert.equal(NetLogPort.send(ENTRY), true);
109+
assert.equal(calls.connected.length, 2);
110+
});
111+
});
112+
113+
describe('the port itself', () => {
114+
it('reports an entry the host could not write', () => {
115+
NetLogPort.send(ENTRY);
116+
117+
NetLogPort.port.reply({ Success: false, Error: 'entry is not valid JSON' });
118+
119+
assert.match(calls.errors.join('\n'), /entry is not valid JSON/);
120+
});
121+
122+
// The host stays quiet when it wrote the entry, so nothing should be logged.
123+
it('says nothing about an entry that was written', () => {
124+
NetLogPort.send(ENTRY);
125+
126+
NetLogPort.port.reply(undefined);
127+
128+
assert.deepEqual(calls.errors, []);
129+
});
130+
131+
it('reports why the port was disconnected', () => {
132+
NetLogPort.send(ENTRY);
133+
const port = NetLogPort.port;
134+
chrome.runtime.lastError = { message: 'native host has exited' };
135+
136+
port.disconnect();
137+
138+
assert.match(calls.errors.join('\n'), /native host has exited/);
139+
});
140+
141+
// A disconnect for the previous port must not discard the current one.
142+
it('keeps a port opened after an earlier one was closed', () => {
143+
NetLogPort.send(ENTRY);
144+
const first = NetLogPort.port;
145+
first.disconnect();
146+
NetLogPort.send(ENTRY);
147+
const second = NetLogPort.port;
148+
149+
first.disconnect();
150+
151+
assert.equal(NetLogPort.port, second);
152+
});
153+
});

0 commit comments

Comments
 (0)