From ea9c633effa65f533da3bad37b493d319557ffaf Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Mon, 13 Oct 2025 14:02:17 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 4 ++ README.md | 14 ++++ web/lib/potato_mesh/application/filesystem.rb | 32 +++++++-- web/lib/potato_mesh/application/identity.rb | 41 ++++++++++++ web/lib/potato_mesh/config.rb | 31 ++++++++- web/spec/config_spec.rb | 31 +++++++++ web/spec/filesystem_spec.rb | 36 +++++++++- web/spec/identity_spec.rb | 67 +++++++++++++++++++ 8 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 web/spec/identity_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 84097cd..47e339c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index bf618c7..879189c 100644 --- a/README.md +++ b/README.md @@ -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 `/potato-mesh/keyfile` and the +well-known document is staged in +`/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") diff --git a/web/lib/potato_mesh/application/filesystem.rb b/web/lib/potato_mesh/application/filesystem.rb index 1e176ca..e06e54c 100644 --- a/web/lib/potato_mesh/application/filesystem.rb +++ b/web/lib/potato_mesh/application/filesystem.rb @@ -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. diff --git a/web/lib/potato_mesh/application/identity.rb b/web/lib/potato_mesh/application/identity.rb index b887f3f..101157b 100644 --- a/web/lib/potato_mesh/application/identity.rb +++ b/web/lib/potato_mesh/application/identity.rb @@ -47,6 +47,7 @@ module PotatoMesh # @return [Array] 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. diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 0629a74..51d0ac4 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -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] 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] 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. diff --git a/web/spec/config_spec.rb b/web/spec/config_spec.rb index 33d9daa..92ae906 100644 --- a/web/spec/config_spec.rb +++ b/web/spec/config_spec.rb @@ -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) diff --git a/web/spec/filesystem_spec.rb b/web/spec/filesystem_spec.rb index c30b1aa..1f5cfad 100644 --- a/web/spec/filesystem_spec.rb +++ b/web/spec/filesystem_spec.rb @@ -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") diff --git a/web/spec/identity_spec.rb b/web/spec/identity_spec.rb new file mode 100644 index 0000000..4264bee --- /dev/null +++ b/web/spec/identity_spec.rb @@ -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