fix(updates): установка пересобранных релизов в рамках одного тега

opkg считает пакет уже установленным при равенстве версий, поэтому
пересборка под тем же тегом молча не применялась: задание завершалось
успехом, билд оставался старым. Добавлен --force-reinstall в
pkg_install_files_command (opkg) и в install.sh; apk не тронут, он
всегда пишет переданный файл.

Детект пересборки больше не зависит только от SHA, которого нет в
релизах старше f974e4a0: добавлен отпечаток билда по published_at и
метаданным ipk-ассета, решение вынесено в tachyon_build_differs с
префиксным сравнением SHA разной длины. Отпечаток установленного
билда пишется в state-файл и попадает в extra.

Telegram уведомляет о пересборках: check_notified_updates пропускает
outdated_same_release, дедуп идёт по версии и SHA, текст показывает
короткие SHA вместо пары одинаковых версий.

Кэш проверки обновлений сохраняет current_sha из результата действия
вместо безусловного latest, иначе неудачная установка выглядела как
"актуально".

Фронт пересобран из TS, ручная правка main.js снята. Добавлен
describeSameReleaseBuild с фолбэк-текстом, когда SHA недоступен.
This commit is contained in:
Dushnilin
2026-08-09 16:57:10 +03:00
parent 4c8ee871f1
commit af2d07b056
19 changed files with 3627 additions and 2813 deletions
+126
View File
@@ -0,0 +1,126 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
OpenWrt package for proxy / anti-censorship orchestration (sing-box + zapret v1/v2 + ByeDPI + Telegram bot + self-healing watchdog). Fork of Forkop / Podkop Plus. Ships two packages: `tachyon` (backend, ucode) and `luci-app-tachyon` (web UI).
Backend logic is **ucode** — OpenWrt's native scripting language. Not Lua, not JS. Frontend is TypeScript compiled into LuCI-style JS.
## Commands
### Backend tests
No ucode toolchain on the Windows host. Everything runs in the container built from `tests/Dockerfile.test` (tag `tachyon-ucode:test`, ucode v0.0.20250529, built with `-DUBUS_SUPPORT=OFF -DUCI_SUPPORT=OFF -DULOOP_SUPPORT=OFF`).
Build the image once, then run through PowerShell (Git-Bash mangles the `-v G:/tachyon:/repo` path) and append `exit $LASTEXITCODE` so an inner failure actually surfaces:
```powershell
docker build -t tachyon-ucode:test -f tests/Dockerfile.test tests
docker run --rm -v 'G:/tachyon:/repo' -w /repo tachyon-ucode:test bash -c 'bash tests/run_all.sh'; exit $LASTEXITCODE
docker run --rm -v 'G:/tachyon:/repo' -w /repo tachyon-ucode:test bash -c 'bash tests/event_bus.sh'; exit $LASTEXITCODE
```
- `tests/run_all.sh` = syntax lint + every `tests/*.sh` serially. Auto-discovers; new tests need no registration.
- `tests/ucode_syntax_lint.sh` = compile check only (`ucode -c` and `ucode -S -c` over every `.uc`).
- `docker_e2e_test.sh` and `container_entrypoint.sh` are excluded from `run_all.sh` — they need Docker themselves.
- CI (`.github/workflows/backend-ci.yml`) runs the same set with `xargs -P4`.
Container gotchas: `ucode -e '...'` combined with `-L` aborts with `double free or corruption` — write snippets to a real `.uc` file. No `python3`; use `sed`/`perl`. `mktemp --suffix` unsupported.
### Frontend
```bash
cd fe-app-tachyon
npm install # npm, not yarn (package-lock.json is authoritative)
npm run build # tsc --noEmit, then tsup
npm test # vitest
npm run ci # format + lint + test --run + build
npm run locales:actualize # extract calls -> pot -> ru po -> distribute
```
### Package build
```bash
bash build.sh <x.y.z> [output-dir]
```
Requires Linux + apt (auto-installs deps); on WSL the repo is copied to the native FS first. Version must be semver or the script exits. Output lands in `dist/release-final/` as `.ipk` + `.apk`.
### Lint
```bash
shellcheck --severity=error build.sh install.sh tachyon/files/etc/init.d/* luci-app-tachyon/root/etc/uci-defaults/* tests/**/*.sh
cd fe-app-tachyon && npm run lint
```
## Architecture
### Backend: ucode module tree
All modules live under `tachyon/files/usr/lib/`, installed to `/usr/lib/tachyon/`. Loaded via `ucode -L <lib_dir>` with dotted requires that mirror the directory layout: `require("core.uci")` → `core/uci.uc`, `require("providers.zapret.runtime")` → `providers/zapret/runtime.uc`.
Layers, roughly bottom-up:
- `core/` — no domain knowledge. `common` (string/shell helpers), `uci` (config read/write), `helpers` (tag naming, file predicates), `ip`, `url`, `constants`, `packages`, `events` (the bus).
- `config/` — UCI schema layer: `validator`, `migration` (forkop/podkop → tachyon), `rule`, `domain`, `connections`.
- `singbox/` — config generation. `generator` orchestrates, with `generator_outbounds` / `generator_routes` / `route` / `dns*` / `country` / `priority` as parts.
- `providers/` — one subtree per DPI-bypass engine (`zapret`, `zapret2`, `nfqueue`, `byedpi`), each with the same `runtime` / `validator` / `check` triad, plus shared `rules.uc` and `status.uc`.
- `service/` — lifecycle and orchestration: `initd` (called by `/etc/init.d/tachyon`), `lifecycle`, `reload`, `state`, `api`, `telegram`, `ui`, `watchdog`, `event_controller`, `package`.
- `nft/`, `dns/`, `routing/`, `diagnostics/`, `subscription/`, `components/`, `server/` — focused single-concern modules.
Every module is **both a library and a CLI**. The bottom of each `.uc` file is an `if (mode == "...")` dispatch chain over `ARGV`, which is how the shell tests exercise pure functions:
```bash
ucode -L "$TACHYON_LIB" "$TACHYON_LIB/core/helpers.uc" outbound-tag proxy # -> proxy-out
```
When adding a function you want tested, add a dispatch arm for it — that is the established test seam.
### Event bus, not timers
`core/events.uc` is a domain-agnostic pub/sub bus (per-subscriber cooldown, publish-side dedup, priority ordering, handler error isolation, `clock(true)` monotonic timing so NTP steps at boot don't skew windows). It performs no I/O, which is what makes it unit-testable.
The strict split around it:
- `service/event_controller.uc` **observes only** — push sources (ubus, syslog, honeypot FIFO) and probe tiers (fast 15s / normal adaptive 120–300s / slow 300s) turn system state into facts on the bus. Event names live in an `EV` constant table so a typo is a load-time error.
- `service/watchdog.uc` **repairs only** — subscribes to those facts. Its registrations go through a `subscribe()` wrapper that `die()`s if `bus.on()` rejects the handler (see the hoisting hazard below).
Do not add probing to a healer or repair to the controller.
### Entrypoints
- `/usr/bin/tachyon` (`tachyon/files/usr/bin/tachyon`) — ucode CLI dispatcher. Resolves the module for a subcommand, shells out with `ucode -L`, normalizes the exit status, and restores the dnsmasq failsafe if the loader fails on a lifecycle command.
- `/etc/init.d/tachyon` — thin procd shim; delegates every action to `service/initd.uc`. Deliberately minimal shell.
- Environment overrides everywhere: `TACHYON_LIB`, `TACHYON_CONFIG_NAME`, `TACHYON_BIN`, `TACHYON_SERVICE_INIT`, plus per-module `SB_*` tag overrides. Tests rely on these — keep new constants overridable via `getenv(...) || default`.
### Frontend
`fe-app-tachyon/src/` → tsup → `luci-app-tachyon/htdocs/luci-static/resources/view/tachyon/main.js`. The tsup `onSuccess` hook rewrites the emitted `export { ... }` into `return baseclass.extend({ ... })`, and **throws if that block is absent** — this is intentional LuCI interop, not a bug to fix. Anything the LuCI views need must be re-exported from `src/main.ts`.
`main.js` is generated — never edit it directly. The sibling files in that directory (`dashboard.js`, `settings.js`, `section.js`, …) are hand-written LuCI views that consume `main.js`.
`__COMPILED_VERSION_VARIABLE__` in `src/constants.ts` is a literal placeholder sed-replaced at build time. Leave it alone.
## Landmines
**ucode does not hoist function declarations.** Not at file scope, not nested. A name used before its declaration resolves as a global and comes back `null`. It's lexical, not timing — a closure created before a sibling `function` is declared never captures it, so deferring the call doesn't help. Both `ucode -c` and `ucode -S -c` accept the broken file; you get a runtime `Type error: left-hand side is not a function`. Combined with the bus this fails silently: `bus.on()` returns `false` for a non-function handler, so a misordered handler registers nothing, emits nothing, throws nothing, and a repair just quietly stops happening. Guards: `tests/forward_references.sh` (static decl-after-use scan + actually invoking the push handlers) and `tests/watchdog_subscriptions.sh`.
**Contract tests exist and will fail you.** `package_contract.sh` checks `tachyon/Makefile` `DEPENDS` against what the code actually invokes; `shell_inventory.sh` fails if any `.sh` reappears under `tachyon/files/usr/lib/` (the runtime is ucode-only — a shell owner returning is a regression); `config_contract_matrix.sh` pins the UCI schema. Adding a new external binary or config option means updating the matching contract.
**Atomic writes.** JSON configs, subscription cache, diagnostics, runtime state, and UCI backups all go through tmp + `mv`. Persistent staging lives in `/etc/.tachyon/` so it survives reboot. Follow this when writing new state.
**`/etc/config/tachyon` must be `chmod 600` root:root** — it holds the Telegram bot token. postinst/postupgrade re-assert this; so should any code that rewrites the file.
**postupgrade is deliberately violent.** It kills the procd `flock`-waiting processes before starting via `/usr/bin/tachyon start` (which registers with procd directly), because `rc.common`'s `flock -w 1000` plus `retry_start_on_wan_up` retriggers deadlocks across an upgrade. Read the comment in `tachyon/Makefile` before touching it.
**Line endings: LF, enforced by `.gitattributes`.** Never commit CRLF from Windows.
**Legacy migration is a supported path** — configs from `forkop`, `forkop_plus`, and `podkop` are migrated in postinst and `config/migration.uc`. Don't drop those code paths.
## Conventions
- Each shell test is standalone: `set -eo pipefail`, a local `ucode()` wrapper that injects `-L "$TACHYON_LIB"`, `fail()`/`assert_eq()` helpers, exit non-zero on mismatch. No framework, no shared harness beyond `tests/helpers/`.
- Branches: `main` for releases, `rc/**` for release candidates.
- Backend CI only triggers on `tachyon/files/**`, `tachyon/Makefile`, `build.sh`, `tests/**` — a frontend-only change won't run it.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+24 -9
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-03 20:40+0300\n"
"PO-Revision-Date: 2026-08-03 20:40+0300\n"
"POT-Creation-Date: 2026-08-09 16:50+0300\n"
"PO-Revision-Date: 2026-08-09 16:50+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -374,6 +374,9 @@ msgstr "Путь к файлу кэша"
msgid "Cache file path cannot be empty"
msgstr "Путь к файлу кэша не может быть пустым"
msgid "Cancel"
msgstr ""
msgid "Cannot receive checks result"
msgstr "Не удалось получить результаты проверки"
@@ -527,6 +530,9 @@ msgstr "Настройки сервиса Tachyon"
msgid "Configure User-Agent in the item settings"
msgstr "Настройте User-Agent в настройках элемента"
msgid "Conflict"
msgstr ""
msgid "Connect through"
msgstr "Подключаться через"
@@ -1337,6 +1343,9 @@ msgstr "Установить"
msgid "Install another build:"
msgstr "Установить другую сборку:"
msgid "Installed build → Available build"
msgstr "Установленная сборка → Доступная сборка"
msgid "Installed version is newer than release"
msgstr "Установлена версия новее релиза"
@@ -2207,6 +2216,9 @@ msgstr "Reality short ID"
msgid "Reality short ID must contain 1-8 hex digits"
msgstr "Reality short ID должен содержать 1-8 hex-символов"
msgid "Rebuilt release"
msgstr "Пересобранный релиз"
msgid "Reconnect Threshold"
msgstr "Порог переподключений"
@@ -2273,6 +2285,9 @@ msgstr "Magic Header: Ответ (S4)"
msgid "Restart Tachyon"
msgstr "Перезапустить Tachyon"
msgid "Restarts"
msgstr ""
msgid "Resume updates"
msgstr "Продолжить обновление"
@@ -3074,6 +3089,9 @@ msgstr "Неизвестный флаг NFQWS2."
msgid "Unspecified IP address is not allowed"
msgstr "Неуказанный IP-адрес не разрешён"
msgid "Unstable"
msgstr ""
msgid "Unsupported security mode for this protocol"
msgstr "Этот протокол не поддерживает выбранный режим защиты"
@@ -3083,14 +3101,11 @@ msgstr "Обновить"
msgid "Update is available"
msgstr "Доступно обновление"
msgid "Update is available:"
msgstr "Доступна новая версия:"
msgid "Update is available for current release"
msgstr "Доступно обновление текущего релиза"
msgid "Update is available for current release:"
msgstr "Доступно обновление текущего релиза:"
msgid "Update is available:"
msgstr "Доступна новая версия:"
msgid "Update subscriptions"
msgstr "Обновить подписки"
@@ -3122,8 +3137,8 @@ msgstr "URL для проверки доступности сервера"
msgid "URL used to test server latency"
msgstr "URL для тестирования задержки сервера"
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>. Entries are resolved via both dnsmasq and sing-box DNS."
msgstr "URL удалённых файлов хостов для загрузки и применения. Поддерживается стандартный формат hosts: <code>IP domain</code>. Записи разрешаются через dnsmasq и DNS sing-box."
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>."
msgstr ""
msgid "URLTest"
msgstr "URLTest"
@@ -651,10 +651,7 @@ export const TachyonShellMethods = {
const installedVersion = await readTachyonVersion();
const targetVersion = expectedLatestVersion || installedVersion;
if (
targetVersion &&
installedVersion === targetVersion
) {
if (targetVersion && installedVersion === targetVersion) {
if (!selfUpdateVersionMatchedAt) {
selfUpdateVersionMatchedAt = Date.now();
}
@@ -26,6 +26,7 @@ import {
shouldRefreshComponentStateBeforeRender,
shouldResetCheckResultsOnMount,
} from './checkResultLifecycle';
import { describeSameReleaseBuild } from './sameReleaseBuild';
import { TachyonShellMethods } from '../../methods';
import {
logger,
@@ -1106,28 +1107,20 @@ function renderComponentCard(card: ComponentCard) {
} else if (checkResult.status === 'outdated_same_release') {
labelText = _('Update is available for current release');
// Show short commit SHAs so user understands it's the same version, different build
const currentSha = checkResult.current_sha
? checkResult.current_sha.substring(0, 8)
: '';
const latestSha = checkResult.latest_sha
? checkResult.latest_sha.substring(0, 8)
: '';
if (currentSha || latestSha) {
const shaText =
currentSha && latestSha
? `${currentSha} → ${latestSha}`
: currentSha || latestSha;
latestValueNodes.push(
E(
'span',
{
class: 'tachyon_updates-page__component__sha-info',
title: _('Installed build → Available build'),
},
shaText,
),
);
}
const build = describeSameReleaseBuild({
currentSha: checkResult.current_sha,
latestSha: checkResult.latest_sha,
});
latestValueNodes.push(
E(
'span',
{
class: 'tachyon_updates-page__component__sha-info',
title: _('Installed build → Available build'),
},
build.kind === 'sha' ? build.text : _('Rebuilt release'),
),
);
} else if (checkResult.status === 'latest') {
labelText = _('Latest version is installed');
} else if (checkResult.status === 'dev') {
@@ -0,0 +1,28 @@
/**
* A release tag can be rebuilt: the same version number then covers more than
* one build. What the user needs to see is *which* build, and the answer comes
* from whichever identity the release carries — a commit SHA when the build
* stamps one, nothing at all for releases published before it did.
*/
export function describeSameReleaseBuild({
currentSha,
latestSha,
}: {
currentSha?: string | null;
latestSha?: string | null;
}) {
const current = (currentSha || '').substring(0, 8);
const latest = (latestSha || '').substring(0, 8);
if (current && latest) {
return { kind: 'sha' as const, text: `${current} → ${latest}` };
}
if (current || latest) {
return { kind: 'sha' as const, text: current || latest };
}
// Without this the label claims an update is available and shows nothing to
// back the claim up.
return { kind: 'fallback' as const, text: '' };
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { describeSameReleaseBuild } from '../sameReleaseBuild';
describe('same-release build description', () => {
it('shows a truncated transition when both builds are identified', () => {
expect(
describeSameReleaseBuild({
currentSha: 'abc1234def5678',
latestSha: 'ffff9999aaaa',
}),
).toEqual({ kind: 'sha', text: 'abc1234d → ffff9999' });
});
it('shows the single known build when only one side has a sha', () => {
expect(
describeSameReleaseBuild({ currentSha: 'abc1234def', latestSha: '' }),
).toEqual({ kind: 'sha', text: 'abc1234d' });
expect(
describeSameReleaseBuild({ currentSha: null, latestSha: 'ffff9999aaaa' }),
).toEqual({ kind: 'sha', text: 'ffff9999' });
});
it('falls back when the release carries no sha at all', () => {
// Releases published before the build stamped a commit SHA are told apart by
// publish timestamps and asset size, so there is nothing to print here — but
// the label still has to say something, or it claims an update with no
// evidence behind it.
expect(describeSameReleaseBuild({})).toEqual({
kind: 'fallback',
text: '',
});
expect(
describeSameReleaseBuild({ currentSha: '', latestSha: null }),
).toEqual({ kind: 'fallback', text: '' });
});
});
+4 -1
View File
@@ -1776,7 +1776,10 @@ pkg_install_files() {
return $rc
fi
else
run_logged_timeout "opkg" 120 opkg install --force-overwrite --force-downgrade "$@"
# --force-reinstall: a release tag can be rebuilt, so the .ipk version may
# equal the installed one. Without it opkg treats that as "already
# installed" and the rebuild is never written.
run_logged_timeout "opkg" 120 opkg install --force-overwrite --force-downgrade --force-reinstall "$@"
local rc=$?
if [ $rc -ne 0 ]; then
log_line "FAIL opkg install failed (exit $rc)"
@@ -3557,9 +3557,10 @@ var TachyonShellMethods = {
if (transientRpc.shouldContinue(failure.error)) {
continue;
}
if (component === "tachyon" && action === "install") {
const installedVersion = expectedLatestVersion ? await readTachyonVersion() : "";
if (expectedLatestVersion && installedVersion === expectedLatestVersion) {
if (component === "tachyon" && (action === "install" || action === "reinstall")) {
const installedVersion = await readTachyonVersion();
const targetVersion = expectedLatestVersion || installedVersion;
if (targetVersion && installedVersion === targetVersion) {
if (!selfUpdateVersionMatchedAt) {
selfUpdateVersionMatchedAt = Date.now();
}
@@ -13868,6 +13869,22 @@ function shouldExposeCheckResults({
return mounted && cacheResolved;
}
// src/tachyon/tabs/updates/sameReleaseBuild.ts
function describeSameReleaseBuild({
currentSha,
latestSha
}) {
const current = (currentSha || "").substring(0, 8);
const latest = (latestSha || "").substring(0, 8);
if (current && latest) {
return { kind: "sha", text: `${current} \u2192 ${latest}` };
}
if (current || latest) {
return { kind: "sha", text: current || latest };
}
return { kind: "fallback", text: "" };
}
// src/tachyon/tabs/updates/initController.ts
var updatesLifecycleRegistered = false;
var updatesControllerInitialized = false;
@@ -14645,21 +14662,20 @@ function renderComponentCard(card) {
}
} else if (checkResult.status === "outdated_same_release") {
labelText = _("Update is available for current release");
const currentSha = checkResult.current_sha ? checkResult.current_sha.substring(0, 8) : "";
const latestSha = checkResult.latest_sha ? checkResult.latest_sha.substring(0, 8) : "";
if (currentSha || latestSha) {
const shaText = currentSha && latestSha ? `${currentSha} \u2192 ${latestSha}` : currentSha || latestSha;
latestValueNodes.push(
E(
"span",
{
class: "tachyon_updates-page__component__sha-info",
title: _("Installed build \u2192 Available build")
},
shaText
)
);
}
const build = describeSameReleaseBuild({
currentSha: checkResult.current_sha,
latestSha: checkResult.latest_sha
});
latestValueNodes.push(
E(
"span",
{
class: "tachyon_updates-page__component__sha-info",
title: _("Installed build \u2192 Available build")
},
build.kind === "sha" ? build.text : _("Rebuilt release")
)
);
} else if (checkResult.status === "latest") {
labelText = _("Latest version is installed");
} else if (checkResult.status === "dev") {
+24 -9
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr ""
"Project-Id-Version: TACHYON\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-03 20:40+0300\n"
"PO-Revision-Date: 2026-08-03 20:40+0300\n"
"POT-Creation-Date: 2026-08-09 16:50+0300\n"
"PO-Revision-Date: 2026-08-09 16:50+0300\n"
"Last-Translator: Dushnilin\n"
"Language-Team: none\n"
"Language: ru\n"
@@ -374,6 +374,9 @@ msgstr "Путь к файлу кэша"
msgid "Cache file path cannot be empty"
msgstr "Путь к файлу кэша не может быть пустым"
msgid "Cancel"
msgstr ""
msgid "Cannot receive checks result"
msgstr "Не удалось получить результаты проверки"
@@ -527,6 +530,9 @@ msgstr "Настройки сервиса Tachyon"
msgid "Configure User-Agent in the item settings"
msgstr "Настройте User-Agent в настройках элемента"
msgid "Conflict"
msgstr ""
msgid "Connect through"
msgstr "Подключаться через"
@@ -1337,6 +1343,9 @@ msgstr "Установить"
msgid "Install another build:"
msgstr "Установить другую сборку:"
msgid "Installed build → Available build"
msgstr ""
msgid "Installed version is newer than release"
msgstr "Установлена версия новее релиза"
@@ -2207,6 +2216,9 @@ msgstr "Reality short ID"
msgid "Reality short ID must contain 1-8 hex digits"
msgstr "Reality short ID должен содержать 1-8 hex-символов"
msgid "Rebuilt release"
msgstr ""
msgid "Reconnect Threshold"
msgstr "Порог переподключений"
@@ -2273,6 +2285,9 @@ msgstr "Magic Header: Ответ (S4)"
msgid "Restart Tachyon"
msgstr "Перезапустить Tachyon"
msgid "Restarts"
msgstr ""
msgid "Resume updates"
msgstr "Продолжить обновление"
@@ -3074,6 +3089,9 @@ msgstr "Неизвестный флаг NFQWS2."
msgid "Unspecified IP address is not allowed"
msgstr "Неуказанный IP-адрес не разрешён"
msgid "Unstable"
msgstr ""
msgid "Unsupported security mode for this protocol"
msgstr "Этот протокол не поддерживает выбранный режим защиты"
@@ -3083,14 +3101,11 @@ msgstr "Обновить"
msgid "Update is available"
msgstr "Доступно обновление"
msgid "Update is available:"
msgstr "Доступна новая версия:"
msgid "Update is available for current release"
msgstr "Доступно обновление текущего релиза"
msgid "Update is available for current release:"
msgstr "Доступно обновление текущего релиза:"
msgid "Update is available:"
msgstr "Доступна новая версия:"
msgid "Update subscriptions"
msgstr "Обновить подписки"
@@ -3122,8 +3137,8 @@ msgstr "URL для проверки доступности сервера"
msgid "URL used to test server latency"
msgstr "URL для тестирования задержки сервера"
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>. Entries are resolved via both dnsmasq and sing-box DNS."
msgstr "URL удалённых файлов хостов для загрузки и применения. Поддерживается стандартный формат hosts: <code>IP domain</code>. Записи разрешаются через dnsmasq и DNS sing-box."
msgid "URLs of remote hosts files to download and apply. Supports standard hosts format: <code>IP domain</code>."
msgstr ""
msgid "URLTest"
msgstr "URLTest"
File diff suppressed because it is too large Load Diff
+81 -10
View File
@@ -13,6 +13,9 @@ const TACHYON_COMMIT_SHA = getenv("TACHYON_COMMIT_SHA") || constants.TACHYON_COM
const TACHYON_RELEASE_REPO = getenv("TACHYON_RELEASE_REPO") || constants.TACHYON_RELEASE_REPO || "Dushnilin/tachyon";
const RUNTIME_STATE_DIR = getenv("TACHYON_RUNTIME_STATE_DIR") || "/var/run/tachyon";
const SYSTEM_INFO_CACHE_FILE = getenv("TACHYON_SYSTEM_INFO_CACHE_FILE") || RUNTIME_STATE_DIR + "/system-info.json";
// Persistent, not RUNTIME_STATE_DIR: the fingerprint describes the build that is
// on disk, so it has to survive a reboot the same way the package does.
const TACHYON_BUILD_STATE_FILE = getenv("TACHYON_BUILD_STATE_FILE") || "/etc/.tachyon/build-state";
const COMPONENT_LOCK_DIR = getenv("UPDATES_LOCK_DIR") || RUNTIME_STATE_DIR + "/component-action.lock";
const TMP_STALE_TTL_MINUTES = getenv("UPDATES_TMP_STALE_TTL_MINUTES") || "30";
const TMP_FILE_STALE_TTL_MINUTES = getenv("UPDATES_TMP_FILE_STALE_TTL_MINUTES") || "10";
@@ -405,8 +408,14 @@ function pkg_install_name_downgrade(package_name, package_version) {
command_success(command_from_args([ "opkg", "install", "--force-downgrade", package_name ]) + " </dev/null");
}
function pkg_install_files_command(files) {
// force_reinstall matters only for opkg: installing an .ipk whose version equals
// the installed one is a no-op unless --force-reinstall is passed, so a rebuild
// published under the same tag would silently not be applied. apk always writes
// the file it is handed, so its argument list stays untouched.
function pkg_install_files_command(files, force_reinstall) {
let args = is_apk() ? [ "apk", "add", "--allow-untrusted" ] : [ "opkg", "install", "--force-overwrite", "--force-downgrade" ];
if (!is_apk() && force_reinstall)
push(args, "--force-reinstall");
for (let file in files)
push(args, file);
return command_from_args(args) + " </dev/null";
@@ -762,6 +771,49 @@ function write_tachyon_latest_version_cache(value, timestamp) {
write_file("/tmp/tachyon.latest-version.cache", as_string(value) + "\n" + as_string(timestamp) + "\n");
}
// A release tag can be rebuilt, so the version alone cannot answer "is the build
// on disk the build the release publishes now?". The fingerprint recorded at
// install time answers it, including for releases that carry no commit SHA.
function read_tachyon_build_fingerprint() {
let fields = split(trim(read_file(TACHYON_BUILD_STATE_FILE)), "\t");
if (length(fields) < 2 || trim(as_string(fields[0])) != trim(as_string(TACHYON_VERSION)))
return "";
return trim(as_string(fields[1]));
}
function write_tachyon_build_fingerprint(version, fingerprint) {
if (as_string(version) == "" || as_string(fingerprint) == "")
return;
let dir = replace(TACHYON_BUILD_STATE_FILE, /\/[^\/]*$/, "");
if (dir != "")
ensure_dir(dir);
write_file(TACHYON_BUILD_STATE_FILE, as_string(version) + "\t" + as_string(fingerprint) + "\n");
}
// Called after a successful install/reinstall. TACHYON_COMMIT_SHA still holds the
// SHA of the build that is running this code, not the one just written to disk,
// so the release we installed from is the only source for the new identity.
function record_tachyon_installed_build(version) {
let release_json = latest_tachyon_release_json();
if (release_json == "")
return null;
let sha = trim(helper_output_input(release_json, "release-commit-sha", []));
if (sha != "" && match(sha, /^[0-9a-fA-F]{7,40}$/) == null)
sha = "";
let fingerprint = trim(helper_output_input(release_json, "release-build-fingerprint", []));
write_tachyon_build_fingerprint(version, fingerprint);
if (sha == "" && fingerprint == "")
return null;
let extra = { current_sha: sha, latest_sha: sha };
if (fingerprint != "") {
extra.current_build = fingerprint;
extra.latest_build = fingerprint;
}
return extra;
}
function retry_resolve(description, fn) {
for (let attempt = 1; attempt <= 3; attempt++) {
if (fn())
@@ -2196,21 +2248,33 @@ function check_tachyon() {
// Fetch remote commit SHA for information display
let remote_sha = "";
let remote_fingerprint = "";
let local_sha = TACHYON_COMMIT_SHA != "" && TACHYON_COMMIT_SHA != "unknown" ? TACHYON_COMMIT_SHA : "";
let local_fingerprint = read_tachyon_build_fingerprint();
if (release_json != "") {
remote_sha = trim(helper_output_input(release_json, "release-commit-sha", []));
if (remote_sha != "" && match(remote_sha, /^[0-9a-fA-F]{7,40}$/) == null)
remote_sha = "";
remote_fingerprint = trim(helper_output_input(release_json, "release-build-fingerprint", []));
}
let sha_extra = local_sha != "" ? { current_sha: local_sha, latest_sha: remote_sha } : null;
let sha_extra = null;
if (local_sha != "" || remote_fingerprint != "" || local_fingerprint != "") {
sha_extra = { current_sha: local_sha, latest_sha: remote_sha };
if (local_fingerprint != "")
sha_extra.current_build = local_fingerprint;
if (remote_fingerprint != "")
sha_extra.latest_build = remote_fingerprint;
}
if (status == "latest") {
if (local_sha != "" && remote_sha != "" &&
substr(remote_sha, 0, length(local_sha)) != local_sha &&
substr(local_sha, 0, length(remote_sha)) != remote_sha) {
// Same tag can carry several builds. Decide via SHA when both sides have
// one, otherwise via the fingerprint recorded at install time.
if (helper_success("tachyon-build-differs", [ local_sha, remote_sha, local_fingerprint, remote_fingerprint ])) {
status = "outdated_same_release";
updates_log("Tachyon build update found for current release (" + TACHYON_VERSION + "): " + local_sha + " -> " + remote_sha);
updates_log("Tachyon build update found for current release (" + TACHYON_VERSION + "): " +
(local_sha != "" && remote_sha != "" ? local_sha + " -> " + remote_sha :
local_fingerprint + " -> " + remote_fingerprint));
action_success("tachyon", "check_update", "Update is available for current release", TACHYON_VERSION, latest_version, 0, status, release_url, sha_extra);
}
updates_log("Tachyon is already up to date (" + TACHYON_VERSION + ")");
@@ -2286,7 +2350,7 @@ function reinstall_tachyon() {
let reinstall_files = [ app_file, backend_file ];
if (i18n_file != "")
push(reinstall_files, i18n_file);
if (!run_logged_retrying("Reinstalling Tachyon packages", pkg_install_files_command(reinstall_files)))
if (!run_logged_retrying("Reinstalling Tachyon packages", pkg_install_files_command(reinstall_files, true)))
action_fail("tachyon", "reinstall", "Failed to reinstall Tachyon packages", TACHYON_VERSION, latest_version);
remove_file("/var/luci-indexcache");
@@ -2302,7 +2366,8 @@ function reinstall_tachyon() {
if (new_version == "")
new_version = latest_version;
updates_log("Tachyon reinstalled to " + new_version);
action_success("tachyon", "reinstall", "Tachyon has been reinstalled", new_version, latest_version, 1, "latest", release.release_url);
let build_extra = record_tachyon_installed_build(new_version);
action_success("tachyon", "reinstall", "Tachyon has been reinstalled", new_version, latest_version, 1, "latest", release.release_url, build_extra);
}
function install_tachyon() {
@@ -2330,7 +2395,10 @@ function install_tachyon() {
let install_files = [ app_file, backend_file ];
if (i18n_file != "")
push(install_files, i18n_file);
if (!run_logged_retrying("Installing Tachyon packages", pkg_install_files_command(install_files)))
// Installing the same tag means a rebuild of the current release; opkg skips
// it as "already installed" unless it is forced.
let same_release_build = trim(as_string(latest_version)) == trim(as_string(TACHYON_VERSION));
if (!run_logged_retrying("Installing Tachyon packages", pkg_install_files_command(install_files, same_release_build)))
action_fail("tachyon", "install", "Failed to install Tachyon packages", TACHYON_VERSION, latest_version);
remove_file("/var/luci-indexcache");
@@ -2346,7 +2414,8 @@ function install_tachyon() {
if (new_version == "")
new_version = latest_version;
updates_log("Tachyon updated to " + new_version);
action_success("tachyon", "install", "Tachyon has been installed", new_version, latest_version, 1, "latest", release.release_url);
let build_extra = record_tachyon_installed_build(new_version);
action_success("tachyon", "install", "Tachyon has been installed", new_version, latest_version, 1, "latest", release.release_url, build_extra);
}
function dispatch_sing_box(action) {
@@ -2439,6 +2508,8 @@ else if (mode == "latest-tachyon-version")
print(latest_tachyon_version(), "\n");
else if (mode == "tachyon-release-metadata")
print(fetch_tachyon_latest_release_metadata(), "\n");
else if (mode == "pkg-install-files-command")
print(pkg_install_files_command(slice(ARGV, 2), ARGV[1] == "1"), "\n");
else {
warn("Usage: components/action.uc <component-action|latest-tachyon-version|tachyon-release-metadata> ...\n");
exit(1);
+94 -10
View File
@@ -271,21 +271,101 @@ function release_metadata_tsv() {
print(tag, "\t", as_string(release.html_url || ""), "\n");
}
function release_commit_sha() {
let release = object_or_empty(read_stdin_json());
function release_commit_sha_value(release) {
release = object_or_empty(release);
let sha = as_string(release.target_commitish || "");
if (sha != "" && match(sha, /^[0-9a-fA-F]{7,40}$/) != null) {
print(sha, "\n");
return;
}
if (sha != "" && match(sha, /^[0-9a-fA-F]{7,40}$/) != null)
return sha;
let body = as_string(release.body || "");
if (body != "") {
let m = match(body, /(?:[Cc]ommit|[Ss][Hh][Aa]):?\s*([0-9a-fA-F]{7,40})/);
if (m && m[1]) {
print(m[1], "\n");
return;
// ucode's regex engine rejects non-capturing groups, so the two spellings
// are matched separately rather than as one alternation.
for (let pattern in [ /[Cc]ommit:?[ \t]*([0-9a-fA-F]{7,40})/,
/[Ss][Hh][Aa]:?[ \t]*([0-9a-fA-F]{7,40})/ ]) {
let m = match(body, pattern);
if (m && m[1])
return as_string(m[1]);
}
}
return "";
}
function release_commit_sha() {
let sha = release_commit_sha_value(read_stdin_json());
if (sha != "")
print(sha, "\n");
}
// A rebuild published under an existing tag keeps tag_name identical, so the tag
// alone cannot tell two builds apart. Releases created before the workflow began
// stamping "Commit: <sha>" into the body have no usable SHA either — for those
// this fingerprint is the only signal that the assets were replaced.
//
// Preference order: the release SHA, then the publish/upload timestamps and the
// size of the backend asset. Timestamps alone would miss a re-upload that keeps
// published_at, and size alone collides across near-identical builds; together
// they change whenever the artifacts do.
function release_build_fingerprint_value(release) {
release = object_or_empty(release);
let sha = release_commit_sha_value(release);
if (sha != "")
return "sha:" + sha;
let parts = [];
let published = as_string(release.published_at || release.created_at || "");
if (published != "")
push(parts, "pub=" + published);
for (let asset in array_or_empty(release.assets)) {
if (type(asset) != "object")
continue;
let name = as_string(asset.name || "");
if (!str_startswith(name, "tachyon_"))
continue;
let updated = as_string(asset.updated_at || asset.created_at || "");
if (updated != "")
push(parts, "upd=" + updated);
if (asset.size != null)
push(parts, "size=" + as_string(asset.size));
break;
}
if (length(parts) == 0)
return "";
return "build:" + join("|", parts);
}
function release_build_fingerprint() {
let fingerprint = release_build_fingerprint_value(read_stdin_json());
if (fingerprint != "")
print(fingerprint, "\n");
}
// Decides whether the installed build differs from the published one. Exits 0
// when they differ (an update exists), 1 when they match or when there is not
// enough information to tell — an unknown state must never be reported as an
// available update, or the UI would offer an install that changes nothing.
function tachyon_build_differs(local_sha, remote_sha, local_fingerprint, remote_fingerprint) {
local_sha = trim(as_string(local_sha));
remote_sha = trim(as_string(remote_sha));
// Build.sh and the OpenWrt Makefile both stamp a short SHA, while the GitHub
// release may carry the full one; compare on the shorter of the two.
if (local_sha != "" && remote_sha != "") {
let shorter = length(local_sha) < length(remote_sha) ? length(local_sha) : length(remote_sha);
return substr(local_sha, 0, shorter) != substr(remote_sha, 0, shorter);
}
local_fingerprint = trim(as_string(local_fingerprint));
remote_fingerprint = trim(as_string(remote_fingerprint));
if (local_fingerprint != "" && remote_fingerprint != "")
return local_fingerprint != remote_fingerprint;
return false;
}
function openwrt_release_value(path, key) {
@@ -1240,6 +1320,10 @@ else if (mode == "release-metadata-tsv")
release_metadata_tsv();
else if (mode == "release-commit-sha")
release_commit_sha();
else if (mode == "release-build-fingerprint")
release_build_fingerprint();
else if (mode == "tachyon-build-differs")
exit(tachyon_build_differs(ARGV[1], ARGV[2], ARGV[3], ARGV[4]) ? 0 : 1);
else if (mode == "openwrt-release-value")
openwrt_release_value(ARGV[1], ARGV[2]);
else if (mode == "openwrt-release-series")
+25 -5
View File
@@ -1443,13 +1443,25 @@ function cache_component_update_check_result(value, notify_update) {
cached.current_sha = as_string(value.current_sha);
if (as_string(value.latest_sha) != "")
cached.latest_sha = as_string(value.latest_sha);
// Build fingerprints answer "is this the build the release publishes now?" for
// releases that carry no commit SHA at all.
if (as_string(value.current_build) != "")
cached.current_build = as_string(value.current_build);
if (as_string(value.latest_build) != "")
cached.latest_build = as_string(value.latest_build);
if (!write_state_file(path, cached))
return false;
if (arg_bool(notify_update) && cached.status == "outdated" &&
(as_string(previous.status) != "outdated" ||
as_string(previous.latest_version) != cached.latest_version)) {
log_message("[component-update] " + component + " " + cached.latest_version, "info");
// A rebuild keeps latest_version, so the build identity has to take part in the
// "is this new to us?" test or same-tag rebuilds would never be logged.
let build_key = as_string(cached.latest_sha) != "" ? as_string(cached.latest_sha) : as_string(cached.latest_build);
let previous_build_key = as_string(previous.latest_sha) != "" ? as_string(previous.latest_sha) : as_string(previous.latest_build);
if (arg_bool(notify_update) && (cached.status == "outdated" || cached.status == "outdated_same_release") &&
(as_string(previous.status) != cached.status ||
as_string(previous.latest_version) != cached.latest_version ||
previous_build_key != build_key)) {
log_message("[component-update] " + component + " " + cached.latest_version +
(cached.status == "outdated_same_release" && build_key != "" ? " (build " + build_key + ")" : ""), "info");
}
return true;
@@ -1483,6 +1495,10 @@ function update_component_check_cache_from_action(value) {
latest_version = current_ver;
if (current_ver == "" || current_ver != latest_version)
current_ver = latest_version;
// The action reports which build it actually put on disk. Carrying it into
// the cache is what keeps a same-tag rebuild from re-appearing as an update
// right after it was installed — and, if the install silently changed
// nothing, from being hidden behind a bare "latest".
cache_component_update_check_result({
success: true,
component,
@@ -1491,7 +1507,11 @@ function update_component_check_cache_from_action(value) {
current_version: current_ver,
latest_version,
release_url: as_string(value.release_url),
status: "latest"
status: "latest",
current_sha: as_string(value.current_sha),
latest_sha: as_string(value.latest_sha),
current_build: as_string(value.current_build),
latest_build: as_string(value.latest_build)
}, false);
}
}
+55 -6
View File
@@ -1247,6 +1247,36 @@ function exec_close_connections(token, chat_id) {
send_message(token, chat_id, "❌ <b>Ошибка сброса соединений.</b>\nsing-box Clash API недоступен.", "HTML", [[{text:"⬅️ Меню", callback_data:"/menu"}]]);
}
// A rebuild published under an existing tag keeps its version, so "1.2.66 ➡️
// 1.2.66" is all the version fields can say. These helpers name the build
// instead: the commit SHA when the release carries one, else the fingerprint
// derived from publish timestamps and asset size.
function build_label(sha, fingerprint) {
sha = as_string(sha);
if (sha != "")
return length(sha) > 7 ? substr(sha, 0, 7) : sha;
fingerprint = as_string(fingerprint);
if (fingerprint == "")
return "";
let published = match(fingerprint, /pub=([0-9]{4}-[0-9]{2}-[0-9]{2})T([0-9]{2}:[0-9]{2})/);
if (published)
return as_string(published[1]) + " " + as_string(published[2]);
return length(fingerprint) > 24 ? substr(fingerprint, 0, 24) : fingerprint;
}
function build_transition(comp) {
let from = build_label(comp.current_sha, comp.current_build);
let to = build_label(comp.latest_sha, comp.latest_build);
if (from == "" || to == "" || from == to)
return to != "" ? "<code>" + escape_html(to) + "</code>" : "";
return "<code>" + escape_html(from) + "</code> ➡️ <code>" + escape_html(to) + "</code>";
}
function build_identity_key(comp) {
let sha = as_string(comp.latest_sha);
return sha != "" ? sha : as_string(comp.latest_build);
}
function exec_check_updates(token, chat_id, msg_id) {
let out = command_output_from_args(["/usr/bin/tachyon", "component_update_check_cache"]);
let text = "📦 <b>Обновления компонентов</b>\n\n";
@@ -1268,7 +1298,13 @@ function exec_check_updates(token, chat_id, msg_id) {
let lat = comp.latest_version || "?";
if (!comp.success) {
text += "• <b>" + title + "</b>: ❌ Ошибка проверки\n";
} else if (comp.status == "outdated" || comp.status == "outdated_same_release") {
} else if (comp.status == "outdated_same_release") {
let transition = build_transition(comp);
text += "• <b>" + title + "</b>: <code>" + cur + "</code> — новая сборка релиза" +
(transition != "" ? " " + transition : "") + " ⚠️\n";
push(keyboard, [{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]);
has_updates = true;
} else if (comp.status == "outdated") {
text += "• <b>" + title + "</b>: <code>" + cur + "</code> ➡️ <code>" + lat + "</code> ⚠️\n";
push(keyboard, [{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]);
has_updates = true;
@@ -2529,20 +2565,33 @@ function check_notified_updates(token, admin_ids) {
let name = comp.component || "";
if (name == "") continue;
if (comp.success !== true) continue;
if (comp.status == "outdated") {
if (comp.status == "outdated" || comp.status == "outdated_same_release") {
let latest = comp.latest_version;
if (notified[name] != latest) {
// Keyed by version *and* build: a rebuild under an existing tag
// leaves the version untouched, so a version-only key would
// suppress its notification forever. Entries written by older
// builds hold a bare version and simply miss once.
let identity = build_identity_key(comp);
let key = as_string(latest) + (identity != "" ? ":" + identity : "");
if (notified[name] != key) {
let title = (name == "sing_box") ? "sing-box" : name;
let cur = comp.current_version || "?";
let msg = "📦 <b>Доступно обновление компонента!</b>\n" + title + ": <code>" + escape_html(cur) + "</code> ➡️ <code>" + escape_html(as_string(latest)) + "</code>";
let msg;
if (comp.status == "outdated_same_release") {
let transition = build_transition(comp);
msg = "📦 <b>Новая сборка текущего релиза!</b>\n" + title + ": <code>" + escape_html(cur) + "</code>" +
(transition != "" ? "\nСборка: " + transition : "");
} else {
msg = "📦 <b>Доступно обновление компонента!</b>\n" + title + ": <code>" + escape_html(cur) + "</code> ➡️ <code>" + escape_html(as_string(latest)) + "</code>";
}
let kb = [[{text: "🔄 Обновить " + title, callback_data: "/update_component " + name}]];
let admins = split(admin_ids, /,/);
for (let admin in admins) {
let cid = trim(admin);
if (cid != "") send_message(token, cid, msg, "HTML", kb);
}
notified[name] = latest;
notified[name] = key;
changed = true;
}
}
+33 -2
View File
@@ -61,7 +61,7 @@ cat >"$fake_lib/components/action.uc" <<'UCODE'
#!/usr/bin/env ucode
let component = ARGV[1] || "tachyon";
let action = ARGV[2] || "check_update";
print(sprintf("%J\n", {
let result = {
success: true,
component,
action,
@@ -71,7 +71,16 @@ print(sprintf("%J\n", {
release_url: "https://example.com/release",
changed: 0,
status: "outdated"
}));
};
// An install reports which build it actually put on disk; a same-tag rebuild is
// only distinguishable by that value.
if (getenv("TEST_ACTION_CURRENT_SHA")) {
result.current_sha = getenv("TEST_ACTION_CURRENT_SHA");
result.latest_sha = getenv("TEST_ACTION_CURRENT_SHA");
result.status = "latest";
result.changed = 1;
}
print(sprintf("%J\n", result));
UCODE
cat >"$fake_lib/providers/zapret/runtime.uc" <<'UCODE'
@@ -115,6 +124,28 @@ TEST_COMPONENT_UPDATE_CHECK_ENABLED=0 updates_ucode \
[ ! -e "$cache_dir/zapret.json" ] ||
fail "manual checks must not be cached while automatic checks are disabled"
# A successful install must record the build identity it installed, not just
# "latest". A release tag can be rebuilt, so without the SHA the next check has
# nothing to compare against and either re-offers the same build or hides an
# install that silently changed nothing.
rm -rf "$cache_dir"
rm -f "$state_file"
TEST_ACTION_CURRENT_SHA=abc1234 updates_ucode \
component-action-worker "$state_file" "$output_file" tachyon install
[ -s "$cache_dir/tachyon.json" ] ||
fail "a successful install must refresh the Tachyon cache entry"
install_cache="$(updates_ucode component-update-check-cache)"
node -e '
const value = JSON.parse(process.argv[1]);
const entry = value.results.find((item) => item.component === "tachyon");
if (!entry) process.exit(1);
if (entry.status !== "latest") process.exit(1);
if (entry.current_sha !== "abc1234") process.exit(1);
if (entry.latest_sha !== "abc1234") process.exit(1);
' "$install_cache" || fail "install must cache the installed build sha"
rm -rf "$cache_dir"
rm -f "$state_file" "$timestamp_file"
updates_ucode component-updates-if-due
[ -s "$cache_dir/tachyon.json" ] ||
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ACTION_UC="$ROOT_DIR/tachyon/files/usr/lib/components/action.uc"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected="$1"
local actual="$2"
local label="$3"
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
}
# is_apk() probes for an apk binary, so the package manager under test is chosen
# by what is on PATH. Both fake dirs shadow the real tools.
mkdir -p "$WORK_DIR/opkg-bin" "$WORK_DIR/apk-bin"
printf '#!/bin/sh\nexit 0\n' >"$WORK_DIR/opkg-bin/opkg"
printf '#!/bin/sh\nexit 0\n' >"$WORK_DIR/apk-bin/apk"
chmod +x "$WORK_DIR/opkg-bin/opkg" "$WORK_DIR/apk-bin/apk"
install_command() {
local pm_dir="$1"
local force="$2"
shift 2
PATH="$WORK_DIR/$pm_dir:$PATH" command ucode -L "$TACHYON_LIB" "$ACTION_UC" \
pkg-install-files-command "$force" "$@"
}
# opkg treats an .ipk whose version equals the installed one as already
# installed. Without --force-reinstall a rebuild published under the same tag is
# downloaded, "installed" successfully, and never actually written.
OPKG_FORCED="$(install_command opkg-bin 1 /tmp/a.ipk /tmp/b.ipk)"
case "$OPKG_FORCED" in
*--force-reinstall*) ;;
*) fail "opkg install must carry --force-reinstall when forced, got: $OPKG_FORCED" ;;
esac
# A plain version upgrade needs no forcing; opkg installs it on its own.
OPKG_PLAIN="$(install_command opkg-bin 0 /tmp/a.ipk /tmp/b.ipk)"
case "$OPKG_PLAIN" in
*--force-reinstall*) fail "opkg install must not force when not asked, got: $OPKG_PLAIN" ;;
esac
for expected_flag in --force-overwrite --force-downgrade; do
case "$OPKG_PLAIN" in
*"$expected_flag"*) ;;
*) fail "opkg install lost $expected_flag, got: $OPKG_PLAIN" ;;
esac
done
case "$OPKG_PLAIN" in
*"/tmp/a.ipk"*"/tmp/b.ipk"*) ;;
*) fail "opkg install must keep the file list in order, got: $OPKG_PLAIN" ;;
esac
# apk add always writes the file it is handed, and has no --force-reinstall, so
# forcing must not alter its argument list.
APK_FORCED="$(install_command apk-bin 1 /tmp/a.apk)"
APK_PLAIN="$(install_command apk-bin 0 /tmp/a.apk)"
assert_eq "$APK_PLAIN" "$APK_FORCED" "apk command must be identical whether or not reinstall is forced"
case "$APK_PLAIN" in
*--force-reinstall*) fail "apk has no --force-reinstall, got: $APK_PLAIN" ;;
esac
# Arguments are shell-quoted individually by command_from_args.
case "$APK_PLAIN" in
*"'apk' 'add' '--allow-untrusted'"*"'/tmp/a.apk'"*) ;;
*) fail "apk install command malformed, got: $APK_PLAIN" ;;
esac
printf 'PASS: pkg install force reinstall\n'
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
set -eo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
UPDATER_UC="$ROOT_DIR/tachyon/files/usr/lib/components/updater.uc"
TACHYON_LIB="$ROOT_DIR/tachyon/files/usr/lib"
ucode() {
command ucode -L "$TACHYON_LIB" "$@"
}
fail() {
printf 'FAIL: %s\n' "$1" >&2
exit 1
}
assert_eq() {
local expected="$1"
local actual="$2"
local label="$3"
[ "$actual" = "$expected" ] || fail "$label: expected '$expected', got '$actual'"
}
fingerprint() {
printf '%s' "$1" | ucode "$UPDATER_UC" release-build-fingerprint
}
commit_sha() {
printf '%s' "$1" | ucode "$UPDATER_UC" release-commit-sha
}
# A release whose body carries "Commit: <sha>" is identified by that sha, so the
# fingerprint must not fall back to timestamps and lose precision.
BODY_SHA_RELEASE='{"tag_name":"1.2.66","html_url":"https://example.invalid/r","target_commitish":"main","body":"Notes\nCommit: a1b2c3d4e5f6\n","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"tachyon_1.2.66_all.ipk","updated_at":"2026-08-01T10:05:00Z","size":123456}]}'
assert_eq "sha:a1b2c3d4e5f6" "$(fingerprint "$BODY_SHA_RELEASE")" "fingerprint prefers body commit sha"
assert_eq "a1b2c3d4e5f6" "$(commit_sha "$BODY_SHA_RELEASE")" "commit sha read from body"
# target_commitish holds a branch name on every release cut from a branch, so it
# must not be mistaken for a sha.
BRANCH_ONLY_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","body":"No commit line here","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"tachyon_1.2.66_all.ipk","updated_at":"2026-08-01T10:05:00Z","size":123456}]}'
assert_eq "" "$(commit_sha "$BRANCH_ONLY_RELEASE")" "branch name is not a commit sha"
assert_eq "build:pub=2026-08-01T10:00:00Z|upd=2026-08-01T10:05:00Z|size=123456" \
"$(fingerprint "$BRANCH_ONLY_RELEASE")" \
"fingerprint falls back to publish stamps and asset size"
# A rebuild replaces the asset, so the fingerprint has to move even when
# published_at stays exactly the same.
REBUILT_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","body":"No commit line here","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"tachyon_1.2.66_all.ipk","updated_at":"2026-08-02T11:30:00Z","size":123999}]}'
[ "$(fingerprint "$BRANCH_ONLY_RELEASE")" != "$(fingerprint "$REBUILT_RELEASE")" ] ||
fail "rebuilt release must not share a fingerprint with the original build"
# created_at stands in for a release that has not been published yet.
CREATED_ONLY_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","created_at":"2026-07-30T08:00:00Z","assets":[]}'
assert_eq "build:pub=2026-07-30T08:00:00Z" "$(fingerprint "$CREATED_ONLY_RELEASE")" \
"created_at substitutes for published_at"
# Assets that are not the backend package must not drive the fingerprint.
FOREIGN_ASSET_RELEASE='{"tag_name":"1.2.66","target_commitish":"main","published_at":"2026-08-01T10:00:00Z","assets":[{"name":"luci-app-tachyon_1.2.66_all.ipk","updated_at":"2026-08-01T10:09:00Z","size":777}]}'
assert_eq "build:pub=2026-08-01T10:00:00Z" "$(fingerprint "$FOREIGN_ASSET_RELEASE")" \
"only the tachyon_ backend asset contributes"
# Nothing identifiable at all must produce no fingerprint rather than a constant
# one, or every release would look identical.
assert_eq "" "$(fingerprint '{"tag_name":"1.2.66","target_commitish":"main"}')" \
"release without stamps yields no fingerprint"
assert_eq "" "$(fingerprint '{}')" "empty release yields no fingerprint"
differs() {
if ucode "$UPDATER_UC" tachyon-build-differs "$1" "$2" "$3" "$4" >/dev/null 2>&1; then
printf 'yes'
else
printf 'no'
fi
}
assert_eq no "$(differs abc1234 abc1234 '' '')" "equal shas match"
assert_eq yes "$(differs abc1234 def5678 '' '')" "different shas differ"
# build.sh and the OpenWrt Makefile stamp a short sha while the release may carry
# the full one; the comparison happens on the shorter of the two.
assert_eq no "$(differs abc1234 abc1234ffffffffffffffffffffffffffffffff '' '')" \
"short local sha is a prefix of the full remote sha"
assert_eq yes "$(differs abc1234 abc1299ffffffffffffffffffffffffffffffff '' '')" \
"prefix mismatch within the short sha differs"
assert_eq no "$(differs '' '' 'build:pub=A' 'build:pub=A')" "equal fingerprints match"
assert_eq yes "$(differs '' '' 'build:pub=A' 'build:pub=B')" "different fingerprints differ"
# An unknown state must never be reported as an available update: the UI would
# offer an install that changes nothing.
assert_eq no "$(differs '' '' '' '')" "no information means no update"
assert_eq no "$(differs abc1234 '' '' '')" "remote sha alone missing means no update"
assert_eq no "$(differs '' def5678 '' '')" "local sha alone missing means no update"
assert_eq no "$(differs '' '' 'build:pub=A' '')" "remote fingerprint missing means no update"
assert_eq no "$(differs '' '' '' 'build:pub=B')" "local fingerprint missing means no update"
# When both shas are known they decide, even if the fingerprints disagree.
assert_eq no "$(differs abc1234 abc1234 'build:pub=A' 'build:pub=B')" \
"matching shas win over disagreeing fingerprints"
printf 'PASS: tachyon build fingerprint\n'