profile: sweep only the profiles pointing at this host
The prefix was all that the sweep had to match,
so clearing the dev one took prod in the batch.
A digest of the host is a segment of its own:
a sweep takes its host's and leaves the rest alone.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/Rakefile b/Rakefile
index aa19bed..31ba46d 100644
--- a/Rakefile
+++ b/Rakefile
@@ -103,18 +103,30 @@ namespace :profile do
sh "open", "x-apple.systempreferences:com.apple.preferences.configurationprofiles"
end
- desc "Remove every installed pro-tacts configuration profile"
+ desc "Remove the installed configuration profiles pointing at PRO_TACTS_HOSTNAME"
task :remove do
require "pro_tacts/profile"
- identifiers = ProTacts::Profile.installed_identifiers(`profiles list`)
+ list = `profiles list`
+ hostname = ENV.fetch("PRO_TACTS_HOSTNAME")
+ identifiers = ProTacts::Profile.installed_identifiers(list, hostname:)
if identifiers.empty?
- puts "No pro-tacts profiles found; remove by hand in System Settings → Profiles if one lingers."
+ puts "No #{hostname} profiles found; remove by hand in System Settings → Profiles if one lingers."
else
identifiers.each do
sh "profiles", "remove", "-identifier", it
end
end
+
+ # A profile pointing at another host provisions another server's
+ # account — the deployment's, beside a dev one — and removing it would
+ # take that account down with it. A profile from before the host was
+ # part of the identifier says nothing about where it points, so it is
+ # reported rather than swept.
+ others = ProTacts::Profile.installed_identifiers(list, hostname: nil) - identifiers
+ unless others.empty?
+ puts "Left installed, from another host or an older identifier (remove by hand if stale):", *others
+ end
end
end
diff --git a/docs/apple-contacts.md b/docs/apple-contacts.md
index f46f797..a7b20e4 100644
--- a/docs/apple-contacts.md
+++ b/docs/apple-contacts.md
@@ -28,8 +28,8 @@ refusing a redirect) that never reach the server at all.
## The account setup path
The fastest path is a configuration profile: `rake profile:install` (with
-`PRO_TACTS_HOSTNAME` set) first removes any installed pro-tacts profiles,
-then renders `carddav.mobileconfig`, opens it, and
+`PRO_TACTS_HOSTNAME` set) first removes the installed profiles pointing at
+that host, then renders `carddav.mobileconfig`, opens it, and
opens System Settings on the Profiles pane (via the
`x-apple.systempreferences:` deep link) — the profiles CLI no longer
supports installs, so the profile lands there as pending until you click
@@ -43,7 +43,9 @@ fresh identifier and UUIDs, so each install provisions a cold account with
no cached sync state — that is deliberate for the experiment loop, and
`rake profile:install` sweeping the old profiles first is what keeps it
cold. `profile:remove` finds them by scanning `profiles list` output for
-the pro-tacts prefix. Apple's device-management
+the pro-tacts prefix and a digest of `PRO_TACTS_HOSTNAME` (which it needs
+set too), so a dev sweep leaves a profile pointing at the deployment alone
+and prints the identifiers it left behind. Apple's device-management
reference marks the CardDAV payload as allowing manual install, so no MDM is
involved.
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 4b589b4..b699566 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -1,3 +1,4 @@
+require "digest"
require "securerandom"
require "nokogiri"
@@ -15,7 +16,8 @@ module ProTacts
# identity follows the profile, so each install provisions a cold account
# with no cached sync state — exactly what the experiment loop needs. The
# cost is that reinstalling without removing first orphans the old account;
- # rake profile:remove sweeps every profile carrying our prefix.
+ # rake profile:remove sweeps every profile carrying our prefix and the
+ # host it is configured for.
#
# The served route keeps that rule rather than deriving a stable identifier
# from the account, which would make a reinstall a no-op: an identifier is
@@ -52,8 +54,8 @@ module ProTacts
# and dropping it is untested.
#: (hostname: String, username: String) -> String
def self.render(hostname:, username:)
- identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
name = account_name
+ identifier = "#{IDENTIFIER_PREFIX}.#{host_digest(hostname)}.#{unique_hex}"
builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |x|
x.doc.create_internal_subset(
@@ -114,16 +116,53 @@ module ProTacts
builder.to_xml
end
- # Picks our profile identifiers out of `profiles list` output so
- # profile:remove can sweep every pro-tacts profile, not just the latest.
- # Scans for the prefix anywhere in the output rather than assuming a
+ # The host as one identifier segment, so an installed profile says
+ # which server it provisions an account against. The host rather than
+ # the account name, which defaults to the same "pro-tacts" whoever
+ # rendered it, or the username, which is one login across both: two
+ # profiles pointing at the same host are the same account, and two
+ # pointing at different hosts never are.
+ #
+ # A digest rather than the host itself, which would read as a second
+ # dotted name inside a dotted identifier. Hiding nothing — the profile
+ # states the host a few keys down — so a short one is enough, and the
+ # sweep recomputes it from the host it is configured for. Downcased
+ # first, since a host differing only in case is the same server.
+ #: (String hostname) -> String
+ def self.host_digest(hostname)
+ # The message names the argument rather than PRO_TACTS_HOSTNAME: the
+ # rake tasks read that variable, /setup passes the request's host,
+ # and only the caller knows which it was.
+ if hostname.strip.empty?
+ raise ArgumentError, "hostname is #{hostname.inspect}, so the profile would name no server. " \
+ "Pass the host the app answers on, such as \"pro-tacts.example.ts.net\"."
+ end
+
+ Digest::SHA256.hexdigest(hostname.downcase)[0, 8] #: String
+ end
+
+ # Picks the identifiers of profiles provisioned against hostname out of
+ # `profiles list` output, so profile:remove sweeps every profile
+ # pointing at the server it is configured for and no others — a dev
+ # sweep leaves the profile the deployment installed alone. Pass
+ # hostname: nil for every pro-tacts profile whatever server it points
+ # at, including the ones installed before the host was part of the
+ # identifier.
+ #
+ # Scans for the identifier anywhere in the output rather than assuming a
# key-value layout, since the listing format has changed across macOS
# versions (key-value today, table under later releases).
- #: (String list_output) -> Array[String]
- def self.installed_identifiers(list_output)
+ #: (String list_output, hostname: String?) -> Array[String]
+ def self.installed_identifiers(list_output, hostname:)
+ tail = if hostname
+ /\.#{host_digest(hostname)}\.[\w-]+/
+ else
+ /[.-][\w.-]+/
+ end
+
# A pattern with no groups scans to whole matches, which is
# narrower than the signature of String#scan can say.
- list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq #: Array[String]
+ list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}#{tail}(?![\w.-])/).uniq #: Array[String]
end
#: () -> String
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
index df91453..004b2aa 100644
--- a/test/pro_tacts/test_profile.rb
+++ b/test/pro_tacts/test_profile.rb
@@ -77,32 +77,100 @@ class ProfileTest < Minitest::Test
assert_equal 2, uuids.uniq.size
end
+ # An identifier as a render writes one: the prefix, the host's digest,
+ # then the unique hex.
+ def identifier(hostname, hex = "20260818ab12")
+ "#{ProTacts::Profile::IDENTIFIER_PREFIX}.#{ProTacts::Profile.host_digest(hostname)}.#{hex}"
+ end
+
+ def payload_identifiers(xml)
+ xml.scan(%r{<key>PayloadIdentifier</key>\s*<string>([^<]+)</string>}).flatten
+ end
+
def test_installed_identifiers_picks_out_pro_tacts_profiles
list_output = <<~OUTPUT
_admin-Profiles-1
- identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12
+ identifier: #{identifier("example.ts.net")}
_admin-Profiles-2
identifier: com.example.unrelated
_admin-Profiles-3
+ identifier: #{identifier("example.ts.net", "20260818cd34")}
+ OUTPUT
+
+ assert_equal [identifier("example.ts.net"), identifier("example.ts.net", "20260818cd34")],
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
+ end
+
+ # The sweep a dev session runs has to leave the profile the deployment
+ # installed alone: same prefix, another server.
+ def test_installed_identifiers_leaves_profiles_pointing_at_another_host
+ list_output = <<~OUTPUT
+ identifier: #{identifier("example.ts.net")}
+ identifier: #{identifier("localhost")}
+ OUTPUT
+
+ assert_equal [identifier("localhost")],
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "localhost")
+ assert_equal [identifier("example.ts.net")],
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
+ end
+
+ # What the sweep reports as left behind, including the profiles
+ # installed before the host was part of the identifier.
+ def test_installed_identifiers_without_a_hostname_finds_every_pro_tacts_profile
+ list_output = <<~OUTPUT
+ identifier: #{identifier("example.ts.net")}
+ identifier: #{identifier("localhost")}
identifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34
+ identifier: com.example.unrelated
OUTPUT
assert_equal [
- "#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12",
+ identifier("example.ts.net"),
+ identifier("localhost"),
"#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818cd34",
- ], ProTacts::Profile.installed_identifiers(list_output)
+ ], ProTacts::Profile.installed_identifiers(list_output, hostname: nil)
+ end
+
+ # A sweep for the host a render points at finds the profile's own
+ # identifier and not the account payload's, which carries it as a
+ # prefix; a sweep for another host finds neither.
+ def test_a_render_is_swept_only_for_the_host_it_points_at
+ xml = render(hostname: "example.ts.net")
+ list_output = payload_identifiers(xml).map { " identifier: #{it}\n" }.join
+
+ assert_equal payload_identifiers(xml).reject { it.end_with?(".account") },
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
+ assert_empty ProTacts::Profile.installed_identifiers(list_output, hostname: "localhost")
+ end
+
+ # A host differing only in case is the same server, so one sweep takes
+ # profiles rendered under either spelling.
+ def test_installed_identifiers_reads_the_host_case_insensitively
+ list_output = " identifier: #{identifier("example.ts.net")}\n"
+
+ assert_equal [identifier("example.ts.net")],
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "Example.TS.net")
+ end
+
+ # A blank host names no server, and the profile it would render points
+ # nowhere, so the render is refused instead.
+ def test_a_blank_hostname_is_refused
+ error = assert_raises(ArgumentError) { render(hostname: " ") }
+
+ assert_includes error.message, "hostname"
end
def test_installed_identifiers_reads_attribute_format_output
list_output = <<~OUTPUT
- alpha[1] attribute: profileIdentifier: #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818155835720a9db
+ alpha[1] attribute: profileIdentifier: #{identifier("example.ts.net", "20260818155835720a9db")}
There are 1 user configuration profiles installed for 'alpha'
OUTPUT
- assert_equal ["#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818155835720a9db"],
- ProTacts::Profile.installed_identifiers(list_output)
+ assert_equal [identifier("example.ts.net", "20260818155835720a9db")],
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
end
def test_installed_identifiers_reads_table_format_output
@@ -110,24 +178,24 @@ class ProfileTest < Minitest::Test
Profiles:
identifier display name
------------------------------------ --------------
- #{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12 pro-tacts CardDAV
+ #{identifier("example.ts.net")} pro-tacts CardDAV
com.example.unrelated Work
OUTPUT
- assert_equal ["#{ProTacts::Profile::IDENTIFIER_PREFIX}-20260818ab12"],
- ProTacts::Profile.installed_identifiers(list_output)
+ assert_equal [identifier("example.ts.net")],
+ ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
end
def test_installed_identifiers_ignores_longer_identifiers_containing_the_prefix
- list_output = "com.example.#{ProTacts::Profile::IDENTIFIER_PREFIX}-fake\n"
+ list_output = "com.example.#{identifier("example.ts.net")}\n"
- assert_empty ProTacts::Profile.installed_identifiers(list_output)
+ assert_empty ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
end
def test_installed_identifiers_is_empty_without_ours
list_output = " identifier: com.example.unrelated\n"
- assert_empty ProTacts::Profile.installed_identifiers(list_output)
+ assert_empty ProTacts::Profile.installed_identifiers(list_output, hostname: "example.ts.net")
end
def test_escapes_xml_in_field_values