diff --git a/README-JA.md b/README-JA.md index 4fea211..7f1fcdf 100644 --- a/README-JA.md +++ b/README-JA.md @@ -189,7 +189,7 @@ MOTD、難易度、ゲームモード、最大人数、オンラインモード 通常、ツールが管理する設定の正本は各サーバーの`server.env`です。Docker Composeが値を`itzg/minecraft-server`へ渡し、コンテナ起動時に`server.properties`へ反映します。 -配布された`server.properties`を使う場合は、サーバーを停止して「サーバー設定 → server.propertiesをインポート」を選ぶか、`mcserver-kit server import-properties /path/to/server.properties`を実行します。Windowsダイアログを有効にしているWSL環境では、設定画面からWindowsのファイル選択画面を開けます。既存のデータファイルは置換前にバックアップします。対応する項目を`server.env`へ取り込み、その他のキーは`CUSTOM_SERVER_PROPERTIES`へ保存します。設定の正本は引き続き`server.env`で、元ファイルも`data/server.properties`へコピーします。ワールド名やポートがこのサーバー構成と異なる場合、または未対応の書式の場合は黙って変更せずエラーにします。サーバー起動中にファイルを直接編集しないでください。 +配布された`server.properties`を使う場合は、サーバーを停止して「サーバー設定 → server.propertiesをインポート」を選ぶか、`mcserver-kit server import-properties /path/to/server.properties`を実行します。Windowsダイアログを有効にしているWSL環境では、設定画面からWindowsのファイル選択画面を開けます。既存のデータファイルは置換前にバックアップします。対応する項目を`server.env`へ取り込み、その他のキーは`CUSTOM_SERVER_PROPERTIES`へ保存します。設定の正本は引き続き`server.env`で、元ファイルも`data/server.properties`へコピーします。`\:`などJava Propertiesのエスケープを解釈します。配布ファイルの`level-name`が異なっても、ワールドの配置は`data/world`、Composeの設定は`LEVEL=world`のままです。ポートが異なる場合や書式が不正な場合は変更前にエラーにします。サーバー起動中にファイルを直接編集しないでください。 ホーム画面からサーバーフォルダ、または永続データの`data/`をWindowsのExplorerで開けます。コマンドでは`mcserver-kit server open server`または`open data`です。WSLとExplorerの連携が必要です。 diff --git a/README.md b/README.md index da3563c..12b48e6 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ The editor covers MOTD, difficulty, game mode, player limit, online mode, whitel Normally, `server.env` is the source of truth for settings managed by the toolkit. Docker Compose passes these values to `itzg/minecraft-server`, which applies them to `server.properties` when the container starts. -For a distributed `server.properties`, stop the server and choose **Server settings → Import server.properties**, or run `mcserver-kit server import-properties /path/to/server.properties`. On WSL, the settings screen offers a Windows file picker when Windows dialogs are enabled. The existing data file is backed up before replacement. Supported properties are imported into `server.env`; additional keys are stored in `CUSTOM_SERVER_PROPERTIES`. `server.env` remains the source of truth, and the imported file is also copied to `data/server.properties`. Imports with a nonstandard world name, port, or unsupported property syntax are rejected rather than silently changing the server layout. Do not edit the file while the server is running. +For a distributed `server.properties`, stop the server and choose **Server settings → Import server.properties**, or run `mcserver-kit server import-properties /path/to/server.properties`. On WSL, the settings screen offers a Windows file picker when Windows dialogs are enabled. The existing data file is backed up before replacement. Supported properties are imported into `server.env`; additional keys are stored in `CUSTOM_SERVER_PROPERTIES`. `server.env` remains the source of truth, and the imported file is also copied to `data/server.properties`. Java Properties escapes such as `\:` are decoded. If the distributed `level-name` differs, the toolkit keeps its existing `data/world` layout and Compose's `LEVEL=world` setting. A nonstandard server port or malformed property is rejected before changing the server. Do not edit the file while the server is running. The dashboard can also open the server folder or its persistent `data/` folder in Windows Explorer. From a terminal, use `mcserver-kit server open server` or `open data`. This requires WSL interop with Explorer. diff --git a/libexec/mcserver-kit/server-config.py b/libexec/mcserver-kit/server-config.py index f52d542..17eba0d 100755 --- a/libexec/mcserver-kit/server-config.py +++ b/libexec/mcserver-kit/server-config.py @@ -145,24 +145,73 @@ def validate_properties_source(source: Path) -> None: raise ValueError("The selected file has no property entries") +def unescape_import_property(value: str, number: int) -> str: + """Decode Java Properties escapes before passing values to itzg.""" + result: list[str] = [] + index = 0 + escapes = {"t": "\t", "r": "\r", "n": "\n", "f": "\f"} + while index < len(value): + char = value[index] + if char != "\\": + result.append(char) + else: + index += 1 + if index == len(value): + raise ValueError(f"Line {number}: trailing property escape") + char = value[index] + if char == "u": + digits = value[index + 1:index + 5] + if not re.fullmatch(r"[0-9a-fA-F]{4}", digits): + raise ValueError(f"Line {number}: malformed Unicode escape") + result.append(chr(int(digits, 16))) + index += 4 + else: + # Java Properties removes the slash for non-special escapes too. + result.append(escapes.get(char, char)) + index += 1 + try: + decoded = "".join(result).encode("utf-16", "surrogatepass").decode("utf-16") + except UnicodeError as error: + raise ValueError(f"Line {number}: malformed Unicode escape") from error + if any(ord(char) < 32 for char in decoded): + raise ValueError(f"Line {number}: control characters cannot be imported") + return decoded + + def parse_import_properties(source: Path) -> dict[str, str]: - """Read the simple key=value format emitted by Minecraft without dropping entries.""" + """Parse Java Properties syntax without silently dropping server settings.""" values: dict[str, str] = {} - for number, line in enumerate(source.read_text(encoding="utf-8-sig").splitlines(), 1): - stripped = line.strip() - if not stripped or stripped.startswith(("#", "!")): + pending: str | None = None + start = 0 + for number, raw in enumerate(source.read_text(encoding="utf-8-sig").splitlines(), 1): + if pending is None: + line = raw.lstrip(" \t\f") + if not line or line.startswith(("#", "!")): + continue + start = number + else: + line = pending + raw.lstrip(" \t\f") + if (len(line) - len(line.rstrip("\\"))) % 2: + pending = line[:-1] continue - if "=" not in line: - raise ValueError(f"Line {number}: expected key=value") - key, value = line.split("=", 1) - key = key.strip() + pending = None + separator = 0 + while separator < len(line): + if line[separator] == "\\": + separator += 2 + elif line[separator] in "=: \t\f": + break + else: + separator += 1 + key = unescape_import_property(line[:separator], start) if not re.fullmatch(r"[A-Za-z0-9_.-]+", key): - raise ValueError(f"Line {number}: unsupported property key: {key}") - if "\\" in key or "\\" in value: - raise ValueError(f"Line {number}: escaped properties are not supported") - values[key] = value - if values.get("level-name", "world") != "world": - raise ValueError("level-name must be world for this server layout") + raise ValueError(f"Line {start}: unsupported property key: {key}") + remainder = line[separator:].lstrip(" \t\f") + if remainder.startswith(("=", ":")): + remainder = remainder[1:] + values[key] = unescape_import_property(remainder.lstrip(" \t\f"), start) + if pending is not None: + raise ValueError(f"Line {start}: unfinished property continuation") if values.get("server-port", "25565") != "25565": raise ValueError("server-port must be 25565 for this server layout") return values @@ -176,6 +225,7 @@ def import_properties(server_dir: Path, source: Path) -> Path | None: for env_key, property_key in PROPERTY_KEYS.items(): if property_key in imported: values[env_key] = imported[property_key] + # create-server.sh stores the selected save at data/world; Compose sets LEVEL=world. known = set(PROPERTY_KEYS.values()) | {"level-name", "server-port"} extras = [f"{key}={value}" for key, value in imported.items() if key not in known] values["CUSTOM_SERVER_PROPERTIES"] = "\n".join(extras) diff --git a/tests/run-tests.sh b/tests/run-tests.sh index 18cf47d..0fd9bed 100755 --- a/tests/run-tests.sh +++ b/tests/run-tests.sh @@ -439,13 +439,17 @@ test_local_installation() { test_home_dashboard() { local temp_dir="$1" + local repo_version next_version major minor patch local config_file="${temp_dir}/home/config.yml" local fake_bin="${temp_dir}/home/bin" local whiptail_log="${temp_dir}/home/whiptail.log" local update_cache="${temp_dir}/home/update-cache" - local curl_count="${temp_dir}/home/curl-count" local output + repo_version="$(head -n 1 "${REPO_ROOT}/VERSION")" + IFS=. read -r major minor patch <<<"$repo_version" + next_version="v${major}.${minor}.$((patch + 1))" + mkdir -p "$fake_bin" cat >"$config_file" <>"$MCSERVER_KIT_TEST_WHIPTAIL_LOG" printf 'exit' >&2 WHIPTAIL - cat >"${fake_bin}/curl" <<'CURL' -#!/usr/bin/env bash -printf x >>"$MCSERVER_KIT_TEST_CURL_COUNT" -printf '%s' 'https://github.com/cotore-game/mcserver-compose-kit/releases/tag/v1.1.1' -CURL - chmod +x "${fake_bin}/whiptail" "${fake_bin}/curl" + chmod +x "${fake_bin}/whiptail" + mkdir -p "$update_cache" + printf '%s\n%s\n' "$(date +%s)" "$next_version" >"${update_cache}/update-check" + output="$(MCSERVER_KIT_CURRENT_VERSION="$repo_version" MCSERVER_KIT_UPDATE_CACHE_DIR="$update_cache" \ + bash "${REPO_ROOT}/libexec/mcserver-kit/update.sh" --cached-quiet)" + assert_equal "$next_version" "$output" 'the dashboard fixture contains a newer cached release' output="$(MCSERVER_KIT_LANG=en bash "${REPO_ROOT}/mcserver-kit")" assert_equal 'present' "$(grep -q 'mcserver-kit home' <<<"$output" && printf present)" 'non-interactive no-argument use shows help instead of starting creation' PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ - MCSERVER_KIT_CURRENT_VERSION=1.1.0 MCSERVER_KIT_UPDATE_CACHE_DIR="$update_cache" \ - MCSERVER_KIT_TEST_CURL_COUNT="$curl_count" \ + MCSERVER_KIT_CURRENT_VERSION="$repo_version" MCSERVER_KIT_UPDATE_CACHE_DIR="$update_cache" \ MCSERVER_KIT_TUI_TEST=true MCSERVER_KIT_TEST_WHIPTAIL_LOG="$whiptail_log" \ bash "${REPO_ROOT}/mcserver-kit" home assert_equal 'present' "$(grep -q -- '--backtitle mcserver-kit' "$whiptail_log" && printf present)" 'the home command opens the interactive dashboard' assert_equal 'present' "$(grep -q 'servers Servers' "$whiptail_log" && printf present)" 'the dashboard exposes server management' - assert_equal 'present' "$(grep -q 'Update available: v1.1.1' "$whiptail_log" && printf present)" 'the dashboard announces a newer cached release' + assert_equal 'present' "$(grep -Fq "Update available: $next_version" "$whiptail_log" && printf present)" 'the dashboard announces a newer cached release' } test_home_screen_transition_after_current_update() { @@ -754,8 +757,13 @@ PROPERTIES # distributed settings motd=Distributed MOTD difficulty=hard +level-name=Fantasia_Arena_World +level-type=minecraft\:normal custom-setting=preserve-me plugin.option=enabled +plugin.label=\u00a7a +plugin.description=first \ + second PROPERTIES cat >"${fake_bin}/docker" <<'DOCKER' #!/usr/bin/env bash @@ -795,6 +803,14 @@ PROPERTIES bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/incompatible/server.properties" assert_equal 'Old MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" MOTD)" 'rejected import leaves the environment unchanged' + cat >"${source_dir}/incompatible/server.properties" <<'PROPERTIES' +plugin.option=\u12G4 +PROPERTIES + assert_fails 'malformed Unicode escapes are rejected before migration' \ + env PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ + bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/incompatible/server.properties" + assert_equal 'Old MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" MOTD)" 'malformed escape leaves the environment unchanged' + assert_fails 'import refuses to replace properties while the server is running' \ env PATH="${fake_bin}:$PATH" MCSERVER_KIT_LANG=en MCSERVER_KIT_CONFIG="$config_file" \ MCSERVER_KIT_TEST_RUNNING=true bash "${REPO_ROOT}/mcserver-kit" server alpha import-properties "${source_dir}/server.properties" @@ -806,7 +822,8 @@ PROPERTIES assert_equal 'true' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" OVERRIDE_SERVER_PROPERTIES)" 'import keeps server.env authoritative' assert_equal 'Distributed MOTD' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" MOTD)" 'import converts managed properties to environment settings' assert_equal 'hard' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" DIFFICULTY)" 'import updates the difficulty' - assert_equal $'custom-setting=preserve-me\nplugin.option=enabled' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" CUSTOM_SERVER_PROPERTIES)" 'import converts extra properties to CUSTOM_SERVER_PROPERTIES' + assert_equal $'level-type=minecraft:normal\ncustom-setting=preserve-me\nplugin.option=enabled\nplugin.label=§a\nplugin.description=first second' "$(python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" CUSTOM_SERVER_PROPERTIES)" 'import decodes Java escapes and keeps extra properties' + assert_equal 'absent' "$(! python3 "${REPO_ROOT}/libexec/mcserver-kit/server-config.py" get "${server_dir}/server.env" CUSTOM_SERVER_PROPERTIES | grep -q '^level-name=' && printf absent)" 'source world name does not override the managed world directory' assert_equal 'present' "$(grep -q 'custom-setting=preserve-me' "${server_dir}/data/server.properties" && printf present)" 'import keeps custom settings' assert_equal 'present' "$(grep -q 'motd=Old MOTD' "${server_dir}"/data/server.properties.mcserver-kit.*.bak && printf present)" 'import backs up the previous properties' assert_equal 'present' "$(grep -q 'Previous server.properties backup' <<<"$output" && printf present)" 'import reports the backup location'