Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/decrypt-child.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,12 @@ function decryptValue(value, cryptor) {
function tryDecryptString(value, cryptor) {
if (!looksEncrypted(value)) return value;
try {
// decrypt() returns null when the auth tag fails (wrong key, or the string
// was never encrypted) and "" when the ciphertext authenticates to empty
// plaintext (e.g. a blank password). Only null is a failure — keep a valid
// empty result instead of falling back to the raw encrypted blob.
const decrypted = cryptor.decrypt(value);
if (!decrypted || !looksTextual(decrypted)) return value;
if (decrypted === null || !looksTextual(decrypted)) return value;
return decrypted;
} catch {
return value;
Expand Down
58 changes: 58 additions & 0 deletions lib/format.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ import path from "node:path";
import { ensurePrivateDir, writePrivateFile } from "./paths.js";

export function collectEntities(root) {
const byEntityName = collectByEntityName(root);
if (byEntityName.hosts.length > 0) return byEntityName;

const byStores = collectByStores(root);
if (byStores && byStores.hosts.length > 0) return byStores;

return byEntityName;
}

// Legacy layout: entities tagged with an `entityName` field and nested inline.
function collectByEntityName(root) {
const entities = {
hosts: [],
identities: new Map(),
Expand All @@ -28,6 +39,53 @@ export function collectEntities(root) {
return entities;
}

// Current layout: entities live in dedicated IndexedDB stores and reference each
// other by { id, local_id }. Resolve hosts -> ssh_configs -> ssh_identities -> keys.
function collectByStores(root) {
if (!root || !Array.isArray(root.databases)) return null;

const storeRecords = (dbName, storeName = dbName) => {
const db = root.databases.find((entry) => entry?.name === dbName);
const store = db?.stores?.[storeName];
return Array.isArray(store) ? store : [];
};

const indexByIds = (records) => {
const map = new Map();
for (const record of records) {
const value = record?.value;
if (!value || typeof value !== "object") continue;
for (const id of entityIds(value)) map.set(id, value);
}
return map;
};

const configs = indexByIds(storeRecords("ssh_configs"));
const identities = indexByIds(storeRecords("ssh_identities"));
const sshKeys = indexByIds(storeRecords("keys"));
const groups = indexByIds(storeRecords("groups"));

const hosts = [];
for (const record of storeRecords("hosts")) {
const host = record?.value;
if (!host || typeof host !== "object" || !host.address) continue;

const sshConfig = resolveRef(host.ssh_config, configs) || host.ssh_config || {};
const group = resolveRef(host.group, groups) || host.group;
hosts.push({ ...host, ssh_config: sshConfig, group });
}

return { hosts, identities, sshKeys };
}

function resolveRef(ref, map) {
if (!ref || typeof ref !== "object") return null;
for (const id of entityIds(ref)) {
if (map.has(id)) return map.get(id);
}
return null;
}

export function buildExportFiles({ decrypted, outDir, includeSecrets }) {
const entities = collectEntities(decrypted);
const keyDir = path.join(outDir, "keys");
Expand Down
87 changes: 87 additions & 0 deletions test/format.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,93 @@ test("collects host, identity, and key entities from nested IndexedDB export", (
assert.equal(entities.hosts.length, 1);
});

test("collects entities from the store-based layout linked by id/local_id", () => {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "termius-format-test-"));
const decrypted = {
databases: [
{
name: "hosts",
stores: {
hosts: [
{
value: {
local_id: 32,
id: 24765063,
address: "203.0.113.10",
label: "prod one",
group: { id: 2083970, local_id: 5 },
ssh_config: { id: 25419430, local_id: 32 },
},
},
// Key-auth host with a blank password (decrypts to "").
{
value: {
local_id: 33,
id: 24765064,
address: "203.0.113.11",
label: "key only",
group: null,
ssh_config: { id: 25419431, local_id: 33 },
},
},
],
},
},
{
name: "ssh_configs",
stores: {
ssh_configs: [
{ value: { local_id: 32, id: 25419430, port: 2222, identity: { id: 16245555, local_id: 20 } } },
{ value: { local_id: 33, id: 25419431, port: null, identity: { id: 16245556, local_id: 21 } } },
],
},
},
{
name: "ssh_identities",
stores: {
ssh_identities: [
{ value: { local_id: 20, id: 16245555, username: "root", password: "secret", ssh_key: { id: 1962263, local_id: 8 } } },
{ value: { local_id: 21, id: 16245556, username: "deploy", password: "" } },
],
},
},
{
name: "keys",
stores: {
keys: [{ value: { local_id: 8, id: 1962263, label: "prod-key", private_key: "dummy-private-key" } }],
},
},
{
name: "groups",
stores: {
groups: [{ value: { local_id: 5, id: 2083970, label: "production" } }],
},
},
],
};

const entities = collectEntities(decrypted);
assert.equal(entities.hosts.length, 2);

const summary = buildExportFiles({ decrypted, outDir, includeSecrets: true });
assert.equal(summary.hostCount, 2);
assert.equal(summary.passwordCount, 1);
assert.equal(summary.keyCount, 1);

const config = fs.readFileSync(path.join(outDir, "sshconfig"), "utf8");
assert.match(config, /Host prod-one/);
assert.match(config, /HostName 203\.0\.113\.10/);
assert.match(config, /Port 2222/);
assert.match(config, /User root/);
assert.match(config, /Host key-only/);

const csv = fs.readFileSync(path.join(outDir, "credentials.csv"), "utf8");
assert.match(csv, /secret/);
assert.match(csv, /production/);

fs.rmSync(outDir, { recursive: true, force: true });
});

test("writes portable export files and sanitizes host aliases", () => {
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "termius-format-test-"));
const decrypted = {
Expand Down