Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7f09095f |
No files matched your search
Generated
+6
-6
@@ -436,7 +436,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-core"
|
name = "da-core"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"base32",
|
"base32",
|
||||||
@@ -461,7 +461,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-discord"
|
name = "da-discord"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"da-core",
|
"da-core",
|
||||||
@@ -478,7 +478,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-http-api"
|
name = "da-http-api"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -500,7 +500,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-render"
|
name = "da-render"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"resvg",
|
"resvg",
|
||||||
@@ -512,7 +512,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "da-webui"
|
name = "da-webui"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
@@ -542,7 +542,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "discord-authenticator"
|
name = "discord-authenticator"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ members = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
|
|||||||
@@ -392,19 +392,36 @@ accounts are loaded from it, the damaged file is moved to
|
|||||||
`vault.json.corrupt-<timestamp>`, and a fresh `vault.json` is written. You will
|
`vault.json.corrupt-<timestamp>`, and a fresh `vault.json` is written. You will
|
||||||
see one `ERROR` line saying so, then normal startup.
|
see one `ERROR` line saying so, then normal startup.
|
||||||
|
|
||||||
If it still refuses, both files are unreadable and there is nothing to recover
|
If it still refuses, the message names exactly which situation you are in,
|
||||||
from automatically. Get the file out of the way and restart:
|
because they need different responses:
|
||||||
|
|
||||||
|
- **"there is no backup at …"** — no `.bak` was ever created, which means this
|
||||||
|
vault has not completed a single write since backups were introduced. Nothing
|
||||||
|
on disk is recoverable; the unreadable file is all there is.
|
||||||
|
- **"exists but could not be parsed either"** — both copies are gone.
|
||||||
|
- **"is empty as well"** — both are zero bytes.
|
||||||
|
|
||||||
|
Either way, the service will not boot with an empty vault on its own, because
|
||||||
|
that looks identical to a vault you emptied on purpose. Two ways forward:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. by hand
|
||||||
cd /mnt/user/appdata/discord-authenticator
|
cd /mnt/user/appdata/discord-authenticator
|
||||||
mv vault.json "vault.json.broken-$(date +%s)"
|
mv vault.json "vault.json.broken-$(date +%s)"
|
||||||
docker restart Discord-Authenticator
|
docker restart Discord-Authenticator
|
||||||
```
|
```
|
||||||
|
|
||||||
The service will start with an empty vault, so re-import your accounts from
|
Or set `DA_VAULT_RESET=1` in the Unraid template (Docker → the container → Add
|
||||||
your own backups (`otpauth://` links or a Google Authenticator export). Keep
|
another Path, Variable, Port or Label → Variable, Key `DA_VAULT_RESET`, Value
|
||||||
`vault.key` — without it, nothing in the old file can be decrypted even if the
|
`1`) and restart. The service boots empty and moves the unreadable file to
|
||||||
bytes are recovered later.
|
`vault.json.corrupt-<timestamp>` before writing a fresh vault, so nothing is
|
||||||
|
overwritten. **Remove the variable afterwards** — it only has an effect on the
|
||||||
|
failure path, but leaving it set means the next genuinely-broken vault boots
|
||||||
|
empty instead of telling you about it.
|
||||||
|
|
||||||
|
Then re-import your accounts from your own backups (`otpauth://` links or a
|
||||||
|
Google Authenticator export). Keep `vault.key` — without it, nothing in an old
|
||||||
|
vault file can be decrypted even if the bytes turn up later.
|
||||||
|
|
||||||
**If you hit this, upgrading matters more than the workaround.** The cause was
|
**If you hit this, upgrading matters more than the workaround.** The cause was
|
||||||
a rename being journalled ahead of the data it published, which is fixed by
|
a rename being journalled ahead of the data it published, which is fixed by
|
||||||
|
|||||||
+257
-22
@@ -251,6 +251,8 @@ impl Vault {
|
|||||||
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
|
pub fn load(work_dir: &Path, key: VaultKey) -> Result<Self> {
|
||||||
let path = work_dir.join("vault.json");
|
let path = work_dir.join("vault.json");
|
||||||
let backup_path = backup_of(&path);
|
let backup_path = backup_of(&path);
|
||||||
|
// `load_accounts` decides on DA_VAULT_RESET itself, because that is
|
||||||
|
// where the description of what went wrong is built.
|
||||||
let (accounts, recovered) = load_accounts(&path, &backup_path)?;
|
let (accounts, recovered) = load_accounts(&path, &backup_path)?;
|
||||||
tracing::info!(target: "da::vault", count = accounts.len(), "vault loaded");
|
tracing::info!(target: "da::vault", count = accounts.len(), "vault loaded");
|
||||||
|
|
||||||
@@ -790,6 +792,34 @@ impl Vault {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Has the operator explicitly asked to start with an empty vault?
|
||||||
|
///
|
||||||
|
/// Set `DA_VAULT_RESET=1` to boot an unreadable vault as empty instead of
|
||||||
|
/// refusing. Checked only on the failure path, so it has no effect on a vault
|
||||||
|
/// that loads normally.
|
||||||
|
fn reset_requested() -> bool {
|
||||||
|
std::env::var("DA_VAULT_RESET")
|
||||||
|
.map(|v| looks_like_reset(&v))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split out so the accepted spellings can be tested without touching the
|
||||||
|
/// process environment.
|
||||||
|
fn looks_like_reset(value: &str) -> bool {
|
||||||
|
let v = value.trim();
|
||||||
|
!v.is_empty() && v != "0" && !v.eq_ignore_ascii_case("false")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes the tests that write process environment variables. Rust runs
|
||||||
|
/// tests in parallel threads within one process, so an env var set by one test
|
||||||
|
/// is visible to every other test running at that moment.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
|
||||||
|
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
LOCK.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
/// `vault.json` → `vault.json.bak`.
|
/// `vault.json` → `vault.json.bak`.
|
||||||
fn backup_of(path: &Path) -> PathBuf {
|
fn backup_of(path: &Path) -> PathBuf {
|
||||||
let mut name = path
|
let mut name = path
|
||||||
@@ -806,8 +836,19 @@ fn backup_of(path: &Path) -> PathBuf {
|
|||||||
/// between "won't parse" and "unrecoverable", and the backup lives in it:
|
/// between "won't parse" and "unrecoverable", and the backup lives in it:
|
||||||
/// whenever one exists and does parse, the honest move is to use it and say so
|
/// whenever one exists and does parse, the honest move is to use it and say so
|
||||||
/// loudly, keeping the damaged original for inspection.
|
/// loudly, keeping the damaged original for inspection.
|
||||||
|
///
|
||||||
|
/// When *nothing* is recoverable the refusal has to say why precisely, because
|
||||||
|
/// the two cases need opposite responses from the operator. A backup that is
|
||||||
|
/// present and unreadable means both copies are gone and the accounts have to
|
||||||
|
/// be re-added; a backup that was never created (a container whose first write
|
||||||
|
/// never completed, so this feature has not run once) means the same file is
|
||||||
|
/// fine and there is simply nothing on disk to recover. The old wording said
|
||||||
|
/// "the backup could not be used either" for both, which sent the operator
|
||||||
|
/// looking for a file that did not exist.
|
||||||
fn load_accounts(path: &Path, backup_path: &Path) -> Result<(Vec<Account>, bool)> {
|
fn load_accounts(path: &Path, backup_path: &Path) -> Result<(Vec<Account>, bool)> {
|
||||||
let text = match std::fs::read_to_string(path) {
|
let text = match std::fs::read_to_string(path) {
|
||||||
|
// A missing vault is a first run, not a failure: there is nothing to
|
||||||
|
// recover and the service creates one on its first write.
|
||||||
Ok(text) => text,
|
Ok(text) => text,
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), false)),
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((Vec::new(), false)),
|
||||||
Err(e) => return Err(e.into()),
|
Err(e) => return Err(e.into()),
|
||||||
@@ -815,36 +856,116 @@ fn load_accounts(path: &Path, backup_path: &Path) -> Result<(Vec<Account>, bool)
|
|||||||
|
|
||||||
match serde_json::from_str::<VaultFile>(&text) {
|
match serde_json::from_str::<VaultFile>(&text) {
|
||||||
Ok(file) => Ok((file.accounts, false)),
|
Ok(file) => Ok((file.accounts, false)),
|
||||||
Err(primary_err) => match read_backup(backup_path) {
|
Err(primary_err) => {
|
||||||
Some(accounts) => {
|
match read_backup(backup_path) {
|
||||||
let quarantined = quarantine(path);
|
Some(accounts) => {
|
||||||
tracing::error!(
|
let quarantined = quarantine(path);
|
||||||
|
tracing::error!(
|
||||||
|
target: "da::vault",
|
||||||
|
path = %path.display(),
|
||||||
|
backup = %backup_path.display(),
|
||||||
|
recovered = accounts.len(),
|
||||||
|
quarantined = %quarantined.as_deref().map_or_else(
|
||||||
|
|| "could not move aside".to_string(),
|
||||||
|
|p| p.display().to_string()
|
||||||
|
),
|
||||||
|
"vault.json could not be parsed ({primary_err}); recovered the accounts \
|
||||||
|
from the backup. The damaged file is kept alongside for inspection — \
|
||||||
|
compare it against the backup if anything you added recently is missing."
|
||||||
|
);
|
||||||
|
return Ok((accounts, true));
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
let damage = describe_damage(path, backup_path, &primary_err.to_string());
|
||||||
|
|
||||||
|
// The refusal is the right default, but it must not be a dead end:
|
||||||
|
// an operator who has accepted that the accounts are gone needs a
|
||||||
|
// way forward that does not involve editing files inside a
|
||||||
|
// container that will not start. The variable has to be set
|
||||||
|
// deliberately, so this can never look like the silent-empty
|
||||||
|
// fallback the refusal exists to prevent.
|
||||||
|
if reset_requested() {
|
||||||
|
// Move the unreadable file aside *before* the empty vault is
|
||||||
|
// written, or the caller's `persist` would overwrite it and
|
||||||
|
// then take the backup from the replacement — destroying the
|
||||||
|
// only copy of whatever might still have been salvageable.
|
||||||
|
let kept = quarantine(path);
|
||||||
|
tracing::warn!(
|
||||||
target: "da::vault",
|
target: "da::vault",
|
||||||
path = %path.display(),
|
preserved = %kept.as_deref().map_or_else(
|
||||||
backup = %backup_path.display(),
|
|| "NOTHING — it could not be moved aside and will be overwritten"
|
||||||
recovered = accounts.len(),
|
.to_string(),
|
||||||
quarantined = %quarantined.as_deref().map_or_else(
|
|
||||||
|| "could not move aside".to_string(),
|
|
||||||
|p| p.display().to_string()
|
|p| p.display().to_string()
|
||||||
),
|
),
|
||||||
"vault.json could not be parsed ({primary_err}); recovered the accounts \
|
"DA_VAULT_RESET is set, so starting with an empty vault. {damage}"
|
||||||
from the backup. The damaged file is kept alongside for inspection — \
|
|
||||||
compare it against the backup if anything you added recently is missing."
|
|
||||||
);
|
);
|
||||||
Ok((accounts, true))
|
return Ok((Vec::new(), true));
|
||||||
}
|
}
|
||||||
None => Err(Error::invalid(format!(
|
|
||||||
"{} exists but could not be parsed ({primary_err}), and the backup at {} \
|
Err(Error::invalid(format!(
|
||||||
could not be used either. Refusing to start with an empty vault — move the \
|
"{damage} Refusing to start with an empty vault, because that would look \
|
||||||
file aside to start fresh.",
|
exactly like a vault you had emptied on purpose. If those accounts are gone, \
|
||||||
path.display(),
|
either move the files aside to start fresh, or set DA_VAULT_RESET=1 to do the \
|
||||||
backup_path.display()
|
same thing from the Unraid template — either way a copy of the unreadable file \
|
||||||
))),
|
is kept."
|
||||||
},
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse the backup, or `None` if it is missing or not parseable as a vault.
|
/// Describe exactly what is on disk and why nothing could be loaded.
|
||||||
|
///
|
||||||
|
/// The wording has to distinguish the cases, because they need opposite
|
||||||
|
/// responses. A backup that is present and unreadable means both copies are
|
||||||
|
/// gone and the accounts must be re-added. A backup that was never created —
|
||||||
|
/// a container that has not completed a single write since backups were
|
||||||
|
/// introduced — means there is simply nothing on disk to recover. The previous
|
||||||
|
/// message said "the backup could not be used either" for both, which sent an
|
||||||
|
/// operator hunting for a file that did not exist.
|
||||||
|
///
|
||||||
|
/// Returns only the description. The decision to refuse, and the advice that
|
||||||
|
/// goes with it, belongs to the caller: a caller that has been told to reset is
|
||||||
|
/// not refusing anything, and must not say that it is.
|
||||||
|
fn describe_damage(path: &Path, backup_path: &Path, primary_err: &str) -> String {
|
||||||
|
let backup_note = match std::fs::metadata(backup_path) {
|
||||||
|
Ok(m) if m.len() == 0 => format!(
|
||||||
|
"the backup at {} is empty as well, so there is nothing to recover",
|
||||||
|
backup_path.display()
|
||||||
|
),
|
||||||
|
Ok(_) => format!(
|
||||||
|
"the backup at {} exists but could not be parsed either",
|
||||||
|
backup_path.display()
|
||||||
|
),
|
||||||
|
Err(_) => format!(
|
||||||
|
"there is no backup at {} — this vault has not been written since \
|
||||||
|
backups were introduced, so none was ever created",
|
||||||
|
backup_path.display()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// A quarantine file means a previous boot already recovered this vault and
|
||||||
|
// moved a damaged copy aside. That copy is the only other candidate left.
|
||||||
|
let quarantined: Vec<String> = std::fs::read_dir(path.parent().unwrap_or(Path::new(".")))
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| n.starts_with("vault.json.corrupt-"))
|
||||||
|
.collect();
|
||||||
|
let quarantine_note = if quarantined.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
" Files kept from earlier recoveries that may be worth inspecting: {}.",
|
||||||
|
quarantined.join(", ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
format!("vault.json could not be parsed ({primary_err}), and {backup_note}.{quarantine_note}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the backup, or `None` if it is absent or not parseable as a vault.
|
||||||
fn read_backup(backup_path: &Path) -> Option<Vec<Account>> {
|
fn read_backup(backup_path: &Path) -> Option<Vec<Account>> {
|
||||||
let text = std::fs::read_to_string(backup_path).ok()?;
|
let text = std::fs::read_to_string(backup_path).ok()?;
|
||||||
serde_json::from_str::<VaultFile>(&text)
|
serde_json::from_str::<VaultFile>(&text)
|
||||||
@@ -1318,6 +1439,120 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The refusal has to say which of the two situations the operator is in:
|
||||||
|
/// both copies gone, or a backup that was never created. The old wording
|
||||||
|
/// said "the backup could not be used either" for both, which sent someone
|
||||||
|
/// hunting for a file that did not exist.
|
||||||
|
#[test]
|
||||||
|
fn the_refusal_distinguishes_a_missing_backup_from_a_broken_one() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||||
|
|
||||||
|
let missing = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
// No .bak at all: this is the state a container is in when it has not
|
||||||
|
// completed a single write since backups were introduced.
|
||||||
|
assert!(
|
||||||
|
missing.contains("no backup at") && missing.contains("has not been written since"),
|
||||||
|
"a missing backup must be named as missing: {missing}"
|
||||||
|
);
|
||||||
|
|
||||||
|
std::fs::write(dir.path().join("vault.json.bak"), b"garbage").unwrap();
|
||||||
|
let broken = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
broken.contains("exists but could not be parsed"),
|
||||||
|
"a backup that is present but unreadable must say so: {broken}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_backup_is_reported_as_empty_not_as_broken() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json.bak"), b"").unwrap();
|
||||||
|
|
||||||
|
let msg = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("is empty as well"),
|
||||||
|
"zero bytes is not the same as unparseable: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_refusal_points_at_anything_kept_by_an_earlier_recovery() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.path().join("vault.json.corrupt-1700000000"),
|
||||||
|
b"an earlier damaged copy",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let msg = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap())
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
assert!(
|
||||||
|
msg.contains("vault.json.corrupt-1700000000"),
|
||||||
|
"a copy from an earlier recovery is the only other thing worth \
|
||||||
|
looking at, so it should be named: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_is_only_requested_by_a_deliberate_value() {
|
||||||
|
assert!(looks_like_reset("1"));
|
||||||
|
assert!(looks_like_reset("yes"));
|
||||||
|
assert!(looks_like_reset(" true "));
|
||||||
|
// Explicitly off, and the ways an unset variable shows up.
|
||||||
|
assert!(!looks_like_reset("0"));
|
||||||
|
assert!(!looks_like_reset("false"));
|
||||||
|
assert!(!looks_like_reset("FALSE"));
|
||||||
|
assert!(!looks_like_reset(""));
|
||||||
|
assert!(!looks_like_reset(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The escape hatch itself. Serialized against the greeting test because
|
||||||
|
/// both write process environment variables, which are shared by every
|
||||||
|
/// test in the binary.
|
||||||
|
#[test]
|
||||||
|
fn da_vault_reset_boots_empty_and_preserves_the_unreadable_file() {
|
||||||
|
let _guard = env_guard();
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json.bak"), b"").unwrap();
|
||||||
|
|
||||||
|
std::env::set_var("DA_VAULT_RESET", "1");
|
||||||
|
let loaded = Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap());
|
||||||
|
std::env::remove_var("DA_VAULT_RESET");
|
||||||
|
|
||||||
|
let v = loaded.expect("DA_VAULT_RESET must get an operator past a dead vault");
|
||||||
|
assert!(v.is_empty());
|
||||||
|
|
||||||
|
// The unreadable original must not have been silently overwritten —
|
||||||
|
// it is the only thing anyone could still try to salvage.
|
||||||
|
let kept: Vec<_> = std::fs::read_dir(dir.path())
|
||||||
|
.unwrap()
|
||||||
|
.flatten()
|
||||||
|
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||||
|
.filter(|n| n.starts_with("vault.json.corrupt-"))
|
||||||
|
.collect();
|
||||||
|
assert_eq!(kept.len(), 1, "the original must be kept: {kept:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn without_reset_a_dead_vault_still_refuses() {
|
||||||
|
let _guard = env_guard();
|
||||||
|
std::env::remove_var("DA_VAULT_RESET");
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
std::fs::write(dir.path().join("vault.json"), b"").unwrap();
|
||||||
|
assert!(Vault::load(dir.path(), VaultKey::from_bytes(&[1u8; 32]).unwrap()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_fresh_install_is_not_an_error() {
|
fn a_fresh_install_is_not_an_error() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## An accurate message, and a way out
|
||||||
|
|
||||||
|
No functional change to how the vault is written or recovered. This fixes what
|
||||||
|
the service tells you when recovery is not possible, and adds an escape hatch
|
||||||
|
for the case it is not.
|
||||||
|
|
||||||
|
### The message said the wrong thing
|
||||||
|
|
||||||
|
When neither `vault.json` nor `vault.json.bak` could be loaded, the error read:
|
||||||
|
|
||||||
|
> ...and the backup at /var/lib/discord-authenticator/vault.json.bak could not
|
||||||
|
> be used either.
|
||||||
|
|
||||||
|
That is true for three quite different situations, and it reads as though a
|
||||||
|
backup exists. The case that actually bites is the one where **no backup was
|
||||||
|
ever created**: a vault that has not completed a single write since v0.1.3
|
||||||
|
introduced backups has no `.bak` at all, so there is nothing on disk to recover
|
||||||
|
and no file to go looking for. The old wording sent you looking anyway.
|
||||||
|
|
||||||
|
It now says which situation you are in:
|
||||||
|
|
||||||
|
- `there is no backup at … — this vault has not been written since backups were
|
||||||
|
introduced, so none was ever created`
|
||||||
|
- `the backup at … exists but could not be parsed either`
|
||||||
|
- `the backup at … is empty as well, so there is nothing to recover`
|
||||||
|
|
||||||
|
It also lists any `vault.json.corrupt-<timestamp>` files left by earlier
|
||||||
|
recoveries, since those are the only other thing worth inspecting.
|
||||||
|
|
||||||
|
### DA_VAULT_RESET
|
||||||
|
|
||||||
|
Refusing to start on an unreadable vault is still the default — booting empty
|
||||||
|
would look identical to a vault you had emptied on purpose, and the whole point
|
||||||
|
is that the service cannot tell the difference. But refusing must not be a dead
|
||||||
|
end, and having to edit files by hand inside a container that will not start is
|
||||||
|
a poor answer at 2am.
|
||||||
|
|
||||||
|
Set `DA_VAULT_RESET=1` and the service boots with an empty vault instead. It
|
||||||
|
moves the unreadable file to `vault.json.corrupt-<timestamp>` *before* writing
|
||||||
|
the fresh one, so nothing is overwritten, and the log line names where it went.
|
||||||
|
Remove the variable afterwards: it has no effect on a vault that loads, but
|
||||||
|
leaving it set means the next genuinely-broken vault boots empty rather than
|
||||||
|
telling you about it.
|
||||||
|
|
||||||
|
In the Unraid template: Docker → the container → **Add another Path, Variable,
|
||||||
|
Port or Label** → Variable, Key `DA_VAULT_RESET`, Value `1`.
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
Nothing to do. Vault format unchanged, no accounts to re-import. If you are
|
||||||
|
currently stuck on the old message, this release does not recover the accounts
|
||||||
|
either — it just tells you why, and lets you start clean without leaving the UI.
|
||||||
Reference in new issue
Block a user