From f813441bf02fc88e200ea2a92ed98b8fc40a67e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 28 Oct 2026 14:55:35 +0100 Subject: [PATCH] v0.0.2 --- .env.example | 3 +- CHANGELOG.md | 8 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 12 +- SHA256SUMS | 71 ++--- build.rs | 2 +- compose.yaml | 2 +- deploy/mikrotik-backup.service | 2 +- docs/THIRD_PARTY_NOTICES.txt | 2 +- docs/api-documentation.md | 2 +- docs/architecture.md | 9 +- docs/data-import.md | 23 +- docs/data-migration.md | 16 +- docs/dependency-security.md | 4 +- docs/migration.md | 2 +- docs/openapi.json | 2 +- docs/port-matrix.md | 15 +- docs/security.md | 2 +- docs/switchos.md | 2 +- docs/ui.md | 2 +- docs/validation.md | 14 +- make_zip.py | 70 +++++ .../004_routeros_default_credentials.sql | 6 + release-manifest.json | 15 +- src/api/backups.rs | 92 +++++- src/api/devices.rs | 3 - src/api/mod.rs | 12 +- src/backup.rs | 45 ++- src/db.rs | 284 +++++++++++++++--- src/diff.rs | 130 ++++++-- src/main.rs | 7 - src/models.rs | 71 ++++- src/notifications.rs | 8 +- src/routeros.rs | 113 ++++--- src/scheduler.rs | 3 - src/schema.sql | 8 +- src/security.rs | 11 +- tests/api_contract.rs | 2 +- web/css/app.css | 19 +- web/js/app.js | 51 +++- web/js/core.js | 9 +- web/js/i18n.js | 3 +- web/js/views.js | 65 ++-- web/tests/core.test.cjs | 46 ++- 45 files changed, 938 insertions(+), 334 deletions(-) create mode 100644 make_zip.py create mode 100644 migrations/004_routeros_default_credentials.sql diff --git a/.env.example b/.env.example index 6862092..39358cd 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,8 @@ APP_NAME="MikroTik Backup System" APP_ENV=production APP_PORT=5581 # Native defaults to 127.0.0.1; Docker overrides this to 0.0.0.0:5581. -LISTEN_ADDR=127.0.0.1:5581 +LISTEN_ADDR=0.0.0.0:5581 +BIND_ADDRESS=0.0.0.0 API_PREFIX=/api TIMEZONE=Europe/Warsaw DATA_DIR=./storage diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b7008..2b789ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.0.2 — Automatic schema migration and configuration tooling + +- Added automatic transactional startup migration from the supported Rust 0.0.1 schema (v1) to schema v2. +- Added default RouterOS username/password settings with automatic database upgrade; manual `sqlite3` migration is no longer required. +- Added cross-device configuration diff, standalone classic HTML diff view, and full diff loading without pagination. +- Added direct RouterOS `.rsc`/`.backup` file upload over SSH from the repository view. +- Fixed RouterOS credential-source presentation and primary-button hover behavior in light and dark themes. + ## 0.0.1 — Initial release Initial Rust rewrite of the original Python/TypeScript MikroTik Backup System. diff --git a/Cargo.lock b/Cargo.lock index 4cba6d0..39f4788 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,7 +1148,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mikrotik-backup" -version = "0.0.1" +version = "0.0.2" dependencies = [ "anyhow", "argon2", diff --git a/Cargo.toml b/Cargo.toml index 152404c..24d305e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mikrotik-backup" -version = "0.0.1" +version = "0.0.2" edition = "2024" rust-version = "1.99" description = "Self-hosted MikroTik backup system: one Rust service with an embedded web interface" diff --git a/README.md b/README.md index 8fdcc4c..7badc84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MikroTik Backup System -Version **0.0.1** is the initial Rust rewrite of the previous Python/TypeScript application. It is a single self-hosted service with an embedded web UI, SQLite storage, RouterOS/SwOS backup workflows, scheduling, retention, notifications, and local OpenAPI documentation. +Version **0.0.2** is the current Rust release of the MikroTik Backup System. It is a single self-hosted service with an embedded web UI, SQLite storage, RouterOS/SwOS backup workflows, scheduling, retention, notifications, and local OpenAPI documentation. ## Quick start @@ -92,6 +92,12 @@ docker compose run --rm \ The importer copies accounts, devices, settings, logs, backup metadata, and every referenced backup file. It validates existing backup checksums when present and refuses to publish an incomplete destination. See [docs/migration.md](docs/migration.md). +### Automatic Rust database migration + +Version 0.0.2 upgrades a supported 0.0.1 Rust database automatically at startup. The migration adds the optional default RouterOS username/password fields and advances the internal schema marker from v1 to v2 in one SQLite transaction. No manual `sqlite3` command is required. + +The legacy Python/TypeScript database is a different format and still uses the explicit `import-python` flow described above. + ## RouterOS and SwOS RouterOS uses SSH-based export/backup operations. SwOS uses its web interface and downloads `/backup.swb`, with Digest authentication first and Basic as fallback. Connection testing distinguishes general web reachability from backup-endpoint availability. @@ -104,7 +110,9 @@ SwOS details and troubleshooting are in [docs/switchos.md](docs/switchos.md). - side-by-side and unified diff; - changed-lines-only mode and search; -- paged browser rendering to limit DOM size; +- paged browser rendering to limit DOM size, with an explicit full-diff view on demand; +- comparisons between exports from the same RouterOS device or two different RouterOS devices; +- a standalone classic unified-diff HTML view that can be opened in a separate tab; - semantic RouterOS analysis that groups changes by configuration section and identifies added, removed, and modified commands/fields; - masking of sensitive values in semantic summaries. diff --git a/SHA256SUMS b/SHA256SUMS index e168cfc..319fe15 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,48 +1,48 @@ -b1fce4075b13be98f98bb97826e6f8503b6f8684ff409f45fccfee8b721cf574 CHANGELOG.md -341839930e9b231b548dbd1dbbc8e497531ff9c4903b3cf07722f3d9f024e2d8 Cargo.lock -b16e11cea88def78673ceacbb4704259f2e23fccc98645ed7a745435b06dbfbc Cargo.toml -d2dbe44f098b72c7d7c7326147e09e4ce1313a09995031baca1bc61d29d1cb6d Dockerfile -81013dfb92305dbda89ff1a1eee1216d3b2eef4290ba2896fb77cc1506d55218 README.md -b7cab07053ffd10954d69adbb654ae1c00be78ec6f56be6b423c5e65f2be0f0a build.rs -9fdd1d93ba6806b91c36bb295085b831fefd05af807df18442d7493342c22392 compose.yaml -77e0ff099a7d85890555e05b7e6e146832e477c7f3393d1ecdc82c5e50ead91e deploy/mikrotik-backup.service +b47ea5c16b7bb10bb41395c06e50602523ed0e338b8e4d05854c2dfcb130c964 CHANGELOG.md +503e7a205b33055d788377e72f26915326059a1748e501b24378dd813e4f9c78 Cargo.lock +662ce504da8a721dd1cfeada1cd4e3826f72acd5336587e15acea95b39fb1bfa Cargo.toml +107f2abdb9a030c5b7854357c89df0cc22bb42bad28092c15a67274779aa7c69 Dockerfile +20f0f53b627c5bbc19ed213a31eb623320b8fe7257ae7a10ac744a75b031a2f8 README.md +6da56598b57511885067788413b235b7a90a72cf95e7d14a06d9df800b39fdfd build.rs +3103411f556fd1eef92b178b9ad64967454b92a5779231441b491ebad41f7d8f compose.yaml +911d775c98604f8330eb08348d4960c62dfd6c8444abf60cfaf7fbf320c81a95 deploy/mikrotik-backup.service 2bf2eda4b31ab6c08a34b7800c072320e397b50d112858c840a408a58764d016 deploy/start.sh -4d0b41060eda3a422ff00d357fabf73bd3229e9df8a7f755e128c989eb59de93 docs/THIRD_PARTY_NOTICES.txt -13dcb5c1bdbc71e1902d7c1237ccf67a6ce32ce081de9dd35e6b3b4cb8ff7b0a docs/api-ui/index.html -9e07aa7d91594d7066470face7f43e3a8bbd69cbd88fd845343f5689c0ca7027 docs/api-ui/init.js -a5cea2ffa49b8d2a0f11ed99e9b9602f600799fb0bfffa57d844c2be773e34b9 docs/api-ui/style.css +a245139dd8e13ca4b8743077f6011a8f31dab48ff0569caa6e5b6e0ddf0e2021 docs/THIRD_PARTY_NOTICES.txt +b8b3bd39a1e792f0e1c63c6f095506e61a61dee9f1983bcb0391e12bbbbb93ad docs/api-ui/index.html +8d43b1349e3e30dbf1fb677fc310c919439fb318da016c8f1d1f0b46bca4c28c docs/api-ui/init.js +eb9f5f220a465faa34f1bce7a09169241604ae7a8e363efed111a38b6d3befa1 docs/api-ui/style.css 061a0b6a4a2ce5823edbcbf8d7f47a775d348d4cd4f125fd6145189e717feba1 docs/dependency-audit-metadata.json 49e44f55fdaf3e2aacd358d312489882a91147f8790a81348ffa3f9fd76e6634 docs/dependency-audit.json -dcebf59142a77d8f84950d535db0fa6b656519d530ccdfba2855f9ff0fc3f475 docs/migration.md -8d7b4db7302767a0b0d34df5c05270580ac6ae709e0b3ac002668a1a7b2aaf96 docs/openapi.json -cb90e9f1f1827770c67561ca68f5b30f5b025a50da6804ee8c59efa9abd1c716 docs/security.md -5dd31dff53db62fc97e94f02a52efb13c44b6a3950aa13416f4888ef215503e3 docs/switchos.md +c3a7e4304fa3960e4a65a5a1248b2e69f197e4bd60e51c11274ebec19634259f docs/migration.md +967ac1a063ca43017ad3c3850cbad72674c0c3150f6b1673e94ae53fa1f98e2d docs/openapi.json +e4f9b9d5d82edc66f395bd5c416974c9663088463153c533b28b84705f231e97 docs/security.md +9dc2baa1837a30c8794c9edaf05e4b2e2764259898a74cf511e40d618b92aa84 docs/switchos.md 1533deb562e6cd6379a3c8b7988f8b3170c6629a04c874d21a315bcc2ea94407 import-python.sh a9c47bfae030614a21ea30893c285398251340624caec4d029c5b090b7431936 migrations/001_initial.sql 048c5f9d02a6c8f4dac0bbcc5b50650ff0ad036116ba463ae554f43dec750d54 migrations/002_switchos_credentials_source.sql cb8a70125de6688e738ef27811d4297e575a35248b8c00f262fb86cff9ce03ce migrations/003_backup_layout.sql -4d71d9eb0fa89d7182b20fe448cae00c22a851258727451052acaf3145b29fbc release-manifest.json +7bf1f9f76c8a01c5f9cc464c1256d1cf5b6d05f379cecdac97eee5c07fa94175 release-manifest.json 638924b15afb3b6f88b83713e356162659987378c6731a2ea3403b90e31ae0eb rust-toolchain.toml 2883973832dd23f34889b1788ca7ddcd57e2410cf28245b8da55dacd45960d43 src/api/auth.rs -079473319684d445eb7b9b8b01df7207fd96eed5a98287fa541376692e6d1204 src/api/backups.rs -eda20155c645788ebc238a4553edaa90b7e5314782e82337125f41ba50b90234 src/api/devices.rs +aa876ec7f03c5be28801883319eb13c0152640579031d8bec50fc98c00684bc0 src/api/backups.rs +27d891210c8918373fbdb877f06dc9e900057850e50ef9ced4f8ff5aa20491b2 src/api/devices.rs 9827188b7a89573a1b108a3d0cb58e2f720d4b62549cf062b44be767f8aaafa5 src/api/documentation.rs -8f4f8ee55780e90d20c85920a471e4f72b3f17b92cb51469e4634176d6daba3b src/api/mod.rs +9e68ac135312bd7fc96c99b8eb06b93efb906c5d46bbe568ea897ef550e13f8f src/api/mod.rs 81ae453a28edaebf554e11324d088cfe95dbdc6625e388e7f1f5398d94d72e7b src/api/system.rs -7b6959c99e9eca52e1f0aa862c32bb7ca0f988b6b4d7b1c0d1eb8bbd448a5f25 src/backup.rs +5185c0e69a17d3805d6038208964d2dfb8b3a563ec6230d341dbccca20cb6cf8 src/backup.rs 1eee0ed74a61882247332dea93372cba54f310211ed05095712054b0432d507a src/config.rs -bd5d86db6192528ad4c7d79f422650317dd37bfd1e0b8ad623244b4ba93ef545 src/db.rs -30e35f94bf7d876ce0f8fc696f47e6e7680da7fe74e43d47408a0140dac6e5e1 src/diff.rs +87b2ad47c5ab7e33f4ca7ac823b6af006bfc087ee7d34459e79bca89b82429b1 src/db.rs +84db7474b9e218569229bdc466ad564691aec83ae16c322546c70ac7d904f44c src/diff.rs 18d23f46601f69dcf5ce817709e3b7e9ae08aa282bd29b2900946a81bdf78342 src/lib.rs 92c3b8afe80d5d36e21e5fb8da6d718a69cab677ae935528105b0dd026b13869 src/main.rs -89aad2d2795bfbfb18178818aa9908a01d010167d8647f7563aa85c0397e9622 src/models.rs -e570ce7cad4fe4f325419e6cae0d15ff6ecb4b27f79ef3e9c77508a4cdd279d9 src/notifications.rs -2db2f31fbf2c809c6a82cdf8a69dd0012570298b71bd2d2e5d7844ff39903a7c src/routeros.rs +9800272a2bfb4b9f8ea1c5fbb2c83953ea0db025786357d6c7daf26f204dfa5d src/models.rs +8318c912ecfe5214d1b2446e0e0be958ccacbc1700aac792a5f68a5c7b15f9a5 src/notifications.rs +2b63ef9bcfbd21687ff430dcb7f98c8a2e325336d1ec212d6414367430e2096e src/routeros.rs 733a77ddf1dbd6b64e6ec425ee4e2b6f842a0902c61c274f0a1171d69ea5a3ce src/scheduler.rs -6a002266e727e17a23260e25c151191889cee8a793e4412f57cd6ef35cc137e9 src/schema.sql +6bcbb64224105ace3c7711b6e82c034d393732bf8c8b7337511ed1fed8e55e32 src/schema.sql 77acdb3df08469f6834b952e2f75f0ec5198073f781630e16cd9b327f7757258 src/security.rs ce3b2dd7e7005a80571494c954f18d6042ee88d055deab346b191ca9374daaf7 start.sh -b7e80c541653c41e0ebb1ab7c65e35ace5f58adc40f628292a6d18d10ba58dd9 tests/api_contract.rs +9348833266f61023f25101a069625db63bdfe52f08cc36af4d437e71b3cf2192 tests/api_contract.rs dd4d70d7de7209d1113eebaa6799c39535a88ac2cc783445350283fb1c9c5f2e tests/fixtures/python-diff.json bae48d41ce4561196c4428bcb274502e4d8e04fee28b9684c2c579ad82749b18 tests/import_launcher.rs 3cd3d0763f3b46b752720257b183669c602d39f1b07e35ddf07676cfbb5d0eae tests/openapi_contract.rs @@ -144,12 +144,13 @@ c02dd4680423fdaffe96012cd012adda68b26281152a95355744ed4f6141c282 vendor/swagger e5a4792feaa5332dfba37f1d7adf84308b93b4f2d8da0569a9f48a3cb61c969b vendor/swagger-ui/swagger-ui-bundle.js.LICENSE.txt c3cfe8248c4b0b89673600fb829924eafbac6de4bc4b2b06f5f5243483074d0a vendor/swagger-ui/swagger-ui.css 32a37246461a62133053549be4917c5f879410440d67c2e24c76a294a06f9ddb web/assets.list -c88b1848a818c76a76cc4ca57759002fae88d3470afaa8d3a60bea3977d35b00 web/css/app.css -706eb87cc7a6c538f58df091f91cc223a9de4aed308717625a9625495bbde507 web/index.html -cc5062ef7c94e375bb67e5d1127458c0f02163d4bcc0f9e618b44dee458fd58c web/js/app.js -3d40fd0665a87e3d26cabfc20d0292498750c1b4974a69538eb0cb0e9344c4e3 web/js/core.js -8c8ed4800886c9aebb7ac52de8351b2fc26dbafc8977e89217c631b960c228cc web/js/i18n.js -510f9cc6137777e03062d23a5bf841f5b6033b1f976a4825bb4700dfba4d40fc web/js/views.js -5423de8d4b4da517aa2d740a08bbb6fcc6e1d03e0b0d8dd255a489d08c2365ef web/tests/core.test.cjs +9dc176bca56cda46f9d17d8c520469d583afbe2b1c3c75bdba8e82453a3ae62b web/css/app.css +5db148afe605137f20f70c5cfaabc69142415c546eea63a1aab8e7ae8aacdc77 web/index.html +9f72a910cab2e7f71dd4fbbcf2110648b8bad0cd4adf635183e310a7ca7756cf web/js/app.js +03a6dc408ee473c7fa5e1edd412147300dbe25436c0106f93d6ba7080ca41e01 web/js/core.js +2571bf7ae9882c2aee5cbfe33c59707f851c440c02083c897436e63ce80491d4 web/js/i18n.js +1921e3641aa920aba0f1f1e69fdedacfa02bf60717bca44d004b9e33a4d72374 web/js/views.js +b007dc2a47fb520c0ca3bdb4cb154e09da1090991c79d01e14ea35a74a81ecea web/tests/core.test.cjs 78955d3bb7eacc8f4144570df5d54b17b54419d403c6d98472d7314b21c8e377 web/tests/live.cjs de7d09e3d50c339962aa75d4e7603fcd3457e1ba1c2c2c0d5c536c83a005c77d web/tests/smoke.cjs +686cfb85e9139ede4c2ddb395d2e44784c5bdb344aa2cb8928ab6eca35074b06 migrations/004_routeros_default_credentials.sql diff --git a/build.rs b/build.rs index 2b52743..683d72c 100644 --- a/build.rs +++ b/build.rs @@ -44,7 +44,7 @@ fn main() { let manifest = fs::read_to_string("web/assets.list").expect("web/assets.list is required"); let mut js = - String::from("/* MikroTik Backup System 0.0.1; generated from web/assets.list */\n"); + String::from("/* MikroTik Backup System 0.0.2; generated from web/assets.list */\n"); let mut css = String::new(); for entry in manifest .lines() diff --git a/compose.yaml b/compose.yaml index ee1ac62..dc04665 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,7 @@ services: app: build: . - image: mikrotik-backup:0.0.1 + image: mikrotik-backup:0.0.2 restart: unless-stopped ports: - "${BIND_ADDRESS:-127.0.0.1}:${APP_PORT:-5581}:5581" diff --git a/deploy/mikrotik-backup.service b/deploy/mikrotik-backup.service index ffcb929..3493fc1 100644 --- a/deploy/mikrotik-backup.service +++ b/deploy/mikrotik-backup.service @@ -1,5 +1,5 @@ [Unit] -Description=MikroTik Backup System 0.0.1 +Description=MikroTik Backup System After=network-online.target Wants=network-online.target diff --git a/docs/THIRD_PARTY_NOTICES.txt b/docs/THIRD_PARTY_NOTICES.txt index 1077bd0..c6e28f8 100644 --- a/docs/THIRD_PARTY_NOTICES.txt +++ b/docs/THIRD_PARTY_NOTICES.txt @@ -1,4 +1,4 @@ -THIRD-PARTY NOTICES — MikroTik Backup System 0.0.1 +THIRD-PARTY NOTICES — MikroTik Backup System 0.0.2 Generated from the locked Cargo dependency graph for x86_64-unknown-linux-gnu. This inventory includes build/test dependencies where they occur in that graph. Original license and notice texts below are reproduced without alteration. diff --git a/docs/api-documentation.md b/docs/api-documentation.md index e0bc88c..000f086 100644 --- a/docs/api-documentation.md +++ b/docs/api-documentation.md @@ -1,4 +1,4 @@ -# Interactive API documentation — 0.0.1 +# Interactive API documentation — 0.0.2 ## URLs diff --git a/docs/architecture.md b/docs/architecture.md index dfc6feb..1f9cc0d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,4 +1,4 @@ -# Architecture — release 0.0.1 +# Architecture — release 0.0.2 ## One process with explicit module boundaries @@ -101,12 +101,7 @@ operation timeouts or the number of devices require a longer shutdown window. ## Fresh initialization and explicit import -`src/schema.sql` contains the complete current schema. If the database file does not -exist, startup creates a fresh database. If a database file exists, startup first -opens it read-only and validates that it already has the supported schema. An older -or unrelated schema is rejected with instructions to use a fresh installation and -explicit import. Startup does not run `ALTER TABLE`, create upgrade snapshots, add -migration journals, or move backup files. +`src/schema.sql` contains the complete current schema. If the database file does not exist, startup creates a fresh schema v2 database. If a database file exists, startup first validates a private read-only snapshot. Current schema v2 databases are accepted unchanged; the supported Rust 0.0.1 schema v1 is then upgraded transactionally to v2 after storage locking. Unsupported or unrelated schemas are rejected with instructions to use a fresh installation or explicit Python import. Startup schema migration does not move backup files. The separate `import-python --source DIR [--database PATH] [--backup-root DIR]` command imports into a new destination. The `import-python.sh` wrapper builds the diff --git a/docs/data-import.md b/docs/data-import.md index 13333e2..7832ce3 100644 --- a/docs/data-import.md +++ b/docs/data-import.md @@ -1,8 +1,6 @@ # Fresh installation and explicit Python import -Version **0.0.1** starts with a clean installation. It does not update an old -schema or move existing backup files during startup. Importing data from the -Python/TypeScript application is a separate, explicit operation. +Version **0.0.2** initializes clean installations and automatically migrates the supported Rust 0.0.1 schema to the current schema at startup. It does not import or rewrite a Python/TypeScript installation automatically. Importing data from the Python/TypeScript application remains a separate, explicit operation. ## Choose one installation path @@ -23,22 +21,13 @@ server**. Starting the server first creates a database, and the importer will refuse to overwrite or merge it. Use a different new destination if a database already exists; do not delete a database merely to bypass this check. -## Startup never upgrades an existing database +## Startup migration policy -Startup accepts an existing database only when it has the current `schema_info` -marker, supported format version, required tables and columns, valid integrity, -and the expected user/settings records. An older Python schema, an earlier Rust -work-in-progress schema, an unknown version, or a pre-existing empty database file -is rejected with instructions to select a fresh destination or use the explicit -Python importer. No `ALTER TABLE`, persistent pre-upgrade snapshot, migration -journal, automatic import, or backup relocation is performed. +Startup accepts the current schema and the supported Rust 0.0.1 schema. Before creating storage locks or secrets, the server performs a read-only preflight against a private temporary copy of the database, including committed WAL content. -Before creating storage locks or secrets, the server performs a read-only -preflight. SQLite inspects a private temporary copy of the database and its WAL -or rollback journal, so opening an incompatible database cannot create a source -`-shm` file. The temporary inspection copy is removed afterwards. The schema is -checked again while the server holds its storage locks. Existing current records -and file paths are left unchanged. +After storage locking, a Rust schema v1 database is upgraded transactionally to schema v2 by adding `default_routeros_username` and `default_routeros_password` and advancing `schema_info.schema_version`. The resulting schema is validated before the service starts. A current schema is left unchanged. + +Python/TypeScript schemas, unsupported Rust work-in-progress schemas, unknown versions, and pre-existing empty database files are rejected. They are never imported automatically. ## Import command diff --git a/docs/data-migration.md b/docs/data-migration.md index f358349..cabf20a 100644 --- a/docs/data-migration.md +++ b/docs/data-migration.md @@ -1,11 +1,10 @@ -# Importing data from the Python/TypeScript release +# Data migration -The current release does not perform automatic data migration and does not expose the -old `migrate` command. The supported path is the explicit, copy-only -`import-python` command. See [data-import.md](data-import.md) for the full source -discovery, validation, rollback, and failure rules. +Version **0.0.2** performs automatic, transactional schema migration for supported Rust databases. A database created by Rust 0.0.1 uses schema v1; on startup 0.0.2 validates it, adds `default_routeros_username` and `default_routeros_password`, updates `schema_info.schema_version` to `2`, validates the resulting schema, and then starts the service. No manual `sqlite3` command is required. -For the standard layout of the supplied legacy project: +The automatic migration applies only to the supported Rust schema. It does not convert the previous Python/TypeScript application database. For that source, use the explicit, copy-only `import-python` command described in [data-import.md](data-import.md). + +For the standard legacy Python layout: ```sh DATA_DIR="$PWD/storage-imported" \ @@ -15,7 +14,4 @@ DATABASE_PATH="$PWD/storage-imported/routeros_backup_next.db" \ --backup-root docker-data ``` -Stop the old application first. The destination directory must be new and must not -contain an existing database. The import preserves application records and copies -referenced backup files. It intentionally does not copy external environment secrets, -service-manager files, operating-system permissions, or external SSH host-trust files. +Stop the old Python/TypeScript application first. The import destination must be new and must not contain an existing database. diff --git a/docs/dependency-security.md b/docs/dependency-security.md index eeeb145..3aaf643 100644 --- a/docs/dependency-security.md +++ b/docs/dependency-security.md @@ -1,7 +1,7 @@ -# Dependency review — release 0.0.1 +# Dependency review — release 0.0.2 The audit documented here was performed on **27 October 2026 for source revision 2** -of release 0.0.1. Its scope was `Cargo.lock`, known public advisories, and the C SSH +of release 0.0.2. Its scope was `Cargo.lock`, known public advisories, and the C SSH library bundled through `libssh2-sys`. Revision 3 additionally verified the provenance of local Swagger UI assets. These historical checks are not a claim that a new advisory scan has been run for revision 4; see [the validation report](validation.md). diff --git a/docs/migration.md b/docs/migration.md index 486f693..64c8182 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,6 +1,6 @@ # Import from the previous Python/TypeScript application -The Rust service does not perform automatic data migrations on startup. Use the explicit `import-python` command once, with the previous application stopped. +Rust-to-Rust schema migration is automatic for supported releases: 0.0.2 upgrades a 0.0.1 Rust database at startup. This document covers the separate Python/TypeScript import, which remains explicit. Use `import-python` once, with the previous application stopped. ## What is imported diff --git a/docs/openapi.json b/docs/openapi.json index aee9730..fe97f3f 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "MikroTik Backup System", - "version": "0.0.1", + "version": "0.0.2", "description": "Documentation for the live API. Use Authorize with your username and password or provide a Bearer token. Try it out performs real operations on the current instance. Registration is disabled by default. Accounts can access their own devices and backups; settings and logs are shared by operators." }, "servers": [ diff --git a/docs/port-matrix.md b/docs/port-matrix.md index a47e108..95b87d9 100644 --- a/docs/port-matrix.md +++ b/docs/port-matrix.md @@ -1,4 +1,4 @@ -# Port coverage matrix — 0.0.1 +# Port coverage matrix — 0.0.2 ## Scope and source identity @@ -86,9 +86,7 @@ contains `access_token`, `token_type=bearer`, and `user`. ## Models and source fields -Models are explicitly typed in `src/models.rs`. `src/db.rs` implements persistence, -current-schema validation, and explicit import. `src/schema.sql` is the complete -fresh-install schema. Revision 4 does not run incremental schema migrations at startup. +Models are explicitly typed in `src/models.rs`. `src/db.rs` implements persistence, current-schema validation, automatic migration of the supported Rust v1 schema, and explicit Python import. `src/schema.sql` is the complete fresh-install schema for schema v2. | Model / group | Preserved or added fields | Notes | | --- | --- | --- | @@ -107,9 +105,9 @@ fresh-install schema. Revision 4 does not run incremental schema migrations at s | StorageStats | `total`, `used`, `free`, `folder_used`, `usage_percent` | `usage_percent` measures the data directory against total disk capacity, matching the source. | | Settings retention | `backup_retention_days`, `log_retention_days` | 0 means an age threshold of zero days, matching the original retention function; it does not disable cleanup. | | Settings scheduling | `export_cron`, `binary_cron`, `retention_cron`, `enable_auto_export`, `connection_test_interval_minutes` | Five-field cron, configured timezone, Monday=0 as in APScheduler. | -| Settings device defaults | `global_ssh_key`, `default_switchos_username`, `default_switchos_password` | Revealing the global key requires password verification. | +| Settings device defaults | `global_ssh_key`, `default_routeros_username`, `default_routeros_password`, `default_switchos_username`, `default_switchos_password` | RouterOS user/password fall back to the global defaults when a device field is blank. Revealing the global key requires password verification. | | Settings notifications | `pushover_token`, `pushover_userkey`, `notify_failures_only`, `smtp_host`, `smtp_port`, `smtp_login`, `smtp_password`, `smtp_notifications_enabled`, `recipient_email` | SMTP, attachments, Pushover, provider tests, and failures-only mode. | -| Settings DTO | `id`, `has_global_ssh_key`, `has_default_switchos_credentials` | Additional `has_smtp_password`, `has_pushover_token`, `has_pushover_userkey`, and `has_default_switchos_password` flags support editing masked secrets. | +| Settings DTO | `id`, `has_global_ssh_key`, `has_default_routeros_credentials`, `has_default_switchos_credentials` | Additional password/token presence flags support editing masked secrets without returning secret values. | | Diff JSON | `left_backup_id`, `right_backup_id`, `left_file_name`, `right_file_name`, `diff_text`, `stats`, `lines` | Statistics: added/removed/modified/context. Lines: type, left_number/right_number, left_text/right_text. Standalone `diff_html` is generated only by the dedicated HTML endpoint to avoid duplicating large payloads in the browser response. | | SchedulerStatus | `timezone`, `running`, `jobs` | Job fields: key, label, enabled, cron, description, description_params, valid, next_runs, error. | @@ -193,10 +191,7 @@ deployment extensions. - Empty cron expressions disable their schedules. Invalid expressions, invalid ports, negative retention, and excessive request sizes are rejected. Jobs are prevented from overlapping themselves. -- Revision 4 initializes only a fresh schema and validates an existing database - before use. There are no startup migrations or backup-layout conversions. Python - import is explicit, reads the source without modifying it, and requires a new - destination. See [data-import.md](data-import.md). +- Version 0.0.2 validates an existing Rust database before use and automatically upgrades supported schema v1 databases to schema v2 in a transaction. Python import remains explicit, reads the source without modifying it, and requires a new destination. See [data-import.md](data-import.md). - Runtime is one Rust process with SQLite, scheduling, and an embedded interface. Python, Node, Angular, and nginx are not required application processes. diff --git a/docs/security.md b/docs/security.md index 7d1c8f6..dd89cab 100644 --- a/docs/security.md +++ b/docs/security.md @@ -1,6 +1,6 @@ # Security and dependency notes -Version 0.0.1 is distributed as source and uses the dependency versions pinned in `Cargo.lock`. +Version 0.0.2 is distributed as source and uses the dependency versions pinned in `Cargo.lock`. Key controls include: diff --git a/docs/switchos.md b/docs/switchos.md index 16ff85c..b744f04 100644 --- a/docs/switchos.md +++ b/docs/switchos.md @@ -43,4 +43,4 @@ Do not treat an HTTP `200` login page as a valid backup. ## Scope -Version 0.0.1 downloads SwOS backups. It does not restore/upload a SwOS configuration or reboot a switch. +Version 0.0.2 downloads SwOS backups. It does not restore/upload a SwOS configuration or reboot a switch. diff --git a/docs/ui.md b/docs/ui.md index 5512e92..49e114e 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -1,4 +1,4 @@ -# Interface — 0.0.1 +# Interface — 0.0.2 The interface uses the application's real API. It does not populate sample devices, simulate operation results, or present disconnected actions as working features. diff --git a/docs/validation.md b/docs/validation.md index 02d89b2..39ba81e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -7,7 +7,8 @@ respective source revisions. | Area | Current result | Scope | | --- | --- | --- | | JavaScript syntax | **PASS** | `node --check web/js/*.js` | -| JavaScript unit tests | **16/16 PASS** | `node web/tests/core.test.cjs` | +| JavaScript unit tests | **18/18 PASS** | `node web/tests/core.test.cjs` | +| SQLite schema migration | **PASS** | v1 fixture migrated to v2; existing SwitchOS credentials preserved; migrated `global_settings` column layout matches fresh schema | | Rust compile/tests | **NOT RUN in this environment** | `cargo` / `rustc` are not installed in the available runtime; no current Rust pass is claimed | | Browser smoke/live tests | **NOT RUN in this environment** | Playwright is not installed; the test files could not start | | SwitchOS protocol comparison | **REVIEWED** | Compared directly with the supplied Python `SwosBetaService`; local `requests.HTTPDigestAuth` output was also checked for header formatting | @@ -21,7 +22,7 @@ resolved on the affected firmware. --- -# Validation report — 0.0.1, revision 4 +# Validation report — 0.0.2 Date: **27 October 2026**. This report separates completed revision 4 verification from historical results. Historical checks are explicitly labeled and are not @@ -48,7 +49,7 @@ checked-in input files and translates project documentation into English. | API documentation embedding | **PASS** | `/docs`, `/openapi.json`, local versioned assets, matching content hashes, custom prefix, and English documentation | | Release-profile check | **PASS** | `cargo check --release --locked --offline` after removing all six previous generated documentation files; no `OUT_DIR/docs.*` files recreated | | Formatting and static analysis | **PASS** | Package-scoped formatting check and `cargo clippy --all-targets --locked --offline -- -D warnings` | -| JavaScript unit tests | **16/16 PASS** | Existing interface behavior, translations, forms, and credential-source controls | +| JavaScript unit tests | **18/18 PASS** | Existing interface behavior, translations, forms, and credential-source controls | | Chromium with actual Rust and a SwOS fixture | **8/8 PASS** | Global credentials, backup bytes/layout, HTML rejection, English Swagger rendering, public requests, and OAuth-authorized requests | | Chromium with actual Rust navigation | **PASS** | Eight routes, settings redirects, responsive layout, CSP, no API/JavaScript errors, and sign-out | | Dependency integrity | **PASS** | All 91 libssh2 checksum entries and 5 Swagger UI assets verified; Cargo.lock unchanged from the documented historical audit | @@ -91,8 +92,7 @@ Evidence is recorded in `revision4-release-check.log`, `revision4-rust-tests.log These are acceptance-environment reports, not files promised in the source archive. The project contains the Rust, launcher, and interface regression tests. -The installation sequence is documented in [data-import.md](data-import.md). -There is no automatic upgrade, startup conversion, or migration recovery framework. +The installation sequence is documented in [data-import.md](data-import.md). Version 0.0.2 adds automatic transactional migration from the supported Rust schema v1 to v2. Python/TypeScript conversion remains an explicit import. ## Historical revision 3 results @@ -106,7 +106,7 @@ a revision 3 release build, Docker container, or physical MikroTik acceptance te | HTTP contract tests | **11/11 PASS** | Actual Axum router and SQLite, authorization, file operations, and SwitchOS credential-source modes | | OpenAPI/documentation tests | **2/2 PASS** | All 36 operations, route/custom-prefix compatibility, and local Swagger assets without a CDN | | Total Rust tests | **104/104 PASS** | 91 unit + 11 API + 2 OpenAPI; zero failures or skipped tests | -| JavaScript tests | **16/16 PASS** | UI logic, forms, schedules, translations, and interface regressions | +| JavaScript tests | **18/18 PASS** | UI logic, forms, schedules, translations, and interface regressions | | Chromium workflow with controlled API | PASS | **113 requests** to a local fixture API, full interface workflow, no JavaScript errors | | Final formatting | **PASS** | `cargo fmt --package mikrotik-backup -- --check` | | Final Rust static analysis | **PASS** | `cargo clippy --all-targets --locked -- -D warnings` | @@ -314,7 +314,7 @@ pairs recognizable commands, and flags selected security/network/service section Current JavaScript verification in the patch environment: -- `node --test web/tests/core.test.cjs`: **16/16 PASS**. +- `node --test web/tests/core.test.cjs`: **18/18 PASS**. - `node --check web/js/*.js`: **PASS**. - Playwright browser acceptance: **NOT RUN** because the Playwright Node module is not installed. - Rust compiler/tests: **NOT RUN** because `cargo` and `rustc` are unavailable. diff --git a/make_zip.py b/make_zip.py new file mode 100644 index 0000000..47d4687 --- /dev/null +++ b/make_zip.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +import os +import sys +import zipfile +import subprocess +from pathlib import Path + + +def run_git_command(args, repo_path: Path) -> bytes: + result = subprocess.run( + ["git", *args], + cwd=repo_path, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=True, + ) + return result.stdout + + +def get_files_to_archive(repo_path: Path) -> list[str]: + output = run_git_command( + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + repo_path, + ) + files = output.decode("utf-8", errors="surrogateescape").split("\0") + return [f for f in files if f] + + +def make_zip(repo_path: Path, output_zip: Path) -> None: + files = get_files_to_archive(repo_path) + + output_zip = output_zip.resolve() + if output_zip.exists(): + output_zip.unlink() + + with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for rel_path in files: + abs_path = repo_path / rel_path + + if not abs_path.exists(): + continue + + if abs_path.resolve() == output_zip: + continue + + zf.write(abs_path, arcname=rel_path) + + print(f"Utworzono archiwum: {output_zip}") + print(f"Dodano plików: {len(files)}") + + +def main(): + repo_path = Path.cwd() + + if len(sys.argv) > 1: + output_zip = Path(sys.argv[1]) + else: + output_zip = repo_path / f"{repo_path.name}.zip" + + try: + run_git_command(["rev-parse", "--show-toplevel"], repo_path) + except subprocess.CalledProcessError: + print("Błąd: ten katalog nie jest repozytorium Git.", file=sys.stderr) + sys.exit(1) + + make_zip(repo_path, output_zip) + + +if __name__ == "__main__": + main() diff --git a/migrations/004_routeros_default_credentials.sql b/migrations/004_routeros_default_credentials.sql new file mode 100644 index 0000000..c23bd56 --- /dev/null +++ b/migrations/004_routeros_default_credentials.sql @@ -0,0 +1,6 @@ +-- Automatic migration from the 0.0.1 Rust schema (schema v1) to 0.0.2 (schema v2). +-- The application applies the equivalent steps transactionally at startup. +ALTER TABLE global_settings ADD COLUMN default_routeros_username TEXT; +ALTER TABLE global_settings ADD COLUMN default_routeros_password TEXT; +UPDATE schema_info SET schema_version = 2 +WHERE id = 1 AND application = 'mikrotik-backup' AND schema_version = 1; diff --git a/release-manifest.json b/release-manifest.json index 9d74769..bcd94b9 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,6 +1,6 @@ { "name": "MikroTik Backup System", - "version": "0.0.1", + "version": "0.0.2", "distribution": "source", "prebuilt_binary": false, "rust_toolchain": "1.99.1", @@ -15,10 +15,12 @@ }, "data": { "schema": "src/schema.sql", - "automatic_migrations": false, + "automatic_migrations": true, "legacy_import": "explicit import-python command into a new destination", "binary_backups": "storage/backups/binary//", - "text_exports": "storage/backups/exports//" + "text_exports": "storage/backups/exports//", + "schema_version": 2, + "migration_policy": "automatic transactional migration from supported Rust schema v1; Python/TypeScript import remains explicit" }, "frontend_assets": { "strategy": "content-hashed filenames generated at build time", @@ -34,10 +36,11 @@ }, "verification": { "javascript_syntax": "PASS in patch environment", - "javascript_unit_tests": "16/16 PASS", + "javascript_unit_tests": "18/18 PASS", "rust_compile_tests": "NOT RUN in patch environment because cargo/rustc are unavailable", "browser_tests": "NOT RUN in patch environment because Playwright is unavailable", "physical_switchos_device": false, - "docker_runtime": false + "docker_runtime": false, + "sqlite_schema_migration": "PASS: schema v1 -> v2 preserves existing settings and matches fresh schema column layout" } -} +} \ No newline at end of file diff --git a/src/api/backups.rs b/src/api/backups.rs index 1eabf32..ac40bd0 100644 --- a/src/api/backups.rs +++ b/src/api/backups.rs @@ -3,15 +3,18 @@ use crate::{AppState, backup, diff, models::BackupRecord}; use axum::{ Json, body::Body, - extract::{Path, Query, State}, + extract::{Path, Query, Request, State}, http::{StatusCode, header}, response::{Html, IntoResponse, Response}, }; +use http_body_util::BodyExt; use serde::Deserialize; use serde_json::{Value, json}; +use tokio::io::AsyncWriteExt; use std::{ collections::{HashMap, HashSet}, io::{Seek, SeekFrom}, + path::Path as FsPath, }; fn owned_backup(state: &AppState, user_id: i64, id: i64) -> ApiResult { @@ -224,6 +227,93 @@ pub(super) async fn upload( .map_err(|e| ApiError::operation(&state, e))?; Ok(Json(json!({"message":"Backup uploaded to router"}))) } +#[derive(Deserialize)] +pub(super) struct UploadFileQuery { + filename: String, +} + +pub(super) async fn upload_file( + State(state): State, + Auth(user): Auth, + Path(id): Path, + Query(query): Query, + request: Request, +) -> ApiResult> { + let router = owned_router(&state, user.id, id)?; + if router.device_type != "routeros" { + return Err(ApiError::bad( + "Files can be uploaded over SSH only to RouterOS devices", + )); + } + + let name = FsPath::new(query.filename.trim()) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| ApiError::invalid("Uploaded file name is invalid"))? + .to_string(); + let extension = FsPath::new(&name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + if !matches!(extension.as_str(), "rsc" | "backup") { + return Err(ApiError::invalid( + "Only RouterOS .rsc and .backup files can be uploaded", + )); + } + if name.len() > 240 + || name.starts_with('.') + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) + { + return Err(ApiError::invalid( + "File name may contain only ASCII letters, digits, dot, dash and underscore", + )); + } + + let temp = tempfile::NamedTempFile::new_in(&state.config.data_dir) + .map_err(anyhow::Error::from)?; + let mut writer = tokio::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(temp.path()) + .await + .map_err(anyhow::Error::from)?; + let mut body = request.into_body(); + let mut size = 0u64; + while let Some(frame) = body.frame().await { + let frame = frame.map_err(|_| ApiError::bad("Cannot read uploaded file"))?; + let Ok(chunk) = frame.into_data() else { + continue; + }; + size = size.saturating_add(chunk.len() as u64); + if size > crate::routeros::MAX_BACKUP_BYTES { + return Err(ApiError( + StatusCode::PAYLOAD_TOO_LARGE, + "Uploaded file exceeds the 512 MiB limit".into(), + )); + } + writer.write_all(&chunk).await.map_err(anyhow::Error::from)?; + } + writer.flush().await.map_err(anyhow::Error::from)?; + drop(writer); + if size == 0 { + return Err(ApiError::invalid("Uploaded file is empty")); + } + + let source = temp.reopen().map_err(anyhow::Error::from)?; + backup::upload_local_file(&state, &router, source, &name) + .await + .map_err(|error| ApiError::operation(&state, error))?; + Ok(Json(json!({ + "message": "File uploaded to device", + "file_name": name, + "file_size": size + }))) +} + pub(super) async fn remove( State(state): State, Auth(user): Auth, diff --git a/src/api/devices.rs b/src/api/devices.rs index 3b90154..8b840c0 100644 --- a/src/api/devices.rs +++ b/src/api/devices.rs @@ -144,9 +144,6 @@ fn apply(router: &mut RouterRecord, payload: RouterInput) -> ApiResult<()> { if payload.clear_ssh_password { router.ssh_password = None; } - if router.device_type == "routeros" && router.ssh_user.is_empty() { - router.ssh_user = "admin".into(); - } if let Some(value) = payload.disable_all_backups { router.disable_all_backups = value; } diff --git a/src/api/mod.rs b/src/api/mod.rs index 9a37073..8c3201d 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -49,6 +49,7 @@ impl ApiError { if let Ok(settings) = state.db.get_settings() { for secret in [ &settings.global_ssh_key, + &settings.default_routeros_password, &settings.default_switchos_password, &settings.smtp_password, &settings.pushover_token, @@ -138,6 +139,7 @@ pub fn router(state: AppState) -> Router { "/backups/router/{id}/upload/{backup_id}", post(backups::upload), ) + .route("/routers/{id}/files/upload", post(backups::upload_file)) .route("/backups/bulk", post(backups::bulk)) .route("/backups/{id}", delete(backups::remove)) .route("/backups/{id}/download", get(backups::download)) @@ -186,7 +188,7 @@ pub fn router(state: AppState) -> Router { .route(env!("APP_CSS_ROUTE"), get(stylesheet)) .route("/favicon.ico", get(favicon)) .fallback(index) - .layer(DefaultBodyLimit::max(1024 * 1024)) + .layer(DefaultBodyLimit::disable()) .layer(middleware::from_fn(bounded_request_body)) .layer(middleware::from_fn(security_headers)) .layer(CompressionLayer::new()) @@ -196,8 +198,8 @@ pub fn router(state: AppState) -> Router { } async fn collect_request_body(body: Body, deadline: Duration) -> ApiResult { - // All input endpoints accept small JSON/form bodies. Device uploads use - // already stored backups, so there is no streaming HTTP upload to interrupt. + // JSON/form endpoints are intentionally small. The dedicated device-file upload + // route bypasses this collector and is streamed with its own 512 MiB limit. match tokio::time::timeout(deadline, axum::body::to_bytes(body, 1024 * 1024)).await { Ok(Ok(bytes)) => Ok(bytes), Ok(Err(error)) @@ -219,6 +221,10 @@ async fn collect_request_body(body: Body, deadline: Duration) -> ApiResult Response { + let path = request.uri().path(); + if path.ends_with("/files/upload") && path.contains("/routers/") { + return next.run(request).await; + } let (parts, body) = request.into_parts(); match collect_request_body(body, Duration::from_secs(15)).await { Ok(bytes) => { diff --git a/src/backup.rs b/src/backup.rs index 315ff4a..8b14284 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -1,4 +1,3 @@ -//! Backup lifecycle with device-level exclusion, bounded transfers and atomic files. use crate::{ AppState, config::{create_private_dir, protect_file}, @@ -108,8 +107,6 @@ fn resolve_path_in(data_dir: &Path, backup: &BackupRecord) -> Result { Ok(canonical) } -/// Stable paths use the database device ID, so renaming a device never moves -/// its backup directory. Export is the API value; exports is the disk folder. pub(crate) fn layout_folder(kind: &str, router_id: i64) -> Result { ensure!(router_id > 0, "Invalid backup device ID"); let kind = match kind { @@ -207,8 +204,6 @@ pub(crate) fn unused_layout_path(data_dir: &Path, backup: &BackupRecord) -> Resu ensure!(backup.id > 0, "Invalid backup ID"); let folder = layout_folder(&backup.backup_type, backup.router_id)?; let directory = layout_directory(data_dir, &backup.backup_type, backup.router_id)?; - // Keep the metadata name intact, including for old filesystem-limit names. - // Only the physical filename needs a bounded collision-resistant prefix. let name = if backup.file_name.len() <= 200 { backup.file_name.clone() } else { @@ -309,7 +304,6 @@ pub async fn run(state: &AppState, router: &RouterRecord, kind: &str) -> Result< let state = state.clone(); let router = router.clone(); let kind = kind.to_owned(); - // This owned task holds the lock even if the requesting HTTP connection closes. let workers = state.workers.clone(); workers .spawn(async move { @@ -484,6 +478,42 @@ pub async fn run_all(state: &AppState, owner: Option, kind: &str) -> Result )) } +pub async fn upload_local_file( + state: &AppState, + router: &RouterRecord, + source: File, + file_name: &str, +) -> Result<()> { + ensure!( + router.device_type == "routeros", + "File upload is supported only for RouterOS" + ); + let guard = DeviceGuard::acquire(state, router.id)?; + let state = state.clone(); + let router = router.clone(); + let name = file_name.to_string(); + let workers = state.workers.clone(); + workers + .spawn(async move { + let _guard = guard; + let _permit = state.operations.acquire().await?; + let addresses = routeros::resolve_addresses(&state.config, &router).await?; + let settings = state.db.get_settings()?; + let config = state.config.clone(); + let target = router.clone(); + tokio::task::spawn_blocking(move || { + routeros::upload_sync(&config, &target, &settings, source, &name, addresses) + }) + .await??; + state.db.log(&format!( + "File upload OK for device {} (file transfer only)", + router.name + ))?; + Ok(()) + }) + .await? +} + pub async fn upload(state: &AppState, router: &RouterRecord, backup: &BackupRecord) -> Result<()> { ensure!( router.device_type == "routeros", @@ -540,7 +570,6 @@ pub fn delete(state: &AppState, backup: &BackupRecord) -> Result<()> { let path = match resolve_path(state, backup) { Ok(path) => Some(path), Err(error) => { - // Missing files may have their stale metadata removed; unsafe paths must never be touched. if error .downcast_ref::() .is_some_and(|cause| cause.kind() == std::io::ErrorKind::NotFound) @@ -577,8 +606,6 @@ pub fn delete(state: &AppState, backup: &BackupRecord) -> Result<()> { fn restore_deleted_file(tombstone: &mut tempfile::TempPath, original: &Path) -> Result<()> { if let Err(error) = fs::rename(&*tombstone, original) { - // A failed DB delete must never cause TempPath::drop to erase the only - // active copy when its rollback rename also fails. tombstone.disable_cleanup(true); bail!( "Could not restore backup after failed metadata deletion; recovery file retained at {}: {error}", diff --git a/src/db.rs b/src/db.rs index 6b51202..330da81 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,3 @@ -//! Current-schema SQLite storage and explicit, copy-only Python installation import. use anyhow::{Context, Result, bail}; use fs2::FileExt; use rusqlite::{Connection, OpenFlags, OptionalExtension, Row, params}; @@ -22,8 +21,6 @@ pub struct Db { connection: Arc>, } -/// Process-lifetime locks shared by serving and importing. The database lock -/// also covers installations that point different data directories at one DB. pub struct StorageLock { _files: Vec, } @@ -65,8 +62,6 @@ pub fn lock_storage(config: &Config) -> Result { } impl Db { - /// A new file receives the current schema. Existing files are inspected - /// read-only before any writable connection is opened; no upgrades occur. pub fn open(config: &Config) -> Result { let mut normalized = config.clone(); normalize_storage_paths(&mut normalized)?; @@ -92,7 +87,7 @@ impl Db { ) .context("Cannot open current SQLite database")?; conn.busy_timeout(Duration::from_secs(15))?; - // Recheck after reopening, before persistent pragmas or permissions. + migrate_supported_schema(&conn).context(INCOMPATIBLE_DATABASE)?; validate_current_schema(&conn).context(INCOMPATIBLE_DATABASE)?; protect_file(&config.database_path)?; conn.pragma_update(None, "foreign_keys", true)?; @@ -203,13 +198,14 @@ impl Db { } pub fn save_settings(&self, settings: &Settings) -> Result<()> { self.with_conn(|conn| { - let changed = conn.execute("UPDATE global_settings SET backup_retention_days=?1,log_retention_days=?2,export_cron=?3,binary_cron=?4,retention_cron=?5,enable_auto_export=?6,connection_test_interval_minutes=?7,global_ssh_key=?8,default_switchos_username=?9,default_switchos_password=?10,pushover_token=?11,pushover_userkey=?12,notify_failures_only=?13,smtp_host=?14,smtp_port=?15,smtp_login=?16,smtp_password=?17,smtp_notifications_enabled=?18,recipient_email=?19 WHERE id=?20", params![ + let changed = conn.execute("UPDATE global_settings SET backup_retention_days=?1,log_retention_days=?2,export_cron=?3,binary_cron=?4,retention_cron=?5,enable_auto_export=?6,connection_test_interval_minutes=?7,global_ssh_key=?8,default_routeros_username=?9,default_routeros_password=?10,default_switchos_username=?11,default_switchos_password=?12,pushover_token=?13,pushover_userkey=?14,notify_failures_only=?15,smtp_host=?16,smtp_port=?17,smtp_login=?18,smtp_password=?19,smtp_notifications_enabled=?20,recipient_email=?21 WHERE id=?22", params![ settings.backup_retention_days, settings.log_retention_days, settings.export_cron, settings.binary_cron, settings.retention_cron, settings.enable_auto_export, settings.connection_test_interval_minutes, - settings.global_ssh_key, settings.default_switchos_username, settings.default_switchos_password, - settings.pushover_token, settings.pushover_userkey, settings.notify_failures_only, settings.smtp_host, - settings.smtp_port, settings.smtp_login, settings.smtp_password, settings.smtp_notifications_enabled, - settings.recipient_email, settings.id])?; + settings.global_ssh_key, settings.default_routeros_username, settings.default_routeros_password, + settings.default_switchos_username, settings.default_switchos_password, settings.pushover_token, + settings.pushover_userkey, settings.notify_failures_only, settings.smtp_host, settings.smtp_port, + settings.smtp_login, settings.smtp_password, settings.smtp_notifications_enabled, settings.recipient_email, + settings.id])?; if changed != 1 { bail!("Settings record no longer exists"); } Ok(()) }) @@ -217,7 +213,9 @@ impl Db { } const SCHEMA: &str = include_str!("schema.sql"); -const INCOMPATIBLE_DATABASE: &str = "Existing database is not the current Rust schema. Startup never upgrades data. Use a fresh DATA_DIR/DATABASE_PATH, or explicitly run import-python --source /path/to/python-app into a new destination"; +const MIGRATION_V1_TO_V2: &str = include_str!("../migrations/004_routeros_default_credentials.sql"); +const CURRENT_SCHEMA_VERSION: i64 = 2; +const INCOMPATIBLE_DATABASE: &str = "Existing database is neither the current schema nor a supported Rust schema that can be migrated automatically. Python/TypeScript installations still require import-python --source /path/to/python-app into a new destination"; const DATA_TABLES: [&str; 5] = [ "users", "routers", @@ -236,7 +234,6 @@ struct Column { } fn columns(conn: &Connection, table: &str) -> Result> { - // Callers pass only table names from this module's fixed schema. let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?; Ok(statement .query_map([], |row| { @@ -259,8 +256,6 @@ fn table_exists(conn: &Connection, table: &str) -> Result { )?) } -/// Inspect a selected existing database before storage locks or secrets are -/// created. SQLite sees only a private copy, including committed WAL content. pub fn preflight_existing(config: &Config) -> Result<()> { match fs::symlink_metadata(&config.database_path) { Ok(metadata) => { @@ -275,7 +270,7 @@ pub fn preflight_existing(config: &Config) -> Result<()> { &snapshot, OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_NOFOLLOW, )?; - validate_current_schema(&conn).context(INCOMPATIBLE_DATABASE) + validate_supported_startup_schema(&conn).context(INCOMPATIBLE_DATABASE) } Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(error) => Err(error).context("Cannot inspect database path"), @@ -313,8 +308,6 @@ fn future_path(path: &Path) -> Result { Ok(resolved) } -/// Normalize paths before any mkdir or lock call, without touching the selected -/// database leaf (which must still be checked for symbolic links). pub fn normalize_storage_paths(config: &mut Config) -> Result<()> { config.data_dir = future_path(&config.data_dir)?; let parent = config @@ -416,24 +409,19 @@ fn check_integrity(conn: &Connection) -> Result<()> { Ok(()) } -fn validate_current_schema(conn: &Connection) -> Result<()> { +fn schema_marker(conn: &Connection) -> Result<(String, i64, i64)> { anyhow::ensure!( table_exists(conn, "schema_info")?, "Current schema marker is missing" ); - let marker: (String,i64,i64) = conn.query_row("SELECT application,schema_version,(SELECT COUNT(*) FROM schema_info) FROM schema_info WHERE id=1", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?; - anyhow::ensure!( - marker == ("mikrotik-backup".into(), 1, 1), - "Unsupported application schema version" - ); - let expected = Connection::open_in_memory()?; - expected.execute_batch(SCHEMA)?; - for table in std::iter::once("schema_info").chain(DATA_TABLES) { - anyhow::ensure!( - table_exists(conn, table)? && columns(conn, table)? == columns(&expected, table)?, - "Current schema table {table} is missing or incompatible" - ); - } + Ok(conn.query_row( + "SELECT application,schema_version,(SELECT COUNT(*) FROM schema_info) FROM schema_info WHERE id=1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + )?) +} + +fn validate_data_integrity(conn: &Connection) -> Result<()> { check_integrity(conn)?; let users: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; let settings: i64 = @@ -448,6 +436,114 @@ fn validate_current_schema(conn: &Connection) -> Result<()> { Ok(()) } +fn expected_schema() -> Result { + let expected = Connection::open_in_memory()?; + expected.execute_batch(SCHEMA)?; + Ok(expected) +} + +fn validate_current_schema(conn: &Connection) -> Result<()> { + let marker = schema_marker(conn)?; + anyhow::ensure!( + marker == ("mikrotik-backup".into(), CURRENT_SCHEMA_VERSION, 1), + "Unsupported application schema version" + ); + let expected = expected_schema()?; + for table in std::iter::once("schema_info").chain(DATA_TABLES) { + anyhow::ensure!( + table_exists(conn, table)? && columns(conn, table)? == columns(&expected, table)?, + "Current schema table {table} is missing or incompatible" + ); + } + validate_data_integrity(conn) +} + +fn validate_migratable_v1_schema(conn: &Connection) -> Result<()> { + let marker = schema_marker(conn)?; + anyhow::ensure!( + marker == ("mikrotik-backup".into(), 1, 1), + "Unsupported application schema version" + ); + let expected = expected_schema()?; + for table in std::iter::once("schema_info").chain(DATA_TABLES) { + anyhow::ensure!( + table_exists(conn, table)?, + "Schema table {table} is missing" + ); + let actual_columns = columns(conn, table)?; + let expected_columns = columns(&expected, table)?; + if table == "global_settings" { + let legacy_columns = &expected_columns[..expected_columns.len() - 2]; + anyhow::ensure!( + actual_columns.as_slice() == legacy_columns + || actual_columns.as_slice() == expected_columns.as_slice(), + "Rust schema v1 global_settings table is incompatible" + ); + } else { + anyhow::ensure!( + actual_columns == expected_columns, + "Rust schema v1 table {table} is incompatible" + ); + } + } + validate_data_integrity(conn) +} + +fn validate_supported_startup_schema(conn: &Connection) -> Result<()> { + let marker = schema_marker(conn)?; + anyhow::ensure!( + marker.0 == "mikrotik-backup" && marker.2 == 1, + "Unsupported application schema marker" + ); + match marker.1 { + CURRENT_SCHEMA_VERSION => validate_current_schema(conn), + 1 => validate_migratable_v1_schema(conn), + version => bail!("Unsupported application schema version {version}"), + } +} + +fn migrate_supported_schema(conn: &Connection) -> Result<()> { + let marker = schema_marker(conn)?; + if marker == ("mikrotik-backup".into(), CURRENT_SCHEMA_VERSION, 1) { + return Ok(()); + } + validate_migratable_v1_schema(conn)?; + + let expected = expected_schema()?; + let current_settings = columns(conn, "global_settings")?; + let expected_settings = columns(&expected, "global_settings")?; + let legacy_settings = &expected_settings[..expected_settings.len() - 2]; + + let transaction = conn.unchecked_transaction()?; + if current_settings.as_slice() == legacy_settings { + transaction.execute_batch(MIGRATION_V1_TO_V2)?; + } else { + let changed = transaction.execute( + "UPDATE schema_info SET schema_version=?1 WHERE id=1 AND application='mikrotik-backup' AND schema_version=1", + [CURRENT_SCHEMA_VERSION], + )?; + anyhow::ensure!( + changed == 1, + "Schema version changed while applying migration" + ); + } + let migrated_version: i64 = transaction.query_row( + "SELECT schema_version FROM schema_info WHERE id=1", + [], + |row| row.get(0), + )?; + anyhow::ensure!( + migrated_version == CURRENT_SCHEMA_VERSION, + "Schema migration did not advance to the current version" + ); + transaction.commit()?; + tracing::info!( + schema_version = CURRENT_SCHEMA_VERSION, + "SQLite schema migrated" + ); + Ok(()) +} + fn initialize_database(config: &Config) -> Result<()> { let parent = config .database_path @@ -576,6 +672,8 @@ fn settings_row(row: &Row<'_>) -> rusqlite::Result { .get::<_, Option>("connection_test_interval_minutes")? .unwrap_or(0), global_ssh_key: row.get("global_ssh_key")?, + default_routeros_username: row.get("default_routeros_username")?, + default_routeros_password: row.get("default_routeros_password")?, default_switchos_username: row.get("default_switchos_username")?, default_switchos_password: row.get("default_switchos_password")?, pushover_token: row.get("pushover_token")?, @@ -925,8 +1023,6 @@ fn destination_is_separate(config: &Config, source: &Path, selected: &PythonSour Ok(()) } -/// Explicit import only. Source options are relative to the selected application -/// directory; the runtime configuration determines the separate new target. pub fn import_python( config: &Config, source: &Path, @@ -958,8 +1054,6 @@ pub fn import_python( ); destination_is_separate(config, &source, &selected)?; let stage = tempfile::tempdir_in(&config.data_dir)?; - // Never open the original with SQLite: even read-only WAL connections can - // modify shared-memory sidecars. Reconstruct only the private stopped copy. let source_snapshot = stage.path().join("python-source.sqlite"); snapshot_stopped_database(&selected.database, &source_snapshot)?; let source_copy = Connection::open_with_flags( @@ -1075,9 +1169,6 @@ pub fn import_python( fn publish_copy_no_clobber(source: &Path, destination: &Path, published: &mut bool) -> Result<()> { let parent = destination.parent().context("Destination has no parent")?; create_private_dir(parent)?; - // Stage beside the destination so publication is atomic even if a custom - // DATABASE_PATH lives on another filesystem. Never overwrite an existing - // file or symlink, including one created after the earlier validation. let mut target = tempfile::NamedTempFile::new_in(parent)?; std::io::copy(&mut fs::File::open(source)?, &mut target)?; target.as_file().sync_all()?; @@ -1085,8 +1176,6 @@ fn publish_copy_no_clobber(source: &Path, destination: &Path, published: &mut bo target .persist_noclobber(destination) .map_err(|error| error.error)?; - // From this point the database may reference installed backups. A later - // directory-sync failure must not make the caller remove those files. *published = true; crate::backup::sync_directory(parent)?; Ok(()) @@ -1178,6 +1267,36 @@ mod tests { } } + fn rust_v1_fixture(path: &Path) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let conn = Connection::open(path).unwrap(); + let v1_schema = SCHEMA + .replace( + "VALUES (1, 'mikrotik-backup', 2);", + "VALUES (1, 'mikrotik-backup', 1);", + ) + .replace( + ",\n default_routeros_username TEXT,\n default_routeros_password TEXT", + "", + ); + conn.execute_batch(&v1_schema).unwrap(); + conn.execute( + "INSERT INTO users(id,username,password_hash) VALUES(1,'admin','fixture-hash')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO global_settings(id,default_switchos_username,default_switchos_password) VALUES(1,'switch-admin','switch-secret')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO routers(id,owner_id,name,host) VALUES(23,1,'existing','192.0.2.1')", + [], + ) + .unwrap(); + } + fn local_source(root: &Path) -> (PathBuf, PathBuf, PathBuf) { let source = root.join("python-app"); let storage = source.join("storage"); @@ -1194,7 +1313,7 @@ mod tests { } #[test] - fn fresh_initialization_and_current_schema_restart_do_not_upgrade_or_relocate() { + fn fresh_initialization_and_current_schema_restart_do_not_relocate() { let dir = tempfile::tempdir().unwrap(); let config = config(dir.path()); let db = Db::open(&config).unwrap(); @@ -1225,6 +1344,85 @@ mod tests { assert_eq!(directory_names(dir.path()), files_before); } + #[test] + fn startup_migrates_rust_v1_schema_and_preserves_data() { + let dir = tempfile::tempdir().unwrap(); + let config = config(dir.path()); + rust_v1_fixture(&config.database_path); + let before_preflight = fs::read(&config.database_path).unwrap(); + + preflight_existing(&config).unwrap(); + assert_eq!(fs::read(&config.database_path).unwrap(), before_preflight); + + let db = Db::open(&config).unwrap(); + assert_eq!(db.router(23).unwrap().unwrap().name, "existing"); + let settings = db.get_settings().unwrap(); + assert_eq!( + settings.default_switchos_username.as_deref(), + Some("switch-admin") + ); + assert_eq!( + settings.default_switchos_password.as_deref(), + Some("switch-secret") + ); + assert_eq!(settings.default_routeros_username, None); + assert_eq!(settings.default_routeros_password, None); + db.with_conn(|conn| { + let version: i64 = conn.query_row( + "SELECT schema_version FROM schema_info WHERE id=1", + [], + |row| row.get(0), + )?; + assert_eq!(version, CURRENT_SCHEMA_VERSION); + let expected = expected_schema()?; + assert_eq!( + columns(conn, "global_settings")?, + columns(&expected, "global_settings")? + ); + Ok(()) + }) + .unwrap(); + } + + #[test] + fn startup_accepts_previously_manual_routeros_columns_and_only_advances_marker() { + let dir = tempfile::tempdir().unwrap(); + let config = config(dir.path()); + rust_v1_fixture(&config.database_path); + { + let conn = Connection::open(&config.database_path).unwrap(); + conn.execute_batch( + "ALTER TABLE global_settings ADD COLUMN default_routeros_username TEXT; + ALTER TABLE global_settings ADD COLUMN default_routeros_password TEXT; + CREATE TABLE rust_schema_migrations(version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP); + INSERT INTO rust_schema_migrations(version) VALUES(4); + UPDATE global_settings SET default_routeros_username='router-admin', default_routeros_password='router-secret' WHERE id=1;", + ) + .unwrap(); + } + + let db = Db::open(&config).unwrap(); + let settings = db.get_settings().unwrap(); + assert_eq!( + settings.default_routeros_username.as_deref(), + Some("router-admin") + ); + assert_eq!( + settings.default_routeros_password.as_deref(), + Some("router-secret") + ); + db.with_conn(|conn| { + let version: i64 = conn.query_row( + "SELECT schema_version FROM schema_info WHERE id=1", + [], + |row| row.get(0), + )?; + assert_eq!(version, CURRENT_SCHEMA_VERSION); + Ok(()) + }) + .unwrap(); + } + #[test] fn startup_rejects_python_database_without_changing_bytes_permissions_or_files() { let dir = tempfile::tempdir().unwrap(); @@ -1238,7 +1436,7 @@ mod tests { Ok(_) => panic!("legacy database was accepted"), Err(error) => error, }; - assert!(error.to_string().contains("Startup never upgrades")); + assert!(error.to_string().contains("supported Rust schema")); assert!(error.to_string().contains("import-python")); assert_eq!(fs::read(&config.database_path).unwrap(), before); assert_eq!( diff --git a/src/diff.rs b/src/diff.rs index 018ab1e..767974a 100644 --- a/src/diff.rs +++ b/src/diff.rs @@ -1,4 +1,3 @@ -//! Text-only side-by-side and unified diffs; all HTML output is escaped. use crate::{AppState, backup, models::BackupRecord}; use anyhow::{Result, ensure}; use serde_json::{Value, json}; @@ -14,19 +13,52 @@ pub fn escape(value: &str) -> String { .replace('\'', "'") } +fn standalone_diff_html(diff_text: &str, left_name: &str, right_name: &str) -> String { + let mut html = format!( + "Diff: {} ↔ {}

Configuration diff

{} ↔ {}
",
+        escape(left_name),
+        escape(right_name),
+        escape(left_name),
+        escape(right_name)
+    );
+    if diff_text.is_empty() {
+        html.push_str("No differences.");
+    } else {
+        for line in diff_text.lines() {
+            let class = if line.starts_with("--- ") || line.starts_with("+++ ") {
+                "line head"
+            } else if line.starts_with("@@ ") {
+                "line hunk"
+            } else if line.starts_with('+') {
+                "line add"
+            } else if line.starts_with('-') {
+                "line del"
+            } else {
+                "line"
+            };
+            html.push_str(&format!(
+                "{}\n",
+                class,
+                escape(line)
+            ));
+        }
+    }
+    html.push_str("
"); + html +} + pub fn compare(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result { compare_files(state, left, right, true, true) } -/// Compatibility payload used by the public API and tests: unified text is -/// retained, while the standalone HTML document is omitted. -pub fn compare_browser(state: &AppState, left: &BackupRecord, right: &BackupRecord) -> Result { +pub fn compare_browser( + state: &AppState, + left: &BackupRecord, + right: &BackupRecord, +) -> Result { compare_files(state, left, right, true, false) } -/// Compact browser payload used by the web UI. The browser renders unified -/// rows from structured data, so sending another complete textual diff would -/// duplicate large configurations in memory and over the wire. pub fn compare_browser_compact( state: &AppState, left: &BackupRecord, @@ -146,31 +178,28 @@ fn compare_text_options( "lines": rows, "smart": smart_analysis(left, right) }); - if include_text { - result["diff_text"] = json!(unified_diff( + let unified = (include_text || include_html).then(|| { + unified_diff( &operations, &left_lines, &right_lines, left_name, right_name, - )); + ) + }); + if include_text { + result["diff_text"] = json!(unified.as_deref().unwrap_or("")); } if include_html { - let mut html = format!( - "Configuration comparison

Configuration comparison

", - escape(left_name), - escape(right_name) - ); - for row in result["lines"].as_array().expect("diff lines are an array") { - html.push_str(&format!("",row["type"].as_str().unwrap_or("context"),row["left_number"].as_i64().map(|i|i.to_string()).unwrap_or_default(),escape(row["left_text"].as_str().unwrap_or("")),row["right_number"].as_i64().map(|i|i.to_string()).unwrap_or_default(),escape(row["right_text"].as_str().unwrap_or("")))); - } - html.push_str("
Line{}Line{}
{}{}{}{}
"); - result["diff_html"] = json!(html); + result["diff_html"] = json!(standalone_diff_html( + unified.as_deref().unwrap_or(""), + left_name, + right_name, + )); } Ok(result) } - #[derive(Clone, Debug)] struct SmartCommand { section: String, @@ -192,11 +221,20 @@ fn smart_analysis(left: &str, right: &str) -> Value { let mut attention_sections = 0usize; for section in names { - let left_items: Vec<_> = left_commands.iter().filter(|item| item.section == section).collect(); - let right_items: Vec<_> = right_commands.iter().filter(|item| item.section == section).collect(); + let left_items: Vec<_> = left_commands + .iter() + .filter(|item| item.section == section) + .collect(); + let right_items: Vec<_> = right_commands + .iter() + .filter(|item| item.section == section) + .collect(); let mut right_exact: HashMap> = HashMap::new(); for (index, item) in right_items.iter().enumerate() { - right_exact.entry(item.normalized.clone()).or_default().push(index); + right_exact + .entry(item.normalized.clone()) + .or_default() + .push(index); } let mut right_used = vec![false; right_items.len()]; let mut left_unmatched = Vec::new(); @@ -219,10 +257,16 @@ fn smart_analysis(left: &str, right: &str) -> Value { let mut left_by_key: HashMap> = HashMap::new(); let mut right_by_key: HashMap> = HashMap::new(); for item in &left_unmatched { - left_by_key.entry(item.identity.clone()).or_default().push(*item); + left_by_key + .entry(item.identity.clone()) + .or_default() + .push(*item); } for item in &right_unmatched { - right_by_key.entry(item.identity.clone()).or_default().push(*item); + right_by_key + .entry(item.identity.clone()) + .or_default() + .push(*item); } let mut paired_left = BTreeSet::new(); @@ -230,7 +274,9 @@ fn smart_analysis(left: &str, right: &str) -> Value { let mut details = Vec::new(); let mut modified = 0usize; for (key, old_items) in &left_by_key { - let Some(new_items) = right_by_key.get(key) else { continue }; + let Some(new_items) = right_by_key.get(key) else { + continue; + }; if old_items.len() != 1 || new_items.len() != 1 || key.starts_with("raw:") { continue; } @@ -264,7 +310,10 @@ fn smart_analysis(left: &str, right: &str) -> Value { if added == 0 && removed == 0 && modified == 0 { continue; } - for item in removed_items.iter().take(12usize.saturating_sub(details.len())) { + for item in removed_items + .iter() + .take(12usize.saturating_sub(details.len())) + { details.push(json!({ "kind": "removed", "verb": item.verb.as_str(), @@ -272,7 +321,10 @@ fn smart_analysis(left: &str, right: &str) -> Value { "before": truncate_command(&item.normalized) })); } - for item in added_items.iter().take(12usize.saturating_sub(details.len())) { + for item in added_items + .iter() + .take(12usize.saturating_sub(details.len())) + { details.push(json!({ "kind": "added", "verb": item.verb.as_str(), @@ -359,7 +411,13 @@ fn parse_rsc_commands(text: &str) -> (Vec, usize) { let verb = tokens.first().cloned().unwrap_or_default(); let fields = assignment_fields(&tokens); let identity = semantic_identity(&verb, &tokens, &fields, &normalized); - commands.push(SmartCommand { section, verb, normalized, identity, fields }); + commands.push(SmartCommand { + section, + verb, + normalized, + identity, + fields, + }); } (commands, ignored) } @@ -440,7 +498,9 @@ fn shellish_tokens(value: &str) -> Vec { fn assignment_fields(tokens: &[String]) -> BTreeMap { let mut fields = BTreeMap::new(); for token in tokens.iter().skip(1) { - let Some((key, value)) = token.split_once('=') else { continue }; + let Some((key, value)) = token.split_once('=') else { + continue; + }; if !key.is_empty() { fields.insert(key.to_string(), value.to_string()); } @@ -568,7 +628,10 @@ fn classify_section(section: &str) -> (&'static str, &'static str) { { return ("medium", "network"); } - if name.starts_with("/ip dns") || name.starts_with("/snmp") || name.starts_with("/system logging") { + if name.starts_with("/ip dns") + || name.starts_with("/snmp") + || name.starts_with("/system logging") + { return ("medium", "services"); } ("low", "other") @@ -846,6 +909,9 @@ mod tests { assert!(!html.contains("