fix: harden config write auditing

This commit is contained in:
Peter Steinberger
2026-03-28 03:54:45 +00:00
parent 5853b1aab8
commit c5c9640374
6 changed files with 229 additions and 27 deletions

View File

@@ -768,10 +768,8 @@ struct DebugSettings: View {
}
private func loadSessionStorePath() {
let url = self.configURL()
let parsed = OpenClawConfigFile.loadDict()
guard
let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let session = parsed["session"] as? [String: Any],
let path = session["store"] as? String
else {
@@ -783,28 +781,14 @@ struct DebugSettings: View {
private func saveSessionStorePath() {
let trimmed = self.sessionStorePath.trimmingCharacters(in: .whitespacesAndNewlines)
var root: [String: Any] = [:]
let url = self.configURL()
if let data = try? Data(contentsOf: url),
let parsed = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
{
root = parsed
}
var root = OpenClawConfigFile.loadDict()
var session = root["session"] as? [String: Any] ?? [:]
session["store"] = trimmed.isEmpty ? SessionLoader.defaultStorePath : trimmed
root["session"] = session
do {
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
self.sessionStoreSaveError = nil
} catch {
self.sessionStoreSaveError = error.localizedDescription
}
OpenClawConfigFile.saveDict(root)
self.sessionStoreSaveError = nil
}
private var bindingOverride: Binding<String> {
@@ -828,10 +812,6 @@ struct DebugSettings: View {
private var canRestartGateway: Bool {
self.state.connectionMode == .local
}
private func configURL() -> URL {
OpenClawPaths.configURL
}
}
extension DebugSettings {

View File

@@ -44,6 +44,7 @@ enum OpenClawConfigFile {
let previousData = try? Data(contentsOf: url)
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
let previousBytes = previousData?.count
let previousAttributes = try? FileManager().attributesOfItem(atPath: url.path)
let hadMetaBefore = self.hasMeta(previousRoot)
let gatewayModeBefore = self.gatewayMode(previousRoot)
@@ -57,6 +58,7 @@ enum OpenClawConfigFile {
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
let nextBytes = data.count
let nextAttributes = try? FileManager().attributesOfItem(atPath: url.path)
let gatewayModeAfter = self.gatewayMode(output)
let suspicious = self.configWriteSuspiciousReasons(
existsBefore: previousData != nil,
@@ -74,6 +76,18 @@ enum OpenClawConfigFile {
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": nextBytes,
"previousDev": self.fileSystemNumber(previousAttributes?[.systemNumber]) ?? NSNull(),
"nextDev": self.fileSystemNumber(nextAttributes?[.systemNumber]) ?? NSNull(),
"previousIno": self.fileSystemNumber(previousAttributes?[.systemFileNumber]) ?? NSNull(),
"nextIno": self.fileSystemNumber(nextAttributes?[.systemFileNumber]) ?? NSNull(),
"previousMode": self.posixMode(previousAttributes?[.posixPermissions]) ?? NSNull(),
"nextMode": self.posixMode(nextAttributes?[.posixPermissions]) ?? NSNull(),
"previousNlink": self.fileAttributeInt(previousAttributes?[.referenceCount]) ?? NSNull(),
"nextNlink": self.fileAttributeInt(nextAttributes?[.referenceCount]) ?? NSNull(),
"previousUid": self.fileAttributeInt(previousAttributes?[.ownerAccountID]) ?? NSNull(),
"nextUid": self.fileAttributeInt(nextAttributes?[.ownerAccountID]) ?? NSNull(),
"previousGid": self.fileAttributeInt(previousAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"nextGid": self.fileAttributeInt(nextAttributes?[.groupOwnerAccountID]) ?? NSNull(),
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
@@ -384,6 +398,23 @@ enum OpenClawConfigFile {
return date.timeIntervalSince1970 * 1000
}
private static func fileAttributeInt(_ value: Any?) -> Int? {
if let number = value as? NSNumber { return number.intValue }
if let number = value as? Int { return number }
return nil
}
private static func fileSystemNumber(_ value: Any?) -> String? {
if let number = value as? NSNumber { return number.stringValue }
if let number = value as? Int { return String(number) }
return nil
}
private static func posixMode(_ value: Any?) -> Int? {
guard let mode = self.fileAttributeInt(value) else { return nil }
return mode & 0o777
}
private static func configFingerprint(
data: Data,
root: [String: Any]?,
@@ -396,6 +427,12 @@ enum OpenClawConfigFile {
"bytes": data.count,
"mtimeMs": self.fileTimestampMs(attributes?[.modificationDate]) ?? NSNull(),
"ctimeMs": self.fileTimestampMs(attributes?[.creationDate]) ?? NSNull(),
"dev": self.fileSystemNumber(attributes?[.systemNumber]) ?? NSNull(),
"ino": self.fileSystemNumber(attributes?[.systemFileNumber]) ?? NSNull(),
"mode": self.posixMode(attributes?[.posixPermissions]) ?? NSNull(),
"nlink": self.fileAttributeInt(attributes?[.referenceCount]) ?? NSNull(),
"uid": self.fileAttributeInt(attributes?[.ownerAccountID]) ?? NSNull(),
"gid": self.fileAttributeInt(attributes?[.groupOwnerAccountID]) ?? NSNull(),
"hasMeta": self.hasMeta(root),
"gatewayMode": self.gatewayMode(root) ?? NSNull(),
"observedAt": observedAt,
@@ -408,6 +445,12 @@ enum OpenClawConfigFile {
(left["bytes"] as? Int) == (right["bytes"] as? Int) &&
(left["mtimeMs"] as? Double) == (right["mtimeMs"] as? Double) &&
(left["ctimeMs"] as? Double) == (right["ctimeMs"] as? Double) &&
(left["dev"] as? String) == (right["dev"] as? String) &&
(left["ino"] as? String) == (right["ino"] as? String) &&
(left["mode"] as? Int) == (right["mode"] as? Int) &&
(left["nlink"] as? Int) == (right["nlink"] as? Int) &&
(left["uid"] as? Int) == (right["uid"] as? Int) &&
(left["gid"] as? Int) == (right["gid"] as? Int) &&
(left["hasMeta"] as? Bool) == (right["hasMeta"] as? Bool) &&
(left["gatewayMode"] as? String) == (right["gatewayMode"] as? String)
}
@@ -509,6 +552,12 @@ enum OpenClawConfigFile {
"bytes": current["bytes"] ?? NSNull(),
"mtimeMs": current["mtimeMs"] ?? NSNull(),
"ctimeMs": current["ctimeMs"] ?? NSNull(),
"dev": current["dev"] ?? NSNull(),
"ino": current["ino"] ?? NSNull(),
"mode": current["mode"] ?? NSNull(),
"nlink": current["nlink"] ?? NSNull(),
"uid": current["uid"] ?? NSNull(),
"gid": current["gid"] ?? NSNull(),
"hasMeta": current["hasMeta"] ?? false,
"gatewayMode": current["gatewayMode"] ?? NSNull(),
"suspicious": suspicious,
@@ -516,11 +565,23 @@ enum OpenClawConfigFile {
"lastKnownGoodBytes": lastKnownGood?["bytes"] ?? NSNull(),
"lastKnownGoodMtimeMs": lastKnownGood?["mtimeMs"] ?? NSNull(),
"lastKnownGoodCtimeMs": lastKnownGood?["ctimeMs"] ?? NSNull(),
"lastKnownGoodDev": lastKnownGood?["dev"] ?? NSNull(),
"lastKnownGoodIno": lastKnownGood?["ino"] ?? NSNull(),
"lastKnownGoodMode": lastKnownGood?["mode"] ?? NSNull(),
"lastKnownGoodNlink": lastKnownGood?["nlink"] ?? NSNull(),
"lastKnownGoodUid": lastKnownGood?["uid"] ?? NSNull(),
"lastKnownGoodGid": lastKnownGood?["gid"] ?? NSNull(),
"lastKnownGoodGatewayMode": lastKnownGood?["gatewayMode"] ?? NSNull(),
"backupHash": backup?["hash"] ?? NSNull(),
"backupBytes": backup?["bytes"] ?? NSNull(),
"backupMtimeMs": backup?["mtimeMs"] ?? NSNull(),
"backupCtimeMs": backup?["ctimeMs"] ?? NSNull(),
"backupDev": backup?["dev"] ?? NSNull(),
"backupIno": backup?["ino"] ?? NSNull(),
"backupMode": backup?["mode"] ?? NSNull(),
"backupNlink": backup?["nlink"] ?? NSNull(),
"backupUid": backup?["uid"] ?? NSNull(),
"backupGid": backup?["gid"] ?? NSNull(),
"backupGatewayMode": backup?["gatewayMode"] ?? NSNull(),
"clobberedPath": clobberedPath ?? NSNull(),
])

View File

@@ -133,6 +133,10 @@ struct OpenClawConfigFileTests {
#expect(auditRoot?["event"] as? String == "config.write")
#expect(auditRoot?["result"] as? String == "success")
#expect(auditRoot?["configPath"] as? String == configPath.path)
#expect(auditRoot?["previousMode"] is NSNumber)
#expect(auditRoot?["nextMode"] is NSNumber)
#expect(auditRoot?["previousIno"] as? String != nil)
#expect(auditRoot?["nextIno"] as? String != nil)
}
}
@@ -188,6 +192,10 @@ struct OpenClawConfigFileTests {
let auditRoot = try JSONSerialization.jsonObject(with: Data(observeLine.utf8)) as? [String: Any]
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
#expect(auditRoot?["configPath"] as? String == configPath.path)
#expect(auditRoot?["mode"] is NSNumber)
#expect(auditRoot?["ino"] as? String != nil)
#expect(auditRoot?["lastKnownGoodMode"] is NSNumber)
#expect(auditRoot?["backupMode"] is NSNumber)
let suspicious = auditRoot?["suspicious"] as? [String] ?? []
#expect(suspicious.contains("gateway-mode-missing-vs-last-good"))
#expect(suspicious.contains("update-channel-only-root"))

View File

@@ -76,6 +76,10 @@ describe("config io observe", () => {
expect(observe?.source).toBe("config-io");
expect(observe?.configPath).toBe(configPath);
expect(observe?.valid).toBe(true);
expect(observe?.mode).toBeTypeOf("number");
expect(observe?.ino).toBeTypeOf("string");
expect(observe?.lastKnownGoodMode).toBeTypeOf("number");
expect(observe?.backupMode).toBeTypeOf("number");
expect(observe?.suspicious).toEqual(
expect.arrayContaining(["gateway-mode-missing-vs-last-good", "update-channel-only-root"]),
);
@@ -157,6 +161,8 @@ describe("config io observe", () => {
expect(observe).toBeDefined();
expect(observe?.backupHash).toBeTypeOf("string");
expect(observe?.backupIno).toBeTypeOf("string");
expect(observe?.lastKnownGoodIno ?? null).toBeNull();
expect(observe?.suspicious).toEqual(
expect.arrayContaining(["gateway-mode-missing-vs-last-good", "update-channel-only-root"]),
);

View File

@@ -110,6 +110,18 @@ type ConfigWriteAuditRecord = {
nextHash: string | null;
previousBytes: number | null;
nextBytes: number | null;
previousDev: string | null;
nextDev: string | null;
previousIno: string | null;
nextIno: string | null;
previousMode: number | null;
nextMode: number | null;
previousNlink: number | null;
nextNlink: number | null;
previousUid: number | null;
nextUid: number | null;
previousGid: number | null;
nextGid: number | null;
changedPathCount: number | null;
hasMetaBefore: boolean;
hasMetaAfter: boolean;
@@ -125,6 +137,12 @@ type ConfigHealthFingerprint = {
bytes: number;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
observedAt: string;
@@ -156,6 +174,12 @@ type ConfigObserveAuditRecord = {
bytes: number | null;
mtimeMs: number | null;
ctimeMs: number | null;
dev: string | null;
ino: string | null;
mode: number | null;
nlink: number | null;
uid: number | null;
gid: number | null;
hasMeta: boolean;
gatewayMode: string | null;
suspicious: string[];
@@ -163,11 +187,23 @@ type ConfigObserveAuditRecord = {
lastKnownGoodBytes: number | null;
lastKnownGoodMtimeMs: number | null;
lastKnownGoodCtimeMs: number | null;
lastKnownGoodDev: string | null;
lastKnownGoodIno: string | null;
lastKnownGoodMode: number | null;
lastKnownGoodNlink: number | null;
lastKnownGoodUid: number | null;
lastKnownGoodGid: number | null;
lastKnownGoodGatewayMode: string | null;
backupHash: string | null;
backupBytes: number | null;
backupMtimeMs: number | null;
backupCtimeMs: number | null;
backupDev: string | null;
backupIno: string | null;
backupMode: number | null;
backupNlink: number | null;
backupUid: number | null;
backupGid: number | null;
backupGatewayMode: string | null;
clobberedPath: string | null;
};
@@ -596,6 +632,33 @@ function resolveConfigHealthStatePath(env: NodeJS.ProcessEnv, homedir: () => str
return path.join(resolveStateDir(env, homedir), "logs", CONFIG_HEALTH_STATE_FILENAME);
}
function normalizeStatNumber(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function normalizeStatId(value: number | bigint | null | undefined): string | null {
if (typeof value === "bigint") {
return value.toString();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(value);
}
return null;
}
function resolveConfigStatMetadata(
stat: fs.Stats | null,
): Pick<ConfigHealthFingerprint, "dev" | "ino" | "mode" | "nlink" | "uid" | "gid"> {
return {
dev: normalizeStatId(stat?.dev ?? null),
ino: normalizeStatId(stat?.ino ?? null),
mode: normalizeStatNumber(stat ? stat.mode & 0o777 : null),
nlink: normalizeStatNumber(stat?.nlink ?? null),
uid: normalizeStatNumber(stat?.uid ?? null),
gid: normalizeStatNumber(stat?.gid ?? null),
};
}
function resolveConfigWriteSuspiciousReasons(params: {
existsBefore: boolean;
previousBytes: number | null;
@@ -788,6 +851,7 @@ async function readConfigFingerprintForPath(
bytes: Buffer.byteLength(raw, "utf-8"),
mtimeMs: stat?.mtimeMs ?? null,
ctimeMs: stat?.ctimeMs ?? null,
...resolveConfigStatMetadata(stat),
hasMeta: hasConfigMeta(parsed),
gatewayMode: resolveGatewayMode(parsed),
observedAt: new Date().toISOString(),
@@ -811,6 +875,7 @@ function readConfigFingerprintForPathSync(
bytes: Buffer.byteLength(raw, "utf-8"),
mtimeMs: stat?.mtimeMs ?? null,
ctimeMs: stat?.ctimeMs ?? null,
...resolveConfigStatMetadata(stat),
hasMeta: hasConfigMeta(parsed),
gatewayMode: resolveGatewayMode(parsed),
observedAt: new Date().toISOString(),
@@ -874,6 +939,12 @@ function sameFingerprint(
left.bytes === right.bytes &&
left.mtimeMs === right.mtimeMs &&
left.ctimeMs === right.ctimeMs &&
left.dev === right.dev &&
left.ino === right.ino &&
left.mode === right.mode &&
left.nlink === right.nlink &&
left.uid === right.uid &&
left.gid === right.gid &&
left.hasMeta === right.hasMeta &&
left.gatewayMode === right.gatewayMode
);
@@ -894,6 +965,7 @@ async function observeConfigSnapshot(
bytes: Buffer.byteLength(snapshot.raw, "utf-8"),
mtimeMs: stat?.mtimeMs ?? null,
ctimeMs: stat?.ctimeMs ?? null,
...resolveConfigStatMetadata(stat),
hasMeta: hasConfigMeta(snapshot.parsed),
gatewayMode: resolveGatewayMode(snapshot.resolved),
observedAt: now,
@@ -963,6 +1035,12 @@ async function observeConfigSnapshot(
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
dev: current.dev,
ino: current.ino,
mode: current.mode,
nlink: current.nlink,
uid: current.uid,
gid: current.gid,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
@@ -970,11 +1048,23 @@ async function observeConfigSnapshot(
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupDev: backup?.dev ?? null,
backupIno: backup?.ino ?? null,
backupMode: backup?.mode ?? null,
backupNlink: backup?.nlink ?? null,
backupUid: backup?.uid ?? null,
backupGid: backup?.gid ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
});
@@ -1001,6 +1091,7 @@ function observeConfigSnapshotSync(
bytes: Buffer.byteLength(snapshot.raw, "utf-8"),
mtimeMs: stat?.mtimeMs ?? null,
ctimeMs: stat?.ctimeMs ?? null,
...resolveConfigStatMetadata(stat),
hasMeta: hasConfigMeta(snapshot.parsed),
gatewayMode: resolveGatewayMode(snapshot.resolved),
observedAt: now,
@@ -1070,6 +1161,12 @@ function observeConfigSnapshotSync(
bytes: current.bytes,
mtimeMs: current.mtimeMs,
ctimeMs: current.ctimeMs,
dev: current.dev,
ino: current.ino,
mode: current.mode,
nlink: current.nlink,
uid: current.uid,
gid: current.gid,
hasMeta: current.hasMeta,
gatewayMode: current.gatewayMode,
suspicious,
@@ -1077,11 +1174,23 @@ function observeConfigSnapshotSync(
lastKnownGoodBytes: entry.lastKnownGood?.bytes ?? null,
lastKnownGoodMtimeMs: entry.lastKnownGood?.mtimeMs ?? null,
lastKnownGoodCtimeMs: entry.lastKnownGood?.ctimeMs ?? null,
lastKnownGoodDev: entry.lastKnownGood?.dev ?? null,
lastKnownGoodIno: entry.lastKnownGood?.ino ?? null,
lastKnownGoodMode: entry.lastKnownGood?.mode ?? null,
lastKnownGoodNlink: entry.lastKnownGood?.nlink ?? null,
lastKnownGoodUid: entry.lastKnownGood?.uid ?? null,
lastKnownGoodGid: entry.lastKnownGood?.gid ?? null,
lastKnownGoodGatewayMode: entry.lastKnownGood?.gatewayMode ?? null,
backupHash: backup?.hash ?? null,
backupBytes: backup?.bytes ?? null,
backupMtimeMs: backup?.mtimeMs ?? null,
backupCtimeMs: backup?.ctimeMs ?? null,
backupDev: backup?.dev ?? null,
backupIno: backup?.ino ?? null,
backupMode: backup?.mode ?? null,
backupNlink: backup?.nlink ?? null,
backupUid: backup?.uid ?? null,
backupGid: backup?.gid ?? null,
backupGatewayMode: backup?.gatewayMode ?? null,
clobberedPath,
});
@@ -1777,6 +1886,9 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
const previousBytes =
typeof snapshot.raw === "string" ? Buffer.byteLength(snapshot.raw, "utf-8") : null;
const nextBytes = Buffer.byteLength(json, "utf-8");
const previousStat = snapshot.exists
? await deps.fs.promises.stat(configPath).catch(() => null)
: null;
const hasMetaBefore = hasConfigMeta(snapshot.parsed);
const hasMetaAfter = hasConfigMeta(stampedOutputConfig);
const gatewayModeBefore = resolveGatewayMode(snapshot.resolved);
@@ -1842,6 +1954,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
nextHash,
previousBytes,
nextBytes,
previousDev: resolveConfigStatMetadata(previousStat).dev,
nextDev: null,
previousIno: resolveConfigStatMetadata(previousStat).ino,
nextIno: null,
previousMode: resolveConfigStatMetadata(previousStat).mode,
nextMode: null,
previousNlink: resolveConfigStatMetadata(previousStat).nlink,
nextNlink: null,
previousUid: resolveConfigStatMetadata(previousStat).uid,
nextUid: null,
previousGid: resolveConfigStatMetadata(previousStat).gid,
nextGid: null,
changedPathCount: typeof changedPathCount === "number" ? changedPathCount : null,
hasMetaBefore,
hasMetaAfter,
@@ -1849,7 +1973,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
gatewayModeAfter,
suspicious: suspiciousReasons,
};
const appendWriteAudit = async (result: ConfigWriteAuditResult, err?: unknown) => {
const appendWriteAudit = async (
result: ConfigWriteAuditResult,
err?: unknown,
nextStat?: fs.Stats | null,
) => {
const errorCode =
err && typeof err === "object" && "code" in err && typeof err.code === "string"
? err.code
@@ -1858,11 +1986,18 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
err && typeof err === "object" && "message" in err && typeof err.message === "string"
? err.message
: undefined;
const nextMetadata = resolveConfigStatMetadata(nextStat ?? null);
await appendConfigAuditRecord(deps, {
...auditRecordBase,
result,
nextHash: result === "failed" ? null : auditRecordBase.nextHash,
nextBytes: result === "failed" ? null : auditRecordBase.nextBytes,
nextDev: result === "failed" ? null : nextMetadata.dev,
nextIno: result === "failed" ? null : nextMetadata.ino,
nextMode: result === "failed" ? null : nextMetadata.mode,
nextNlink: result === "failed" ? null : nextMetadata.nlink,
nextUid: result === "failed" ? null : nextMetadata.uid,
nextGid: result === "failed" ? null : nextMetadata.gid,
errorCode,
errorMessage,
});
@@ -1898,7 +2033,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
});
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit("copy-fallback");
await appendWriteAudit(
"copy-fallback",
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
return;
}
await deps.fs.promises.unlink(tmp).catch(() => {
@@ -1908,7 +2047,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
}
logConfigOverwrite();
logConfigWriteAnomalies();
await appendWriteAudit("rename");
await appendWriteAudit(
"rename",
undefined,
await deps.fs.promises.stat(configPath).catch(() => null),
);
} catch (err) {
await appendWriteAudit("failed", err);
throw err;

View File

@@ -541,6 +541,10 @@ describe("config io write", () => {
expect(last.hasMetaAfter).toBe(true);
expect(last.previousHash).toBeTypeOf("string");
expect(last.nextHash).toBeTypeOf("string");
expect(last.previousMode).toBeTypeOf("number");
expect(last.nextMode).toBeTypeOf("number");
expect(last.previousIno).toBeTypeOf("string");
expect(last.nextIno).toBeTypeOf("string");
expect(last.result === "rename" || last.result === "copy-fallback").toBe(true);
});
});