Fix legacy configuration migration to XDG directories (#317)

* Handle legacy config migration for XDG assets

* Ensure legacy key migration precedes identity load

* Apply rufo formatting to identity module
This commit is contained in:
l5y
2025-10-13 14:02:17 +02:00
committed by GitHub
parent 9c73fceea7
commit ea9c633eff
8 changed files with 249 additions and 7 deletions
+4
View File
@@ -1,5 +1,9 @@
# CHANGELOG
## Unreleased
* Preserve legacy configuration assets when migrating to XDG directories.
## v0.5.0
* Add JavaScript configuration tests and coverage workflow
+14
View File
@@ -67,6 +67,20 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
* Configure `INSTANCE_DOMAIN` with the public URL of your deployment so vanity
links and generated metadata resolve correctly.
### Configuration storage
PotatoMesh stores its runtime assets using the XDG base directory specification.
During startup the web application migrates existing configuration from
`web/.config` and `web/config` into the resolved `XDG_CONFIG_HOME` directory.
This preserves previously generated instance key material and
`/.well-known/potato-mesh` documents so upgrades do not create new credentials
unnecessarily. When XDG directories are not provided the application falls back
to the repository root.
The migrated key is written to `<XDG_CONFIG_HOME>/potato-mesh/keyfile` and the
well-known document is staged in
`<XDG_CONFIG_HOME>/potato-mesh/well-known/potato-mesh`.
The web app can be configured with environment variables (defaults shown):
* `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo")
+27 -5
View File
@@ -25,6 +25,7 @@ module PotatoMesh
def perform_initial_filesystem_setup!
migrate_legacy_database!
migrate_legacy_keyfile!
migrate_legacy_well_known_assets!
end
private
@@ -47,12 +48,33 @@ module PotatoMesh
#
# @return [void]
def migrate_legacy_keyfile!
migrate_legacy_file(
PotatoMesh::Config.legacy_keyfile_path,
PotatoMesh::Config.keyfile_path,
chmod: 0o600,
context: "filesystem.keys",
PotatoMesh::Config.legacy_keyfile_candidates.each do |candidate|
migrate_legacy_file(
candidate,
PotatoMesh::Config.keyfile_path,
chmod: 0o600,
context: "filesystem.keys",
)
end
end
# Copy the legacy well-known document into the configured XDG directory.
#
# @return [void]
def migrate_legacy_well_known_assets!
destination = File.join(
PotatoMesh::Config.well_known_storage_root,
File.basename(PotatoMesh::Config.well_known_relative_path),
)
PotatoMesh::Config.legacy_well_known_candidates.each do |candidate|
migrate_legacy_file(
candidate,
destination,
chmod: 0o644,
context: "filesystem.well_known",
)
end
end
# Migrate a legacy file if it exists and the destination has not been created yet.
@@ -47,6 +47,7 @@ module PotatoMesh
# @return [Array<OpenSSL::PKey::RSA, Boolean>] tuple of key and generation flag.
def load_or_generate_instance_private_key
keyfile_path = PotatoMesh::Config.keyfile_path
migrate_legacy_keyfile_for_identity!(keyfile_path)
FileUtils.mkdir_p(File.dirname(keyfile_path))
if File.exist?(keyfile_path)
contents = File.binread(keyfile_path)
@@ -72,6 +73,46 @@ module PotatoMesh
[key, true]
end
# Migrate an existing legacy keyfile into the configured destination.
#
# @param destination_path [String] absolute path where the keyfile should reside.
# @return [void]
def migrate_legacy_keyfile_for_identity!(destination_path)
return if File.exist?(destination_path)
PotatoMesh::Config.legacy_keyfile_candidates.each do |candidate|
next unless File.exist?(candidate)
next if candidate == destination_path
begin
FileUtils.mkdir_p(File.dirname(destination_path))
FileUtils.cp(candidate, destination_path)
File.chmod(0o600, destination_path)
debug_log(
"Migrated legacy keyfile to XDG directory",
context: "identity.keys",
source: candidate,
destination: destination_path,
)
rescue SystemCallError => e
warn_log(
"Failed to migrate legacy keyfile",
context: "identity.keys",
source: candidate,
destination: destination_path,
error_class: e.class.name,
error_message: e.message,
)
next
end
break
end
end
private :migrate_legacy_keyfile_for_identity!
# Return the directory used to store well-known documents.
#
# @return [String] absolute path to the staging directory.
+30 -1
View File
@@ -215,7 +215,19 @@ module PotatoMesh
#
# @return [String] absolute filesystem path to the legacy keyfile.
def legacy_keyfile_path
File.join(legacy_config_directory, "keyfile")
legacy_keyfile_candidates.find { |path| File.exist?(path) } || legacy_keyfile_candidates.first
end
# Enumerate known legacy keyfile locations for migration.
#
# @return [Array<String>] ordered list of absolute legacy keyfile paths.
def legacy_keyfile_candidates
[
File.join(web_root, ".config", "keyfile"),
File.join(web_root, ".config", "potato-mesh", "keyfile"),
File.join(web_root, "config", "keyfile"),
File.join(web_root, "config", "potato-mesh", "keyfile"),
].map { |path| File.expand_path(path) }.uniq
end
# Legacy location for well known assets within the public folder.
@@ -225,6 +237,23 @@ module PotatoMesh
File.join(web_root, "public", well_known_relative_path)
end
# Enumerate known legacy well-known document locations for migration.
#
# @return [Array<String>] ordered list of absolute legacy well-known document paths.
def legacy_well_known_candidates
filename = File.basename(well_known_relative_path)
[
File.join(web_root, ".config", "well-known", filename),
File.join(web_root, ".config", ".well-known", filename),
File.join(web_root, ".config", "potato-mesh", "well-known", filename),
File.join(web_root, ".config", "potato-mesh", ".well-known", filename),
File.join(web_root, "config", "well-known", filename),
File.join(web_root, "config", ".well-known", filename),
File.join(web_root, "config", "potato-mesh", "well-known", filename),
File.join(web_root, "config", "potato-mesh", ".well-known", filename),
].map { |path| File.expand_path(path) }.uniq
end
# Interval used to refresh well known documents from disk.
#
# @return [Integer] refresh duration in seconds.
+31
View File
@@ -90,6 +90,21 @@ RSpec.describe PotatoMesh::Config do
File.join(described_class.web_root, ".config", "keyfile"),
)
end
it "prefers repository config keyfiles when present" do
Dir.mktmpdir do |dir|
web_root = File.join(dir, "web")
legacy_key = File.join(web_root, "config", "potato-mesh", "keyfile")
FileUtils.mkdir_p(File.dirname(legacy_key))
File.write(legacy_key, "legacy")
allow(described_class).to receive(:web_root).and_return(web_root)
expect(described_class.legacy_keyfile_path).to eq(legacy_key)
end
ensure
allow(described_class).to receive(:web_root).and_call_original
end
end
describe ".legacy_db_path" do
@@ -100,6 +115,22 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".legacy_well_known_candidates" do
it "includes repository config directories" do
Dir.mktmpdir do |dir|
web_root = File.join(dir, "web")
allow(described_class).to receive(:web_root).and_return(web_root)
candidates = described_class.legacy_well_known_candidates
expect(candidates).to include(
File.join(web_root, "config", "potato-mesh", "well-known", "potato-mesh"),
)
end
ensure
allow(described_class).to receive(:web_root).and_call_original
end
end
describe ".federation_announcement_interval" do
it "returns eight hours in seconds" do
expect(described_class.federation_announcement_interval).to eq(8 * 60 * 60)
+35 -1
View File
@@ -67,9 +67,9 @@ RSpec.describe PotatoMesh::App::Filesystem do
legacy_db_path: legacy_db,
db_path: new_db,
default_db_path: new_db,
legacy_keyfile_path: legacy_key,
keyfile_path: new_key,
)
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_return([legacy_key])
harness_class.perform_initial_filesystem_setup!
@@ -84,6 +84,40 @@ RSpec.describe PotatoMesh::App::Filesystem do
end
end
it "migrates repository configuration assets from web/config" do
Dir.mktmpdir do |dir|
web_root = File.join(dir, "web")
legacy_key = File.join(web_root, "config", "potato-mesh", "keyfile")
legacy_well_known = File.join(web_root, "config", "potato-mesh", "well-known", "potato-mesh")
destination_root = File.join(dir, "xdg-config", "potato-mesh")
new_key = File.join(destination_root, "keyfile")
new_well_known = File.join(destination_root, "well-known", "potato-mesh")
FileUtils.mkdir_p(File.dirname(legacy_key))
File.write(legacy_key, "legacy-key")
FileUtils.mkdir_p(File.dirname(legacy_well_known))
File.write(legacy_well_known, "{\"legacy\":true}")
allow(PotatoMesh::Config).to receive(:web_root).and_return(web_root)
allow(PotatoMesh::Config).to receive(:keyfile_path).and_return(new_key)
allow(PotatoMesh::Config).to receive(:well_known_storage_root).and_return(File.dirname(new_well_known))
allow(PotatoMesh::Config).to receive(:well_known_relative_path).and_return(".well-known/potato-mesh")
allow(PotatoMesh::Config).to receive(:legacy_db_path).and_return(File.join(dir, "legacy", "mesh.db"))
allow(PotatoMesh::Config).to receive(:db_path).and_return(File.join(dir, "data", "potato-mesh", "mesh.db"))
allow(PotatoMesh::Config).to receive(:default_db_path).and_return(File.join(dir, "data", "potato-mesh", "mesh.db"))
harness_class.perform_initial_filesystem_setup!
expect(File).to exist(new_key)
expect(File.read(new_key)).to eq("legacy-key")
expect(File.stat(new_key).mode & 0o777).to eq(0o600)
expect(File).to exist(new_well_known)
expect(File.read(new_well_known)).to eq("{\"legacy\":true}")
expect(File.stat(new_well_known).mode & 0o777).to eq(0o644)
expect(harness_class.debug_entries.map { |entry| entry[:context] }).to include("filesystem.keys", "filesystem.well_known")
end
end
it "skips database migration when using a custom destination" do
Dir.mktmpdir do |dir|
legacy_db = File.join(dir, "legacy", "mesh.db")
+67
View File
@@ -0,0 +1,67 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require "openssl"
RSpec.describe PotatoMesh::App::Identity do
let(:harness_class) do
Class.new do
extend PotatoMesh::App::Identity
end
end
describe ".load_or_generate_instance_private_key" do
it "loads an existing key without generating a new one" do
Dir.mktmpdir do |dir|
key_path = File.join(dir, "config", "potato-mesh", "keyfile")
FileUtils.mkdir_p(File.dirname(key_path))
key = OpenSSL::PKey::RSA.new(2048)
File.write(key_path, key.export)
allow(PotatoMesh::Config).to receive(:keyfile_path).and_return(key_path)
loaded_key, generated = harness_class.load_or_generate_instance_private_key
expect(generated).to be(false)
expect(loaded_key.to_pem).to eq(key.to_pem)
end
ensure
allow(PotatoMesh::Config).to receive(:keyfile_path).and_call_original
end
it "migrates a legacy keyfile before loading" do
Dir.mktmpdir do |dir|
key_path = File.join(dir, "config", "potato-mesh", "keyfile")
legacy_key_path = File.join(dir, "legacy", "keyfile")
FileUtils.mkdir_p(File.dirname(legacy_key_path))
key = OpenSSL::PKey::RSA.new(2048)
File.write(legacy_key_path, key.export)
allow(PotatoMesh::Config).to receive(:keyfile_path).and_return(key_path)
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_return([legacy_key_path])
loaded_key, generated = harness_class.load_or_generate_instance_private_key
expect(generated).to be(false)
expect(loaded_key.to_pem).to eq(key.to_pem)
expect(File.exist?(key_path)).to be(true)
expect(File.binread(key_path)).to eq(key.export)
end
ensure
allow(PotatoMesh::Config).to receive(:keyfile_path).and_call_original
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_call_original
end
end
end