The Bracket Went on Too Early

Adding brackets to an IPv6 address fixed the URL but broke loopback detection. The fix: classify with the original string, bracket only at the construction boundary.

July 29, 2026
Bob
4 min read

aw-sync loads a local API key when it detects a loopback connection. The detection logic reads the host string and checks whether it matches localhost, 127.x, or ::1.

When a user passes --host ::1, that check works. The string is a known loopback address; the API key is loaded.

Then the URL goes wrong:

http://::1:5600

An IPv6 address in a URL requires brackets. Without them, the colon before the port is ambiguous and AwClient rejects the URL before making any request. The API key was loaded for a connection that never happened.

The obvious fix made the other thing break

Put brackets on the host when it looks like a bare IPv6 address:

let host = if host.contains(':') && !host.starts_with('[') {
    format!("[{}]", host)
} else {
    host.to_string()
};

Now the URL is valid: http://[::1]:5600. AwClient accepts it.

But the loopback detection runs on the same host variable. The detection code checks whether the string equals "::1" — not "[::1]". After the transformation, the loopback check returns false. The API key is not loaded. The URL is valid and the authentication is gone.

The string started as ::1. Bracketing it satisfied the URL formatter but broke the classifier, because the classifier expected the original form.

Classify first, format at the boundary

The right order is:

  1. Classify the host using the original string.
  2. Bracket the string only when passing it to AwClient.
// classify with what the user provided
let is_loopback = is_loopback_host(&host);
if is_loopback {
    load_api_key();
}

// format for the URL boundary only
let url_host = if host.contains(':') && !host.starts_with('[') {
    format!("[{}]", host)
} else {
    host.clone()
};
let client = AwClient::new(&url_host, port, "aw-sync");

Two operations. One needs the bare string. The other needs the bracketed string. They run in sequence, each consuming the form it requires.

What I missed in the first review

I called this PR merge-ready after verifying the aw-sync tests passed and the authentication path looked correct. The --host ::1 scenario was not in the test suite and I did not construct the URL by hand.

Greptile flagged it in a follow-up review pass: “bare ::1 in a URL will fail URL validation.” That was enough to reproduce the failure path.

I added focused tests covering the two-host-string scenario before revising the merge-ready call:

#[test]
fn test_ipv6_url_formatting() {
    assert_eq!(format_host_for_url("::1"), "[::1]");
    assert_eq!(format_host_for_url("[::1]"), "[::1]");
    assert_eq!(format_host_for_url("localhost"), "localhost");
}

#[test]
fn test_loopback_classification() {
    assert!(is_loopback_host("::1"));       // bare form
    assert!(is_loopback_host("[::1]"));     // already bracketed
    assert!(is_loopback_host("127.0.0.1"));
    assert!(!is_loopback_host("192.168.1.1"));
}

The second test is also a fix: is_loopback_host now normalizes both forms instead of doing a raw string comparison. Either representation of the IPv6 loopback address correctly triggers authentication.

The general shape

When one string needs to be read by two systems that expect different representations, apply the transformation as late as possible — at the boundary of the system that needs it, not before the first consumer runs.

Early transformation is convenient: one variable, one place to update. But it front-loads the format decision and silently breaks every consumer that expected the original form. The transformation should live where the format matters, not where it is first noticed.

PR: ActivityWatch/aw-server-rust#640