Compare commits

...

11 Commits

Author SHA1 Message Date
Joe Julian 54398837e6 Tighten browser inline overlay qualification 2026-04-13 17:23:41 -07:00
joejulian 989b41735f Merge pull request 'Normalize vault storage root views' (#5) from bugfix/vault-root-view into main
ci / lint-test (push) Successful in 3m25s
ci / build (push) Successful in 6m21s
2026-04-13 16:31:56 +00:00
Joe Julian a88b8a824b Add explicit templates vault view 2026-04-13 08:50:33 -07:00
Joe Julian eccfb886ee Normalize vault storage root on open and create 2026-04-13 07:29:51 -07:00
Joe Julian 6790399e24 Hide physical keepass paths in token and approval UX 2026-04-13 07:18:33 -07:00
Joe Julian 9882d3fc04 Authorize logical root API paths against vault storage 2026-04-13 07:15:16 -07:00
Joe Julian 59cd01f8e7 Use vault views for entry and recycle-bin state 2026-04-13 07:12:32 -07:00
Joe Julian ea30775eb7 Add explicit vault view factories 2026-04-13 07:02:44 -07:00
Joe Julian 0ce25a9712 Add failing vault view behavior tests 2026-04-13 07:00:51 -07:00
Joe Julian 32e6fc6c90 Break vault root bug into commit-sized todo items 2026-04-13 06:59:55 -07:00
Joe Julian e8a48fb7aa Track vault root view bugfix 2026-04-13 06:58:11 -07:00
20 changed files with 1699 additions and 167 deletions
+1
View File
@@ -135,6 +135,7 @@ These features are product requirements, not “nice to have” ideas.
## Delivery Discipline ## Delivery Discipline
- Treat bug fixes as the highest-priority items in `TODO.md`.
- Do not treat this product as complete until the stated requirements in this file are actually satisfied. - Do not treat this product as complete until the stated requirements in this file are actually satisfied.
- Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing. - Do not stop at a “good checkpoint” or “meaningful tranche” when required product capabilities are still missing.
- Continue iterating in test-first slices: - Continue iterating in test-first slices:
+209 -24
View File
@@ -36,21 +36,138 @@ function normalizeRole(rawRole) {
switch (String(rawRole || "").trim().toLowerCase()) { switch (String(rawRole || "").trim().toLowerCase()) {
case "password": case "password":
return "password"; return "password";
default: case "username":
return "username"; return "username";
default:
return "";
} }
} }
function lowerJoined(values) {
return values
.map((value) => String(value || "").trim().toLowerCase())
.filter(Boolean)
.join(" ");
}
function fieldHintText(input) {
if (!input || typeof input !== "object") {
return "";
}
const labels = input.labels ? Array.from(input.labels).map((label) => label.textContent || "") : [];
return lowerJoined([
input.getAttribute?.("type"),
input.getAttribute?.("name"),
input.getAttribute?.("id"),
input.autocomplete,
input.getAttribute?.("autocomplete"),
input.getAttribute?.("placeholder"),
input.getAttribute?.("aria-label"),
...labels
]);
}
function textLikeInputType(type) {
switch (String(type || "").toLowerCase()) {
case "":
case "text":
case "email":
case "tel":
case "number":
return true;
default:
return false;
}
}
function hintMatches(text, patterns) {
return patterns.some((pattern) => pattern.test(text));
}
function scopeHintText(scope) {
const isDocumentScope = typeof document !== "undefined" && scope === document;
if ((!scope || typeof scope !== "object") || (!isDocumentScope && typeof scope.querySelectorAll !== "function" && typeof scope.getAttribute !== "function")) {
return "";
}
const attrText = isDocumentScope ? "" : lowerJoined([
scope.getAttribute?.("id"),
scope.getAttribute?.("name"),
scope.getAttribute?.("class"),
scope.getAttribute?.("action"),
scope.getAttribute?.("aria-label")
]);
const headingText = lowerJoined(Array.from(scope.querySelectorAll?.("button, h1, h2, h3, h4, legend, label, [role='button']") || [])
.slice(0, 8)
.map((element) => element.textContent || ""));
return lowerJoined([attrText, headingText]);
}
function hasAuthFlowSignals(usernameInput, scope) {
if (usernameInput) {
return true;
}
return hintMatches(scopeHintText(scope), authScopePatterns);
}
const usernameHintPatterns = [
/\buser(name|id)?\b/,
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bemail\b/,
/\be-mail\b/,
/\baccount\b/,
/\bmember\b/,
/\bidentifier\b/
];
const nonLoginHintPatterns = [
/\bsearch\b/,
/\bquery\b/,
/\bfilter\b/,
/\bcomment\b/,
/\bmessage\b/,
/\bcontact\b/,
/\bcity\b/,
/\bstate\b/,
/\bpostal\b/,
/\bzip\b/,
/\bcoupon\b/,
/\bpromo\b/,
/\bnewsletter\b/,
/\bsubscribe\b/
];
const authScopePatterns = [
/\blog[\s_-]?in\b/,
/\bsign[\s_-]?in\b/,
/\bauth\b/,
/\bpassword\b/,
/\bpasscode\b/,
/\b2fa\b/,
/\btwo[\s-]?factor\b/,
/\bverify\b/,
/\baccount\b/
];
function describeFieldRole(input) { function describeFieldRole(input) {
const type = String(input?.getAttribute?.("type") || "").toLowerCase(); const type = String(input?.getAttribute?.("type") || "").toLowerCase();
if (type === "password") { if (type === "password") {
return "password"; return "password";
} }
const autocomplete = String(input?.autocomplete || "").toLowerCase(); if (!textLikeInputType(type)) {
if (autocomplete.includes("username") || autocomplete.includes("email")) { return "";
}
const hints = fieldHintText(input);
if (!hints) {
return "";
}
if (hintMatches(hints, nonLoginHintPatterns)) {
return "";
}
if (hintMatches(hints, usernameHintPatterns)) {
return "username"; return "username";
} }
return "username"; return "";
} }
function isUsernameCandidate(input) { function isUsernameCandidate(input) {
@@ -102,6 +219,40 @@ function firstVisibleUsername(scope) {
return visibleInputs(scope).find(isUsernameCandidate) || null; return visibleInputs(scope).find(isUsernameCandidate) || null;
} }
function authFlowCandidate(anchorInput) {
const scope = (typeof HTMLFormElement !== "undefined" && anchorInput?.form instanceof HTMLFormElement ? anchorInput.form : document);
const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || null;
if (!passwordInput) {
return null;
}
const associated = associatedFieldsForAnchor(anchorInput || passwordInput);
if (!hasAuthFlowSignals(associated.usernameInput, scope)) {
return null;
}
return {
usernameInput: associated.usernameInput,
passwordInput,
anchorInput: anchorInput || passwordInput,
scope
};
}
function loginCandidates() {
const candidates = [];
for (const passwordInput of visibleInputs(document).filter(isPasswordCandidate)) {
const candidate = authFlowCandidate(passwordInput);
if (!candidate) {
continue;
}
if (candidates.some((existing) => existing.passwordInput === candidate.passwordInput)) {
continue;
}
candidates.push(candidate);
}
return candidates;
}
function associatedFieldsForAnchor(anchorInput) { function associatedFieldsForAnchor(anchorInput) {
const scopeInputs = resolveFormInputs(anchorInput); const scopeInputs = resolveFormInputs(anchorInput);
const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document); const passwordInput = scopeInputs.find(isPasswordCandidate) || firstVisiblePassword(document);
@@ -121,11 +272,11 @@ function associatedFieldsForAnchor(anchorInput) {
} }
function buildFieldDescriptor(input, role) { function buildFieldDescriptor(input, role) {
if (!(input instanceof HTMLInputElement)) { if (typeof HTMLInputElement === "undefined" || !(input instanceof HTMLInputElement)) {
return null; return null;
} }
const normalizedRole = normalizeRole(role || describeFieldRole(input)); const normalizedRole = normalizeRole(role || describeFieldRole(input));
const form = input.form instanceof HTMLFormElement ? input.form : null; const form = typeof HTMLFormElement !== "undefined" && input.form instanceof HTMLFormElement ? input.form : null;
const scope = form || document; const scope = form || document;
const inputs = visibleInputs(scope); const inputs = visibleInputs(scope);
const fieldIndex = inputs.indexOf(input); const fieldIndex = inputs.indexOf(input);
@@ -145,6 +296,9 @@ function resolveFieldDescriptor(descriptor) {
return null; return null;
} }
const normalizedRole = normalizeRole(descriptor.role); const normalizedRole = normalizeRole(descriptor.role);
if (!normalizedRole) {
return null;
}
const forms = Array.from(document.forms || []); const forms = Array.from(document.forms || []);
const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null; const form = Number.isInteger(descriptor.formIndex) && descriptor.formIndex >= 0 ? forms[descriptor.formIndex] || null : null;
const scope = form || document; const scope = form || document;
@@ -200,20 +354,22 @@ function chooseFillTargets(targetDescriptor) {
function scanLoginFields() { function scanLoginFields() {
const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null; const activeElement = document.activeElement instanceof HTMLInputElement ? document.activeElement : null;
const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null; const activeUsable = activeElement && isVisibleInput(activeElement) ? activeElement : null;
const targets = chooseFillTargets(buildFieldDescriptor(activeUsable, describeFieldRole(activeUsable))); const explicitRole = describeFieldRole(activeUsable);
const anchorInput = activeUsable || targets.passwordInput || targets.usernameInput; const activeTargets = activeUsable ? authFlowCandidate(activeUsable) : null;
const focusTarget = buildFieldDescriptor(anchorInput, describeFieldRole(anchorInput)); const candidates = loginCandidates();
const allVisible = visibleInputs(document); const chosen = activeTargets || candidates[0] || null;
const roles = allVisible const anchorInput = activeUsable || chosen?.passwordInput || chosen?.usernameInput || null;
.filter((input) => isUsernameCandidate(input) || isPasswordCandidate(input)) const focusRole = explicitRole || describeFieldRole(anchorInput);
.map((input) => { const focusTarget = anchorInput ? buildFieldDescriptor(anchorInput, focusRole) : null;
const descriptor = buildFieldDescriptor(input, describeFieldRole(input)); const roles = candidates.map((candidate) => {
return `${descriptor.formIndex}:${descriptor.fieldIndex}:${descriptor.role}`; const passwordDescriptor = buildFieldDescriptor(candidate.passwordInput, "password");
}); const usernameDescriptor = candidate.usernameInput ? buildFieldDescriptor(candidate.usernameInput, "username") : null;
return `${passwordDescriptor.formIndex}:${passwordDescriptor.fieldIndex}:password:${usernameDescriptor ? `${usernameDescriptor.formIndex}:${usernameDescriptor.fieldIndex}` : "-"}`;
});
return { return {
pageHasLoginForm: Boolean(targets.usernameInput || targets.passwordInput), pageHasLoginForm: Boolean(chosen),
usernameInput: targets.usernameInput, usernameInput: chosen?.usernameInput || null,
passwordInput: targets.passwordInput, passwordInput: chosen?.passwordInput || null,
anchorInput, anchorInput,
focusTarget, focusTarget,
signature: roles.join("|") signature: roles.join("|")
@@ -265,8 +421,8 @@ function inlineMatchSummary(match) {
return parts.join(" · ") || "No username"; return parts.join(" · ") || "No username";
} }
function shouldShowInlineOverlay(state, hasTarget, suppressed) { function shouldShowInlineOverlay(state, hasTarget, suppressed, idleHidden) {
if (suppressed || !hasTarget) { if (suppressed || idleHidden || !hasTarget) {
return false; return false;
} }
return Boolean( return Boolean(
@@ -286,7 +442,11 @@ const contentTestExports = {
chooseFillTargets, chooseFillTargets,
inlineMatchSummary, inlineMatchSummary,
domainLabel, domainLabel,
shouldShowInlineOverlay shouldShowInlineOverlay,
fieldHintText,
scopeHintText,
hasAuthFlowSignals,
authFlowCandidate
}; };
if (isNodeTestEnv) { if (isNodeTestEnv) {
@@ -303,7 +463,9 @@ if (isNodeTestEnv) {
}; };
let chooserOpen = false; let chooserOpen = false;
let inlineSuppressed = false; let inlineSuppressed = false;
let inlineIdleHidden = false;
let refreshTimer = null; let refreshTimer = null;
let idleHideTimer = null;
let lastReportedSignature = ""; let lastReportedSignature = "";
let lastReportedTarget = ""; let lastReportedTarget = "";
@@ -464,6 +626,24 @@ if (isNodeTestEnv) {
dock.dataset.open = "false"; dock.dataset.open = "false";
} }
function clearIdleHideTimer() {
if (idleHideTimer !== null) {
clearTimeout(idleHideTimer);
idleHideTimer = null;
}
}
function refreshInlineLifetime(shouldShow) {
clearIdleHideTimer();
if (!shouldShow || chooserOpen || pageState.pendingFill) {
return;
}
idleHideTimer = window.setTimeout(() => {
inlineIdleHidden = true;
hideDock();
}, 15000);
}
function positionDock() { function positionDock() {
const anchor = currentTarget(); const anchor = currentTarget();
if (!anchor || dock.style.display === "none") { if (!anchor || dock.style.display === "none") {
@@ -537,9 +717,10 @@ if (isNodeTestEnv) {
function renderInlineState() { function renderInlineState() {
const target = currentTarget(); const target = currentTarget();
const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed); const shouldShow = shouldShowInlineOverlay(pageState, Boolean(target), inlineSuppressed, inlineIdleHidden);
if (!shouldShow) { if (!shouldShow) {
clearIdleHideTimer();
hideDock(); hideDock();
return; return;
} }
@@ -558,17 +739,21 @@ if (isNodeTestEnv) {
dock.dataset.open = chooserOpen ? "true" : "false"; dock.dataset.open = chooserOpen ? "true" : "false";
renderMatches(); renderMatches();
positionDock(); positionDock();
refreshInlineLifetime(shouldShow);
} }
function reportFieldState(force) { function reportFieldState(force) {
const scan = scanLoginFields(); const scan = scanLoginFields();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (scan.signature !== lastReportedSignature || nextTarget !== lastReportedTarget) {
inlineIdleHidden = false;
}
pageState = { pageState = {
...pageState, ...pageState,
pageHasLoginForm: scan.pageHasLoginForm, pageHasLoginForm: scan.pageHasLoginForm,
focusTarget: scan.focusTarget focusTarget: scan.focusTarget
}; };
renderInlineState(); renderInlineState();
const nextTarget = JSON.stringify(scan.focusTarget || null);
if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) { if (!force && scan.signature === lastReportedSignature && nextTarget === lastReportedTarget) {
return; return;
} }
+77 -1
View File
@@ -18,6 +18,68 @@ test("domainLabel tolerates invalid URLs", () => {
assert.equal(content.domainLabel("not-a-url"), ""); assert.equal(content.domainLabel("not-a-url"), "");
}); });
test("describeFieldRole only treats explicit account fields as usernames", () => {
const loginField = {
autocomplete: "username",
labels: [],
getAttribute(name) {
const attrs = {
type: "email",
id: "crew-email",
name: "email",
placeholder: "Email address",
"aria-label": "Email address"
};
return attrs[name] || "";
}
};
const searchField = {
autocomplete: "",
labels: [],
getAttribute(name) {
const attrs = {
type: "text",
id: "site-search",
name: "query",
placeholder: "Search casino news",
"aria-label": "Search"
};
return attrs[name] || "";
}
};
assert.equal(content.describeFieldRole(loginField), "username");
assert.equal(content.describeFieldRole(searchField), "");
});
test("hasAuthFlowSignals rejects generic password scopes and accepts sign-in scopes", () => {
const genericScope = {
getAttribute() {
return "";
},
querySelectorAll() {
return [{ textContent: "Confirm shipment" }];
}
};
const signInScope = {
getAttribute(name) {
const attrs = {
id: "signin-panel",
name: "signin",
action: "/session"
};
return attrs[name] || "";
},
querySelectorAll() {
return [{ textContent: "Sign in to the Bellagio vault" }];
}
};
assert.equal(content.hasAuthFlowSignals(null, genericScope), false);
assert.equal(content.hasAuthFlowSignals(null, signInScope), true);
assert.equal(content.hasAuthFlowSignals({ id: "danny-ocean" }, genericScope), true);
});
test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => { test("shouldShowInlineOverlay hides the page overlay after it is suppressed", () => {
const state = { const state = {
pageHasLoginForm: true, pageHasLoginForm: true,
@@ -29,5 +91,19 @@ test("shouldShowInlineOverlay hides the page overlay after it is suppressed", ()
}; };
assert.equal(content.shouldShowInlineOverlay(state, true, false), true); assert.equal(content.shouldShowInlineOverlay(state, true, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, true), false); assert.equal(content.shouldShowInlineOverlay(state, true, true, false), false);
});
test("shouldShowInlineOverlay hides the page overlay after idle expiry", () => {
const state = {
pageHasLoginForm: true,
configured: true,
success: true,
status: { locked: false },
matches: [{ id: "rusty-ryan" }],
pendingFill: false
};
assert.equal(content.shouldShowInlineOverlay(state, true, false, false), true);
assert.equal(content.shouldShowInlineOverlay(state, true, false, true), false);
}); });
+64 -8
View File
@@ -291,7 +291,7 @@ func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBro
}, },
score: score, score: score,
resource: resource, resource: resource,
decision: apitokens.Evaluate(token, apitokens.OperationListEntries, resource), decision: evaluateAuthorization(model, token, apitokens.OperationListEntries, resource),
}) })
} }
slices.SortFunc(matches, func(a, b rankedBrowserMatch) int { slices.SortFunc(matches, func(a, b rankedBrowserMatch) int {
@@ -1220,7 +1220,9 @@ func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Oper
} }
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) { func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
switch apitokens.Evaluate(token, op, resource) { model, _ := s.snapshotModel()
displayResource := displayAuthorizationResource(resource)
switch evaluateAuthorization(model, token, op, resource) {
case apitokens.DecisionAllow: case apitokens.DecisionAllow:
return token, nil return token, nil
case apitokens.DecisionDeny: case apitokens.DecisionDeny:
@@ -1232,9 +1234,9 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
result, err := s.approvals.Request(ctx, token, op, resource) result, err := s.approvals.Request(ctx, token, op, displayResource)
if result.Rule != nil { if result.Rule != nil {
if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil { if persistErr := s.persistApprovalRule(token.ID, *result.Rule); persistErr != nil {
return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr) return apitokens.Token{}, status.Errorf(codes.Internal, "persist approval decision: %v", persistErr)
@@ -1248,7 +1250,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return token, nil return token, nil
case errors.Is(err, apiapproval.ErrRequestDenied): case errors.Is(err, apiapproval.ErrRequestDenied):
@@ -1258,7 +1260,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval") return apitokens.Token{}, status.Error(codes.PermissionDenied, "access denied by user approval")
case errors.Is(err, apiapproval.ErrRequestCanceled): case errors.Is(err, apiapproval.ErrRequestCanceled):
@@ -1268,7 +1270,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled") return apitokens.Token{}, status.Error(codes.Unauthenticated, "authorization request canceled")
case errors.Is(err, apiapproval.ErrRequestTimedOut): case errors.Is(err, apiapproval.ErrRequestTimedOut):
@@ -1278,7 +1280,7 @@ func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.T
TokenName: token.Name, TokenName: token.Name,
ClientName: token.ClientName, ClientName: token.ClientName,
Operation: op, Operation: op,
Resource: resource, Resource: displayResource,
}) })
return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out") return apitokens.Token{}, status.Error(codes.DeadlineExceeded, "authorization request timed out")
case errors.Is(err, context.Canceled): case errors.Is(err, context.Canceled):
@@ -1337,6 +1339,60 @@ func hasPolicyRule(rules []apitokens.PolicyRule, target apitokens.PolicyRule) bo
return false return false
} }
func evaluateAuthorization(model vault.Model, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) apitokens.Decision {
return apitokens.Evaluate(canonicalizeTokenForAuthorization(model, token), op, canonicalizeAuthorizationResource(model, resource))
}
func canonicalizeTokenForAuthorization(model vault.Model, token apitokens.Token) apitokens.Token {
token.Policies = append([]apitokens.PolicyRule(nil), token.Policies...)
for i := range token.Policies {
token.Policies[i].Resource = canonicalizeAuthorizationResource(model, token.Policies[i].Resource)
}
return token
}
func canonicalizeAuthorizationResource(model vault.Model, resource apitokens.Resource) apitokens.Resource {
resource.Path = canonicalAuthorizationPath(model, resource.Path)
return resource
}
func displayAuthorizationResource(resource apitokens.Resource) apitokens.Resource {
resource.Path = displayAuthorizationPath(resource.Path)
return resource
}
func canonicalAuthorizationPath(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == vaultview.KeepassRoot {
return append([]string(nil), path...)
}
if path[0] == "Root" {
if len(path) > 1 && (path[1] == "Templates" || path[1] == "API Tokens") {
return append([]string(nil), path...)
}
return vaultview.VaultRoot(model).ToPhysicalPath(path[1:])
}
if path[0] == "Templates" || path[0] == "API Tokens" {
return append([]string(nil), path...)
}
return vaultview.VaultRoot(model).ToPhysicalPath(path)
}
func displayAuthorizationPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == vaultview.KeepassRoot {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
if path[0] == "Root" {
return append([]string(nil), path...)
}
return append([]string(nil), path...)
}
func copyOperation(target string) apitokens.Operation { func copyOperation(target string) apitokens.Operation {
switch clipboard.Target(target) { switch clipboard.Target(target) {
case clipboard.TargetUsername: case clipboard.TargetUsername:
+59 -7
View File
@@ -316,7 +316,7 @@ func TestVaultServiceFindsBrowserLoginsWithinAuthorizedGroupScope(t *testing.T)
Path: []string{"keepass", "Joe", "Internet"}, Path: []string{"keepass", "Joe", "Internet"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
}) })
@@ -396,6 +396,58 @@ func TestVaultServiceFindsBrowserLoginsRechecksChildPoliciesAfterPrompt(t *testi
} }
} }
func TestVaultServiceApprovalRequestsUseLogicalRootPathForPhysicalVault(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{
ID: "codex-nextcloud",
Title: "Nextcloud (codex)",
Username: "jjulian",
Password: "secret-1",
URL: "https://nextcloud.example.invalid",
Path: []string{"keepass", "Joe", "codex"},
},
testAPITokenEntry(t),
},
Groups: [][]string{
{"keepass"},
{"keepass", "Joe"},
{"keepass", "Joe", "codex"},
},
}
client, _, service, cleanup := newTestHarnessForModel(t, model)
defer cleanup()
service.approvals = apiapproval.NewBroker(time.Minute)
respCh := make(chan *keepassgov1.ListEntriesResponse, 1)
errCh := make(chan error, 1)
go func() {
resp, err := client.ListEntries(tokenContext(defaultTestTokenSecret), &keepassgov1.ListEntriesRequest{
Path: []string{"Joe", "codex"},
})
respCh <- resp
errCh <- err
}()
pending := waitForServerPendingApproval(t, service, 1)[0]
if got := pending.Resource.Path; !slices.Equal(got, []string{"Root", "Joe", "codex"}) {
t.Fatalf("pending.Resource.Path = %v, want [Root Joe codex]", got)
}
if _, _, err := service.ResolveApproval(pending.ID, apiapproval.OutcomeAllowOnce); err != nil {
t.Fatalf("ResolveApproval(allow once) error = %v", err)
}
if err := <-errCh; err != nil {
t.Fatalf("ListEntries() error = %v", err)
}
resp := <-respCh
if len(resp.GetEntries()) != 1 || resp.GetEntries()[0].GetId() != "codex-nextcloud" {
t.Fatalf("ListEntries().Entries = %#v, want codex-nextcloud after approval", resp.GetEntries())
}
}
func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) { func TestVaultServiceDoesNotMatchSpecificBrowserEntryToParentDomain(t *testing.T) {
t.Parallel() t.Parallel()
@@ -452,7 +504,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRoot(t *testing.T) {
Path: []string{"keepass", "Joe", "codex"}, Path: []string{"keepass", "Joe", "codex"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -491,7 +543,7 @@ func TestVaultServiceListEntriesHidesSingleInternalVaultRootWhenRecycleBinExists
Path: []string{"keepass", "Joe", "codex"}, Path: []string{"keepass", "Joe", "codex"},
}, },
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -523,7 +575,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRoot(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -549,7 +601,7 @@ func TestVaultServiceListGroupsHidesSingleInternalVaultRootWhenRecycleBinExists(
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
@@ -1387,8 +1439,8 @@ func TestVaultServiceUpsertsNewEntryWithinAuthorizedGroupScope(t *testing.T) {
client, _, cleanup := newTestClientForModel(t, vault.Model{ client, _, cleanup := newTestClientForModel(t, vault.Model{
Entries: []vault.Entry{ Entries: []vault.Entry{
testAPITokenEntry(t, testAPITokenEntry(t,
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Joe", "codex"}}}, apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Joe", "codex"}}},
), ),
}, },
Groups: [][]string{ Groups: [][]string{
+234 -24
View File
@@ -11,6 +11,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -30,6 +31,9 @@ const (
SectionAbout Section = "about" SectionAbout Section = "about"
) )
const entriesRootLabel = "Root"
const templatesRootLabel = "Templates"
type CurrentSession interface { type CurrentSession interface {
Current() (vault.Model, error) Current() (vault.Model, error)
} }
@@ -98,6 +102,10 @@ type RemoteOpenableSession interface {
OpenRemote(webdav.Client, string, vault.MasterKey) error OpenRemote(webdav.Client, string, vault.MasterKey) error
} }
type WarningSession interface {
ConsumeWarning() string
}
type SecurityConfigurableSession interface { type SecurityConfigurableSession interface {
ConfigureSecurity(vault.SecuritySettings) error ConfigureSecurity(vault.SecuritySettings) error
SecuritySettings() vault.SecuritySettings SecuritySettings() vault.SecuritySettings
@@ -375,7 +383,7 @@ func (s *State) VisibleEntries() ([]vault.Entry, error) {
} }
if s.Section == SectionEntries { if s.Section == SectionEntries {
return entriesInPath(model.Entries, s.CurrentPath), nil return entriesInPath(entries, logicalEntriesPathForModel(model, s.CurrentPath)), nil
} }
if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 { if s.Section == SectionRecycleBin || len(s.CurrentPath) == 0 {
return entries, nil return entries, nil
@@ -395,13 +403,13 @@ func (s *State) ChildGroups() ([]string, error) {
} }
if s.Section != SectionEntries { if s.Section != SectionEntries {
if s.Section == SectionTemplates && len(s.CurrentPath) == 0 { if s.Section == SectionTemplates {
return childGroups(s.entriesForSection(model), []string{"Templates"}), nil return vaultview.VaultTemplates(model).ChildGroups(templatesViewPath(s.CurrentPath)), nil
} }
return childGroups(s.entriesForSection(model), s.CurrentPath), nil return childGroups(s.entriesForSection(model), s.CurrentPath), nil
} }
return model.ChildGroups(s.CurrentPath), nil return vaultview.VaultRoot(model).ChildGroups(entriesViewPathForModel(model, s.CurrentPath)), nil
} }
func (s *State) SelectVisibleIndex(index int) error { func (s *State) SelectVisibleIndex(index int) error {
@@ -445,13 +453,13 @@ func (s *State) currentModel() (vault.Model, error) {
func (s *State) entriesForSection(model vault.Model) []vault.Entry { func (s *State) entriesForSection(model vault.Model) []vault.Entry {
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
return slices.Clone(model.Templates) return logicalTemplateEntries(vaultview.VaultTemplates(model).EntriesUnderPath(nil))
case SectionRecycleBin: case SectionRecycleBin:
return slices.Clone(model.RecycleBin) return logicalEntries(vaultview.VaultRecycleBin(model).EntriesUnderPath(nil))
case SectionAPITokens, SectionAPIAudit, SectionAbout: case SectionAPITokens, SectionAPIAudit, SectionAbout:
return nil return nil
default: default:
return slices.Clone(model.Entries) return logicalEntries(vaultview.VaultRoot(model).EntriesUnderPath(nil))
} }
} }
@@ -459,11 +467,11 @@ func (s State) SearchPathContext(entry vault.Entry) string {
path := slices.Clone(entry.Path) path := slices.Clone(entry.Path)
switch s.Section { switch s.Section {
case SectionTemplates: case SectionTemplates:
if len(path) == 0 || path[0] != "Templates" { path = logicalTemplatePath(path)
path = append([]string{"Templates"}, path...)
}
case SectionRecycleBin: case SectionRecycleBin:
path = append([]string{"Recycle Bin"}, path...) path = append([]string{"Recycle Bin"}, logicalEntriesPath(path)...)
case SectionEntries:
path = logicalEntriesPath(path)
} }
return strings.Join(path, " / ") return strings.Join(path, " / ")
} }
@@ -520,6 +528,163 @@ func filterEntries(entries []vault.Entry, query string) []vault.Entry {
return out return out
} }
func logicalEntriesPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if usesPhysicalEntriesRoot(model) && path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalEntriesPath(path []string) []string {
if len(path) == 0 {
return []string{entriesRootLabel}
}
if path[0] == entriesRootLabel {
return append([]string(nil), path...)
}
if path[0] == vaultview.KeepassRoot {
path = path[1:]
}
return append([]string{entriesRootLabel}, append([]string(nil), path...)...)
}
func logicalTemplatePath(path []string) []string {
if len(path) == 0 {
return []string{templatesRootLabel}
}
if path[0] == templatesRootLabel {
return append([]string(nil), path...)
}
return append([]string{templatesRootLabel}, append([]string(nil), path...)...)
}
func templatesViewPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == templatesRootLabel {
return append([]string(nil), path[1:]...)
}
return append([]string(nil), path...)
}
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == entriesRootLabel:
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func logicalEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalEntriesPath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalEntry(entry.History[i])
}
return entry
}
func logicalEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalEntry(entries[i])
}
return out
}
func logicalTemplateEntry(entry vault.Entry) vault.Entry {
entry.Path = logicalTemplatePath(entry.Path)
for i := range entry.History {
entry.History[i] = logicalTemplateEntry(entry.History[i])
}
return entry
}
func logicalTemplateEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = logicalTemplateEntry(entries[i])
}
return out
}
func entryForModel(model vault.Model, entry vault.Entry) vault.Entry {
entry.Path = entriesViewPathForModel(model, entry.Path)
for i := range entry.History {
entry.History[i] = entryForModel(model, entry.History[i])
}
return entry
}
func templateEntryForModel(entry vault.Entry) vault.Entry {
entry.Path = templatesViewPath(entry.Path)
for i := range entry.History {
entry.History[i] = templateEntryForModel(entry.History[i])
}
return entry
}
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == entriesRootLabel {
return true
}
}
return false
}
func childGroups(entries []vault.Entry, path []string) []string { func childGroups(entries []vault.Entry, path []string) []string {
seen := map[string]bool{} seen := map[string]bool{}
var groups []string var groups []string
@@ -544,6 +709,33 @@ func childGroups(entries []vault.Entry, path []string) []string {
return groups return groups
} }
func sectionGroupView(model vault.Model, section Section) vaultview.View {
switch section {
case SectionTemplates:
return vaultview.VaultTemplates(model)
default:
return vaultview.VaultRoot(model)
}
}
func sectionGroupViewPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return templatesViewPath(path)
default:
return entriesViewPathForModel(model, path)
}
}
func sectionGroupLogicalPath(model vault.Model, section Section, path []string) []string {
switch section {
case SectionTemplates:
return logicalTemplatePath(path)
default:
return logicalEntriesPathForModel(model, path)
}
}
func (s *State) DeleteSelectedEntry() error { func (s *State) DeleteSelectedEntry() error {
session, ok := s.Session.(MutableSession) session, ok := s.Session.(MutableSession)
if !ok { if !ok {
@@ -594,7 +786,7 @@ func (s *State) UpsertEntry(entry vault.Entry) error {
return err return err
} }
model.UpsertEntry(entry) model.UpsertEntry(vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -611,7 +803,7 @@ func (s *State) UpsertTemplate(entry vault.Entry) error {
return err return err
} }
model.UpsertTemplate(entry) model.UpsertTemplate(vaultview.VaultTemplates(model).ToPhysicalEntry(templateEntryForModel(entry)))
session.Replace(model) session.Replace(model)
s.SelectedEntryID = entry.ID s.SelectedEntryID = entry.ID
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
@@ -628,7 +820,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
return vault.Entry{}, err return vault.Entry{}, err
} }
entry, err := model.InstantiateTemplate(templateID, overrides) entry, err := model.InstantiateTemplate(templateID, vaultview.VaultRoot(model).ToPhysicalEntry(entryForModel(model, overrides)))
if err != nil { if err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
@@ -638,7 +830,7 @@ func (s *State) InstantiateTemplate(templateID string, overrides vault.Entry) (v
if err := s.markDirtyAndAutoSave(); err != nil { if err := s.markDirtyAndAutoSave(); err != nil {
return vault.Entry{}, err return vault.Entry{}, err
} }
return entry, nil return logicalEntry(entry), nil
} }
func (s *State) DeleteTemplate(id string) error { func (s *State) DeleteTemplate(id string) error {
@@ -726,7 +918,13 @@ func (s *State) Unlock(key vault.MasterKey) error {
return fmt.Errorf("session is not lockable") return fmt.Errorf("session is not lockable")
} }
return session.Unlock(key) if err := session.Unlock(key); err != nil {
return err
}
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil
} }
func (s *State) ChangeMasterKey(key vault.MasterKey) error { func (s *State) ChangeMasterKey(key vault.MasterKey) error {
@@ -888,6 +1086,9 @@ func (s *State) OpenVault(path string, key vault.MasterKey) error {
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -918,6 +1119,9 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
s.CurrentPath = nil s.CurrentPath = nil
s.SelectedEntryID = "" s.SelectedEntryID = ""
s.Dirty = false s.Dirty = false
if warningSession, ok := s.Session.(WarningSession); ok {
s.StatusMessage = warningSession.ConsumeWarning()
}
return nil return nil
} }
@@ -993,7 +1197,8 @@ func (s *State) CreateGroup(name string) error {
return err return err
} }
model.CreateGroup(s.CurrentPath, name) view := sectionGroupView(model, s.Section)
model.CreateGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), name)
session.Replace(model) session.Replace(model)
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1007,13 +1212,16 @@ func (s *State) MoveCurrentGroup(parent []string) error {
if err != nil { if err != nil {
return err return err
} }
current := append([]string(nil), s.CurrentPath...) view := sectionGroupView(model, s.Section)
if err := model.MoveGroup(current, parent); err != nil { current := sectionGroupLogicalPath(model, s.Section, s.CurrentPath)
currentViewPath := sectionGroupViewPath(model, s.Section, current)
parentViewPath := sectionGroupViewPath(model, s.Section, parent)
if err := model.MoveGroup(view.ToPhysicalPath(currentViewPath), view.ToPhysicalPath(parentViewPath)); err != nil {
return err return err
} }
session.Replace(model) session.Replace(model)
if len(current) > 0 { if len(currentViewPath) > 0 {
s.CurrentPath = append(append([]string(nil), parent...), current[len(current)-1]) s.CurrentPath = sectionGroupLogicalPath(model, s.Section, append(append([]string(nil), parentViewPath...), currentViewPath[len(currentViewPath)-1]))
} }
return s.markDirtyAndAutoSave() return s.markDirtyAndAutoSave()
} }
@@ -1029,7 +1237,8 @@ func (s *State) RenameCurrentGroup(newName string) error {
return err return err
} }
if err := model.RenameGroup(s.CurrentPath, newName); err != nil { view := sectionGroupView(model, s.Section)
if err := model.RenameGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath)), newName); err != nil {
return err return err
} }
@@ -1051,7 +1260,7 @@ func (s *State) MoveSelectedEntry(path []string) error {
return err return err
} }
if err := model.MoveEntry(s.SelectedEntryID, path); err != nil { if err := model.MoveEntry(s.SelectedEntryID, vaultview.VaultRoot(model).ToPhysicalPath(entriesViewPathForModel(model, path))); err != nil {
return err return err
} }
@@ -1070,7 +1279,8 @@ func (s *State) DeleteCurrentGroup() error {
return err return err
} }
if err := model.DeleteGroup(s.CurrentPath); err != nil { view := sectionGroupView(model, s.Section)
if err := model.DeleteGroup(view.ToPhysicalPath(sectionGroupViewPath(model, s.Section, s.CurrentPath))); err != nil {
return err return err
} }
+74 -5
View File
@@ -27,7 +27,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
}, },
}, },
}, },
CurrentPath: []string{"Crew", "Internet"}, CurrentPath: []string{"Root", "Crew", "Internet"},
} }
got, err := state.VisibleEntries() got, err := state.VisibleEntries()
@@ -583,6 +583,75 @@ func TestSearchPathContextIncludesSectionRoots(t *testing.T) {
} }
} }
func TestVisibleEntriesUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "vault-console", Title: "Vault Console", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
CurrentPath: []string{"Crew", "Internet"},
}
got, err := state.VisibleEntries()
if err != nil {
t.Fatalf("VisibleEntries() error = %v", err)
}
titles := make([]string, 0, len(got))
for _, entry := range got {
titles = append(titles, entry.Title)
}
if !slices.Equal(titles, []string{"Bellagio", "Vault Console"}) {
t.Fatalf("VisibleEntries() titles = %v, want [Bellagio Vault Console]", titles)
}
if !slices.Equal(got[0].Path, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("VisibleEntries()[0].Path = %v, want [Root Crew Internet]", got[0].Path)
}
}
func TestChildGroupsUseLogicalVaultRootForPhysicalKeepassModel(t *testing.T) {
t.Parallel()
state := State{
Session: stubSession{
model: vault.Model{
Entries: []vault.Entry{
{ID: "bellagio", Title: "Bellagio", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"keepass", "Crew", "Security Office"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security Office"},
},
},
},
}
got, err := state.ChildGroups()
if err != nil {
t.Fatalf("ChildGroups() error = %v", err)
}
if !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("ChildGroups() = %v, want [Crew]", got)
}
}
func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) { func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1634,11 +1703,11 @@ func TestCreateGroupSupportsNestedGroupPath(t *testing.T) {
t.Fatalf("CreateGroup() error = %v", err) t.Fatalf("CreateGroup() error = %v", err)
} }
if got := session.model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := session.model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := session.model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := session.model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
+15 -1
View File
@@ -71,7 +71,7 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
event.ClientName, event.ClientName,
string(event.Operation), string(event.Operation),
AuditOperationLabel(event.Operation), AuditOperationLabel(event.Operation),
strings.Join(event.Resource.Path, " / "), FormatResourcePath(event.Resource.Path),
event.Resource.EntryID, event.Resource.EntryID,
event.Message, event.Message,
} }
@@ -91,3 +91,17 @@ func AuditEventSearchTerms(event apiaudit.Event) string {
} }
return strings.ToLower(strings.Join(parts, " ")) return strings.ToLower(strings.Join(parts, " "))
} }
func DisplayResourcePath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] == "keepass" {
return append([]string{"Root"}, append([]string(nil), path[1:]...)...)
}
return append([]string(nil), path...)
}
func FormatResourcePath(path []string) string {
return strings.Join(DisplayResourcePath(path), " / ")
}
+3 -3
View File
@@ -336,7 +336,7 @@ func (u *ui) editAPIPolicyRuleAction(index int) error {
} }
u.apiPolicyGroupScope = true u.apiPolicyGroupScope = true
u.apiPolicyGroupScopeW.Value = true u.apiPolicyGroupScopeW.Value = true
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / ")) u.apiPolicyPath.SetText(apiui.FormatResourcePath(rule.Resource.Path))
u.apiPolicyEntryID.SetText("") u.apiPolicyEntryID.SetText("")
return nil return nil
} }
@@ -476,7 +476,7 @@ func policyRuleParts(rule apitokens.PolicyRule) (string, string, string) {
if rule.Resource.Kind == apitokens.ResourceEntry { if rule.Resource.Kind == apitokens.ResourceEntry {
resource = "Entry: " + rule.Resource.EntryID resource = "Entry: " + rule.Resource.EntryID
} else if len(rule.Resource.Path) > 0 { } else if len(rule.Resource.Path) > 0 {
resource = strings.Join(rule.Resource.Path, " / ") resource = apiui.FormatResourcePath(rule.Resource.Path)
} }
return effect, operation, resource return effect, operation, resource
} }
@@ -1211,5 +1211,5 @@ func formatAuditResource(resource apitokens.Resource) string {
if len(resource.Path) == 0 { if len(resource.Path) == 0 {
return "/" return "/"
} }
return strings.Join(resource.Path, " / ") return apiui.FormatResourcePath(resource.Path)
} }
+2 -1
View File
@@ -27,6 +27,7 @@ import (
"git.julianfamily.org/keepassgo/internal/apiaudit" "git.julianfamily.org/keepassgo/internal/apiaudit"
"git.julianfamily.org/keepassgo/internal/apitokens" "git.julianfamily.org/keepassgo/internal/apitokens"
"git.julianfamily.org/keepassgo/internal/appstate" "git.julianfamily.org/keepassgo/internal/appstate"
apiui "git.julianfamily.org/keepassgo/internal/appui/api"
detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail" detailmodel "git.julianfamily.org/keepassgo/internal/appui/detail"
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout" detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle" lifecyclemodel "git.julianfamily.org/keepassgo/internal/appui/lifecycle"
@@ -1511,7 +1512,7 @@ func approvalResourceText(request apiapproval.Request) string {
} }
case apitokens.ResourceGroup: case apitokens.ResourceGroup:
if len(request.Resource.Path) > 0 { if len(request.Resource.Path) > 0 {
return strings.Join(request.Resource.Path, " / ") return apiui.FormatResourcePath(request.Resource.Path)
} }
} }
return "Vault root" return "Vault root"
+11 -2
View File
@@ -24,6 +24,7 @@ import (
"git.julianfamily.org/keepassgo/internal/clipboard" "git.julianfamily.org/keepassgo/internal/clipboard"
"git.julianfamily.org/keepassgo/internal/session" "git.julianfamily.org/keepassgo/internal/session"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
) )
func (u *ui) bannerSurface() uiBanner { func (u *ui) bannerSurface() uiBanner {
@@ -558,6 +559,11 @@ func copyPath(path []string) []string {
} }
func pathExistsInModel(model vault.Model, path []string) bool { func pathExistsInModel(model vault.Model, path []string) bool {
if len(path) > 0 && path[0] == "Root" {
view := vaultview.VaultRoot(model)
viewPath := entriesViewPathForModel(model, path)
return len(view.EntriesInPath(viewPath)) > 0 || len(view.ChildGroups(viewPath)) > 0 || hasExactGroup(model, view.ToPhysicalPath(viewPath))
}
return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) return len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path)
} }
@@ -569,9 +575,12 @@ func normalizeEntriesPathWithoutModel(path []string, root string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" { if path[0] == "Root" {
return copyPath(path)
}
if path[0] == vaultview.KeepassRoot {
return append([]string{root}, path[1:]...) return append([]string{root}, path[1:]...)
} }
return copyPath(path) return append([]string{root}, copyPath(path)...)
} }
func (u *ui) normalizedEntriesPath(path []string) []string { func (u *ui) normalizedEntriesPath(path []string) []string {
@@ -590,7 +599,7 @@ func (u *ui) normalizedEntriesPath(path []string) []string {
return []string{root} return []string{root}
} }
if path[0] == "Root" && root != "" { if path[0] == "Root" && root != "" {
candidate := append([]string{root}, path[1:]...) candidate := copyPath(path)
if pathExistsInModel(model, candidate) { if pathExistsInModel(model, candidate) {
return candidate return candidate
} }
+108 -32
View File
@@ -2441,8 +2441,8 @@ func TestUIOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -2675,8 +2675,8 @@ func TestUIStartOpenRemoteActionBootstrapsFromLocalVaultBinding(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Session.Current() error = %v", err) t.Fatalf("Session.Current() error = %v", err)
} }
if got := current.EntriesInPath([]string{"Root", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" { if got := current.EntriesInPath([]string{"keepass", "Internet"}); len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("EntriesInPath(Root/Internet) = %#v, want Vault Console", got) t.Fatalf("EntriesInPath(keepass/Internet) = %#v, want Vault Console", got)
} }
} }
@@ -3180,8 +3180,8 @@ func TestUIAdvancedSynchronizeFromLocalMergesIntoCurrentVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3241,8 +3241,8 @@ func TestUIAdvancedSynchronizeFromImportedLocalVaultMergesIntoCurrentVault(t *te
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3406,8 +3406,8 @@ func TestUIAdvancedSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
if got := len(model.EntriesInPath([]string{"Root", "Internet"})); got != 2 { if got := len(model.EntriesInPath([]string{"keepass", "Internet"})); got != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", got) t.Fatalf("len(EntriesInPath(keepass/Internet)) = %d, want 2", got)
} }
} }
@@ -3606,11 +3606,11 @@ func TestUICreateGroupActionSupportsNestedSubgroups(t *testing.T) {
t.Fatalf("createGroupAction() error = %v", err) t.Fatalf("createGroupAction() error = %v", err)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root"}); !slices.Equal(got, []string{"Infrastructure"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass"}); !slices.Equal(got, []string{"Infrastructure"}) {
t.Fatalf("ChildGroups(Root) = %v, want [Infrastructure]", got) t.Fatalf("ChildGroups(keepass) = %v, want [Infrastructure]", got)
} }
if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"Root", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) { if got := u.state.Session.(*uiSession).model.ChildGroups([]string{"keepass", "Infrastructure"}); !slices.Equal(got, []string{"Prod"}) {
t.Fatalf("ChildGroups(Root/Infrastructure) = %v, want [Prod]", got) t.Fatalf("ChildGroups(keepass/Infrastructure) = %v, want [Prod]", got)
} }
} }
@@ -5125,8 +5125,8 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
t.Fatalf("openVaultAction() error = %v", err) t.Fatalf("openVaultAction() error = %v", err)
} }
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5136,6 +5136,39 @@ func TestUIAutoEntersSingleVaultRootGroupAndDisplaysSlashRoot(t *testing.T) {
} }
} }
func TestUIOpenVaultShowsLegacyRootNormalizationWarning(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
var encoded bytes.Buffer
if err := vault.SaveKDBX(&encoded, vault.Model{
Entries: []vault.Entry{
{ID: "vault-console", Title: "Vault Console", Path: []string{"Root", "Crew", "Internet"}},
},
Groups: [][]string{
{"Root"},
{"Root", "Crew"},
{"Root", "Crew", "Internet"},
},
}, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
if err := os.WriteFile(path, encoded.Bytes(), 0o600); err != nil {
t.Fatalf("WriteFile(legacy-root.kdbx) error = %v", err)
}
u := newUIWithSession("desktop", &session.Manager{})
u.masterPassword.SetText("correct horse battery staple")
u.vaultPath.SetText(path)
if err := u.openVaultAction(); err != nil {
t.Fatalf("openVaultAction() error = %v", err)
}
if got := u.state.StatusMessage; !strings.Contains(got, "legacy vault root") {
t.Fatalf("StatusMessage = %q, want legacy vault root normalization warning", got)
}
}
func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) { func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
t.Parallel() t.Parallel()
@@ -5152,8 +5185,8 @@ func TestUIAutoEntersSingleVaultRootWhenRecycleBinAlsoExists(t *testing.T) {
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath = %v, want [keepass]", got) t.Fatalf("currentPath = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() = %v, want root slash path", got) t.Fatalf("displayPath() = %v, want root slash path", got)
@@ -5174,15 +5207,15 @@ func TestUIShowEntriesSectionRestoresHiddenRootAfterLeavingEntries(t *testing.T)
}) })
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after initial entries section = %v, want [keepass]", got) t.Fatalf("currentPath after initial entries section = %v, want [Root]", got)
} }
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got) t.Fatalf("displayPath() after returning to entries = %v, want root slash path", got)
@@ -5215,8 +5248,8 @@ func TestUISyncCurrentPathNormalizesHiddenRootAfterSectionSwitch(t *testing.T) {
u.syncCurrentPath() u.syncCurrentPath()
if got := u.currentPath; !slices.Equal(got, []string{"keepass"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root"}) {
t.Fatalf("currentPath after syncCurrentPath() = %v, want [keepass]", got) t.Fatalf("currentPath after syncCurrentPath() = %v, want [Root]", got)
} }
if got := u.displayPath(); len(got) != 0 { if got := u.displayPath(); len(got) != 0 {
t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got) t.Fatalf("displayPath() after syncCurrentPath() = %v, want root slash path", got)
@@ -5235,7 +5268,7 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
}) })
u.showEntriesSection() u.showEntriesSection()
u.setCurrentPath([]string{"keepass", "Crew", "Internet"}) u.setCurrentPath([]string{"Root", "Crew", "Internet"})
u.search.SetText("amazon") u.search.SetText("amazon")
u.filter() u.filter()
u.state.SelectedEntryID = "amazon" u.state.SelectedEntryID = "amazon"
@@ -5245,8 +5278,8 @@ func TestUIShowEntriesSectionRestoresEntriesViewState(t *testing.T) {
u.showAPITokensSection() u.showAPITokensSection()
u.showEntriesSection() u.showEntriesSection()
if got := u.currentPath; !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) { if got := u.currentPath; !slices.Equal(got, []string{"Root", "Crew", "Internet"}) {
t.Fatalf("currentPath after returning to entries = %v, want [keepass Crew Internet]", got) t.Fatalf("currentPath after returning to entries = %v, want [Root Crew Internet]", got)
} }
if got := u.search.Text(); got != "amazon" { if got := u.search.Text(); got != "amazon" {
t.Fatalf("search text after returning to entries = %q, want amazon", got) t.Fatalf("search text after returning to entries = %q, want amazon", got)
@@ -8073,7 +8106,7 @@ func TestUISelectedRemoteCardUsesLocalCacheSummaryForBoundRemote(t *testing.T) {
wantDetails := []string{ wantDetails := []string{
"/vaults/cache", "/vaults/cache",
"Sync target: home.kdbx · dav.example.invalid", "Sync target: home.kdbx · dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8105,7 +8138,7 @@ func TestUISelectedRemoteCardUsesConnectionSummaryWithoutLocalCache(t *testing.T
wantDetails := []string{ wantDetails := []string{
"Path: vaults/home.kdbx", "Path: vaults/home.kdbx",
"Server: https://dav.example.invalid", "Server: https://dav.example.invalid",
"Last group: Root / Internet", "Last group: Internet",
} }
if !slices.Equal(gotDetails, wantDetails) { if !slices.Equal(gotDetails, wantDetails) {
t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails) t.Fatalf("selectedRemoteCardDetailLines() = %v, want %v", gotDetails, wantDetails)
@@ -8480,7 +8513,7 @@ func TestUIConsumesPendingSharedVaultImportOnStartup(t *testing.T) {
if err := reopened.openVaultAction(); err != nil { if err := reopened.openVaultAction(); err != nil {
t.Fatalf("openVaultAction(imported) error = %v", err) t.Fatalf("openVaultAction(imported) error = %v", err)
} }
reopened.state.NavigateToPath([]string{"Crew", "Internet"}) reopened.state.NavigateToPath([]string{"Root", "Crew", "Internet"})
reopened.filter() reopened.filter()
if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) { if got := reopened.filteredTitles(); !slices.Equal(got, []string{"Bellagio"}) {
t.Fatalf("filteredTitles() = %v, want [Bellagio]", got) t.Fatalf("filteredTitles() = %v, want [Bellagio]", got)
@@ -9327,8 +9360,8 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
if err := u.useCurrentGroupForPolicyAction(); err != nil { if err := u.useCurrentGroupForPolicyAction(); err != nil {
t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err) t.Fatalf("useCurrentGroupForPolicyAction() error = %v", err)
} }
if got := u.apiPolicyPath.Text(); got != "bashertarr" { if got := u.apiPolicyPath.Text(); got != "Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "bashertarr") t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Crew / bashertarr")
} }
if !u.apiPolicyGroupScopeW.Value { if !u.apiPolicyGroupScopeW.Value {
t.Fatal("apiPolicyGroupScopeW.Value = false, want true") t.Fatal("apiPolicyGroupScopeW.Value = false, want true")
@@ -9355,6 +9388,49 @@ func TestUIAPIPolicyTargetActionsUseCurrentContext(t *testing.T) {
} }
} }
func TestUIEditAPIPolicyRuleHidesPhysicalKeepassRoot(t *testing.T) {
t.Parallel()
token := apitokens.Token{
ID: "token-1",
Name: "Crew Browser",
Policies: []apitokens.PolicyRule{{
Effect: apitokens.EffectAllow,
Operation: apitokens.OperationListEntries,
Resource: apitokens.Resource{
Kind: apitokens.ResourceGroup,
Path: []string{"keepass", "Crew", "bashertarr"},
},
}},
}
u := newUIWithModel("desktop", vault.Model{
Entries: []vault.Entry{
token.Entry(apitokens.EntryPath),
},
})
u.showAPITokensSection()
u.state.SelectedEntryID = "token-1"
if err := u.editAPIPolicyRuleAction(0); err != nil {
t.Fatalf("editAPIPolicyRuleAction() error = %v", err)
}
if got := u.apiPolicyPath.Text(); got != "Root / Crew / bashertarr" {
t.Fatalf("apiPolicyPath.Text() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIAuditAndApprovalFormattingHidePhysicalKeepassRoot(t *testing.T) {
t.Parallel()
resource := apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"keepass", "Crew", "bashertarr"}}
if got := formatAuditResource(resource); got != "Root / Crew / bashertarr" {
t.Fatalf("formatAuditResource() = %q, want %q", got, "Root / Crew / bashertarr")
}
if got := approvalResourceText(apiapproval.Request{Resource: resource}); got != "Root / Crew / bashertarr" {
t.Fatalf("approvalResourceText() = %q, want %q", got, "Root / Crew / bashertarr")
}
}
func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) { func TestUIVisibleBreadcrumbsCompressesAggressivelyOnPhone(t *testing.T) {
t.Parallel() t.Parallel()
+68 -13
View File
@@ -1260,14 +1260,10 @@ func (u *ui) recentVaultGroup(path string) []string {
} }
func (u *ui) hiddenVaultRoot() string { func (u *ui) hiddenVaultRoot() string {
if u.state.Section != appstate.SectionEntries { if u.state.Section == appstate.SectionEntries {
return "" return "Root"
} }
model, err := u.state.Session.Current() return ""
if err != nil {
return ""
}
return vaultview.HiddenRoot(model)
} }
func (u *ui) enterHiddenVaultRoot() { func (u *ui) enterHiddenVaultRoot() {
@@ -1294,7 +1290,7 @@ func (u *ui) restoreRecentVaultGroup(path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1317,7 +1313,7 @@ func (u *ui) restoreRecentRemoteGroup(baseURL, path string) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
if len(model.EntriesInPath(saved)) > 0 || len(model.ChildGroups(saved)) > 0 || hasExactGroup(model, saved) { if pathExistsInModel(model, saved) {
u.setCurrentPath(saved) u.setCurrentPath(saved)
return return
} }
@@ -1339,7 +1335,7 @@ func (u *ui) restoreEntriesPath(path []string) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
if len(model.EntriesInPath(path)) > 0 || len(model.ChildGroups(path)) > 0 || hasExactGroup(model, path) { if pathExistsInModel(model, path) {
u.setCurrentPath(path) u.setCurrentPath(path)
return return
} }
@@ -1415,6 +1411,22 @@ func pathHasPrefix(path, prefix []string) bool {
return slices.Equal(path[:len(prefix)], prefix) return slices.Equal(path[:len(prefix)], prefix)
} }
func entriesViewPathForModel(model vault.Model, path []string) []string {
if len(path) == 0 {
return nil
}
switch {
case usesPhysicalEntriesRoot(model) && path[0] == "Root":
return append([]string(nil), path[1:]...)
case usesLogicalEntriesRoot(model):
return append([]string(nil), path...)
case path[0] == "Root":
return append([]string(nil), path[1:]...)
default:
return append([]string(nil), path...)
}
}
func hasExactGroup(model vault.Model, path []string) bool { func hasExactGroup(model vault.Model, path []string) bool {
for _, group := range model.Groups { for _, group := range model.Groups {
if slices.Equal(group, path) { if slices.Equal(group, path) {
@@ -1433,12 +1445,14 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
if err != nil { if err != nil {
return false, "" return false, ""
} }
path := append([]string(nil), u.currentPath...) view := vaultview.VaultRoot(model)
if len(model.ChildGroups(path)) > 0 { path := entriesViewPathForModel(model, u.currentPath)
physicalPath := view.ToPhysicalPath(path)
if len(model.ChildGroups(physicalPath)) > 0 {
return false, "This group contains child groups. Move or delete them before removing the group." return false, "This group contains child groups. Move or delete them before removing the group."
} }
for _, item := range model.Entries { for _, item := range model.Entries {
if slices.Equal(item.Path, path) || pathHasPrefix(item.Path, path) { if slices.Equal(item.Path, physicalPath) || pathHasPrefix(item.Path, physicalPath) {
return false, "This group contains entries. Move or delete them before removing the group." return false, "This group contains entries. Move or delete them before removing the group."
} }
} }
@@ -1450,6 +1464,47 @@ func (u *ui) currentGroupDeletionState() (bool, string) {
return true, "Deleting this empty group will not remove any entries." return true, "Deleting this empty group will not remove any entries."
} }
func usesPhysicalEntriesRoot(model vault.Model) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return true
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func usesLogicalEntriesRoot(model vault.Model) bool {
for _, group := range model.Groups {
if len(group) > 0 && group[0] == "Root" {
return true
}
}
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == "Root" {
return true
}
}
return false
}
func (u *ui) deleteGroupPendingConfirmation() bool { func (u *ui) deleteGroupPendingConfirmation() bool {
return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath) return len(u.deleteGroupPath) > 0 && slices.Equal(u.deleteGroupPath, u.currentPath)
} }
+72 -25
View File
@@ -12,6 +12,7 @@ import (
"strings" "strings"
"git.julianfamily.org/keepassgo/internal/vault" "git.julianfamily.org/keepassgo/internal/vault"
"git.julianfamily.org/keepassgo/internal/vaultview"
"git.julianfamily.org/keepassgo/internal/webdav" "git.julianfamily.org/keepassgo/internal/webdav"
) )
@@ -31,6 +32,7 @@ type Manager struct {
remoteClient *webdav.Client remoteClient *webdav.Client
remotePath string remotePath string
remoteVersion webdav.Version remoteVersion webdav.Version
warning string
} }
type PreparedLocalOpen struct { type PreparedLocalOpen struct {
@@ -40,6 +42,7 @@ type PreparedLocalOpen struct {
Key vault.MasterKey Key vault.MasterKey
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
Warning string
} }
type PreparedRemoteOpen struct { type PreparedRemoteOpen struct {
@@ -51,6 +54,7 @@ type PreparedRemoteOpen struct {
Encoded []byte Encoded []byte
VaultRoot string VaultRoot string
RemoteVersion webdav.Version RemoteVersion webdav.Version
Warning string
} }
type PreparedUnlock struct { type PreparedUnlock struct {
@@ -58,6 +62,7 @@ type PreparedUnlock struct {
Config *vault.KDBXConfig Config *vault.KDBXConfig
Key vault.MasterKey Key vault.MasterKey
VaultRoot string VaultRoot string
Warning string
} }
func (m *Manager) SecuritySettings() vault.SecuritySettings { func (m *Manager) SecuritySettings() vault.SecuritySettings {
@@ -74,7 +79,7 @@ func (m *Manager) ConfigureSecurity(settings vault.SecuritySettings) error {
} }
func (m *Manager) Create(model vault.Model, key vault.MasterKey) error { func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
root := detectSingleVaultRoot(model) root := vaultview.KeepassRoot
model = normalizeUnderRoot(model, root) model = normalizeUnderRoot(model, root)
var encoded bytes.Buffer var encoded bytes.Buffer
if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil { if err := vault.SaveKDBXWithConfigAndKey(&encoded, model, key, m.config); err != nil {
@@ -86,6 +91,7 @@ func (m *Manager) Create(model vault.Model, key vault.MasterKey) error {
m.vaultRoot = root m.vaultRoot = root
m.encoded = encoded.Bytes() m.encoded = encoded.Bytes()
m.locked = false m.locked = false
m.warning = ""
return nil return nil
} }
@@ -118,6 +124,12 @@ func (m *Manager) Open(path string, key vault.MasterKey) error {
return nil return nil
} }
func (m *Manager) ConsumeWarning() string {
warning := strings.TrimSpace(m.warning)
m.warning = ""
return warning
}
func (m *Manager) Save() error { func (m *Manager) Save() error {
if m.remoteClient != nil && m.remotePath != "" { if m.remoteClient != nil && m.remotePath != "" {
return m.SaveRemote() return m.SaveRemote()
@@ -254,7 +266,7 @@ func (m *Manager) SaveAs(path string) error {
func (m *Manager) Replace(model vault.Model) { func (m *Manager) Replace(model vault.Model) {
root := m.vaultRoot root := m.vaultRoot
if root == "" { if root == "" {
root = detectSingleVaultRoot(model) root = vaultview.KeepassRoot
} }
m.model = normalizeUnderRoot(model, root) m.model = normalizeUnderRoot(model, root)
m.vaultRoot = root m.vaultRoot = root
@@ -305,12 +317,13 @@ func PrepareLocalOpen(path string, key vault.MasterKey) (PreparedLocalOpen, erro
return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err) return PreparedLocalOpen{}, fmt.Errorf("open %s: %w", path, err)
} }
return PreparedLocalOpen{ return PreparedLocalOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -324,14 +337,15 @@ func PrepareRemoteOpen(client webdav.Client, path string, key vault.MasterKey) (
return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err) return PreparedRemoteOpen{}, fmt.Errorf("decode remote %s: %w", path, err)
} }
return PreparedRemoteOpen{ return PreparedRemoteOpen{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Client: client, Client: client,
Path: path, Path: path,
Key: key, Key: key,
Encoded: content, Encoded: content,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
RemoteVersion: version, RemoteVersion: version,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -341,10 +355,11 @@ func PrepareUnlock(encoded []byte, key vault.MasterKey) (PreparedUnlock, error)
return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err) return PreparedUnlock{}, fmt.Errorf("unlock vault: %w", err)
} }
return PreparedUnlock{ return PreparedUnlock{
Model: model, Model: normalizeUnderRoot(model, vaultview.KeepassRoot),
Config: config, Config: config,
Key: key, Key: key,
VaultRoot: detectSingleVaultRoot(model), VaultRoot: vaultview.KeepassRoot,
Warning: normalizationWarning(model),
}, nil }, nil
} }
@@ -359,6 +374,7 @@ func (m *Manager) ApplyPreparedLocalOpen(prepared PreparedLocalOpen) {
m.remoteClient = nil m.remoteClient = nil
m.remotePath = "" m.remotePath = ""
m.remoteVersion = webdav.Version{} m.remoteVersion = webdav.Version{}
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) { func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
@@ -372,6 +388,7 @@ func (m *Manager) ApplyPreparedRemoteOpen(prepared PreparedRemoteOpen) {
m.remotePath = prepared.Path m.remotePath = prepared.Path
m.remoteVersion = prepared.RemoteVersion m.remoteVersion = prepared.RemoteVersion
m.path = "" m.path = ""
m.warning = prepared.Warning
} }
func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) { func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
@@ -380,6 +397,7 @@ func (m *Manager) ApplyPreparedUnlock(prepared PreparedUnlock) {
m.key = prepared.Key m.key = prepared.Key
m.vaultRoot = prepared.VaultRoot m.vaultRoot = prepared.VaultRoot
m.locked = false m.locked = false
m.warning = prepared.Warning
} }
func (m *Manager) ChangeMasterKey(key vault.MasterKey) error { func (m *Manager) ChangeMasterKey(key vault.MasterKey) error {
@@ -584,9 +602,7 @@ func (m *Manager) reloadCurrentLocal(merged vault.Model) error {
return err return err
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.locked = false m.locked = false
return nil return nil
@@ -603,9 +619,7 @@ func (m *Manager) reloadCurrentRemote(merged vault.Model) error {
return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err) return fmt.Errorf("reopen remote %s after synchronize: %w", m.remotePath, err)
} }
m.model = merged m.model = merged
if root := detectSingleVaultRoot(merged); root != "" { m.vaultRoot = vaultview.KeepassRoot
m.vaultRoot = root
}
m.encoded = encoded m.encoded = encoded
m.remoteVersion = version m.remoteVersion = version
m.locked = false m.locked = false
@@ -867,17 +881,6 @@ func mergePeerGroups(primary, secondary [][]string) [][]string {
return out return out
} }
func detectSingleVaultRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 {
return ""
}
groups := model.ChildGroups(nil)
if len(groups) != 1 {
return ""
}
return groups[0]
}
func normalizeUnderRoot(model vault.Model, root string) vault.Model { func normalizeUnderRoot(model vault.Model, root string) vault.Model {
if root == "" { if root == "" {
return model return model
@@ -888,8 +891,15 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
switch { switch {
case len(path) == 0: case len(path) == 0:
return []string{root} return []string{root}
case path[0] == "Root":
if len(path) == 1 {
return []string{root}
}
return append([]string{root}, path[1:]...)
case path[0] == root: case path[0] == root:
return path return path
case path[0] == "Templates":
return path
default: default:
return append([]string{root}, path...) return append([]string{root}, path...)
} }
@@ -907,12 +917,49 @@ func normalizeUnderRoot(model vault.Model, root string) vault.Model {
out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path) out.RecycleBin[i].History[j].Path = normalizePath(out.RecycleBin[i].History[j].Path)
} }
} }
for i := range out.Templates {
out.Templates[i].Path = normalizePath(out.Templates[i].Path)
for j := range out.Templates[i].History {
out.Templates[i].History[j].Path = normalizePath(out.Templates[i].History[j].Path)
}
}
for i := range out.Groups { for i := range out.Groups {
out.Groups[i] = normalizePath(out.Groups[i]) out.Groups[i] = normalizePath(out.Groups[i])
} }
return out return out
} }
func normalizationWarning(model vault.Model) string {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return ""
}
if usesKeepassStorageRoot(model) {
return ""
}
return "Opened legacy vault root layout and normalized it under keepass."
}
func usesKeepassStorageRoot(model vault.Model) bool {
if len(model.Entries) != 0 || len(model.RecycleBin) != 0 {
for _, entry := range model.Entries {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
for _, entry := range model.RecycleBin {
if len(entry.Path) > 0 && entry.Path[0] == vaultview.KeepassRoot {
return true
}
}
}
for _, group := range model.Groups {
if len(group) > 0 && group[0] == vaultview.KeepassRoot {
return true
}
}
return false
}
func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) { func loadLocalSource(path string, key vault.MasterKey) (vault.Model, *vault.KDBXConfig, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { if err != nil {
+63 -12
View File
@@ -64,7 +64,7 @@ func TestCreateSaveAsLockAndUnlockRoundTripsVault(t *testing.T) {
t.Fatalf("Current() after Unlock() error = %v", err) t.Fatalf("Current() after Unlock() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" { if len(got) != 1 || got[0].Title != "Vault Console" || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got) t.Fatalf("Current() entries = %#v, want persisted Vault Console entry", got)
} }
@@ -110,12 +110,63 @@ func TestOpenLoadsExistingKDBXFromDisk(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Home Assistant"}) got := current.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("Current() entries = %#v, want Home Assistant entry", got) t.Fatalf("Current() entries = %#v, want Home Assistant entry", got)
} }
} }
func TestOpenNormalizesLegacyVaultRootToKeepassAndReportsWarning(t *testing.T) {
t.Parallel()
key := vault.MasterKey{Password: "correct horse battery staple"}
model := vault.Model{
Entries: []vault.Entry{
{
ID: "entry-1",
Title: "Surveillance Console",
Username: "codex",
Password: "token-2",
URL: "https://surveillance.crew.example.invalid",
Path: []string{"Root", "Home Assistant"},
},
},
Groups: [][]string{
{"Root"},
{"Root", "Home Assistant"},
},
}
path := filepath.Join(t.TempDir(), "legacy-root.kdbx")
file, err := os.Create(path)
if err != nil {
t.Fatalf("Create(legacy path) error = %v", err)
}
if err := vault.SaveKDBXWithKey(file, model, key); err != nil {
file.Close()
t.Fatalf("SaveKDBXWithKey() error = %v", err)
}
if err := file.Close(); err != nil {
t.Fatalf("Close(legacy path) error = %v", err)
}
var sess Manager
if err := sess.Open(path, key); err != nil {
t.Fatalf("Open() error = %v", err)
}
current, err := sess.Current()
if err != nil {
t.Fatalf("Current() error = %v", err)
}
if got := current.EntriesInPath([]string{"keepass", "Home Assistant"}); len(got) != 1 || got[0].ID != "entry-1" {
t.Fatalf("Current().EntriesInPath([keepass Home Assistant]) = %#v, want normalized legacy entry", got)
}
if got := sess.ConsumeWarning(); got == "" {
t.Fatal("ConsumeWarning() = empty, want legacy root normalization warning")
}
}
func TestSavePersistsEditsBackToCurrentPath(t *testing.T) { func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Parallel() t.Parallel()
@@ -169,7 +220,7 @@ func TestSavePersistsEditsBackToCurrentPath(t *testing.T) {
t.Fatalf("LoadKDBXWithKey() error = %v", err) t.Fatalf("LoadKDBXWithKey() error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Internet"}) got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded entries = %#v, want updated password token-2", got) t.Fatalf("loaded entries = %#v, want updated password token-2", got)
} }
@@ -307,7 +358,7 @@ func TestOpenRemoteLoadsExistingKDBXFromWebDAV(t *testing.T) {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Password != "token-1" { if len(got) != 1 || got[0].Password != "token-1" {
t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got) t.Fatalf("Current() entries = %#v, want Vault Console entry from remote vault", got)
} }
@@ -392,7 +443,7 @@ func TestSaveRemotePersistsEditsBackToWebDAV(t *testing.T) {
t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err) t.Fatalf("LoadKDBXWithKey(savedBytes) error = %v", err)
} }
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"}) got := loaded.EntriesInPath([]string{"keepass", "Home Assistant"})
if len(got) != 1 || got[0].Password != "token-2" { if len(got) != 1 || got[0].Password != "token-2" {
t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got) t.Fatalf("loaded remote entries = %#v, want updated token-2 entry", got)
} }
@@ -513,7 +564,7 @@ func TestChangeMasterKeyReencryptsSavedAndLockedVault(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Current() error = %v", err) t.Fatalf("Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 || got[0].Title != "Vault Console" { if len(got) != 1 || got[0].Title != "Vault Console" {
t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got) t.Fatalf("Current() entries = %#v, want Vault Console entry after ChangeMasterKey", got)
} }
@@ -720,7 +771,7 @@ func TestRemoteSaveAndReopenPreservesCrossFeatureState(t *testing.T) {
t.Fatalf("Current() after reopen error = %v", err) t.Fatalf("Current() after reopen error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) after reopen = %d, want 1", len(got))
} }
@@ -879,7 +930,7 @@ func TestSynchronizeRemotePreservesOverwrittenRemoteVariantInHistory(t *testing.
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 { if len(got) != 1 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 1", len(got))
} }
@@ -947,7 +998,7 @@ func TestSynchronizeFromLocalMergesOtherVaultIntoCurrentSource(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1004,7 +1055,7 @@ func TestSynchronizeFromLocalBytesMergesOtherVaultIntoCurrentSource(t *testing.T
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1063,7 +1114,7 @@ func TestSynchronizeToLocalWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
@@ -1148,7 +1199,7 @@ func TestSynchronizeToRemoteWritesMergedVaultToTarget(t *testing.T) {
t.Fatalf("reopened Current() error = %v", err) t.Fatalf("reopened Current() error = %v", err)
} }
got := current.EntriesInPath([]string{"Root", "Internet"}) got := current.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 2 { if len(got) != 2 {
t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got)) t.Fatalf("len(EntriesInPath(Root/Internet)) = %d, want 2", len(got))
} }
+5
View File
@@ -26,6 +26,7 @@ var ErrInvalidMasterKey = errors.New("invalid master key")
const ( const (
templatesRoot = "Templates" templatesRoot = "Templates"
recycleBinRoot = "Recycle Bin" recycleBinRoot = "Recycle Bin"
keepassRoot = "keepass"
keepassGOIDField = "KeePassGO-ID" keepassGOIDField = "KeePassGO-ID"
remoteProfilesKey = "keepassgo.remoteProfiles" remoteProfilesKey = "keepassgo.remoteProfiles"
) )
@@ -502,6 +503,10 @@ func compareGroupNames(a, b string) int {
return -1 return -1
case b == "Root": case b == "Root":
return 1 return 1
case a == keepassRoot:
return -1
case b == keepassRoot:
return 1
case a == templatesRoot: case a == templatesRoot:
return -1 return -1
case b == templatesRoot: case b == templatesRoot:
+51
View File
@@ -755,6 +755,57 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
} }
} }
func TestKDBXKeepassRootEntriesPreserveAttachmentsWithTemplates(t *testing.T) {
t.Parallel()
model := Model{
Entries: []Entry{
{
ID: "entry-1",
Title: "Vault Console",
Username: "dannyocean",
Password: "bellagio-pass-2",
URL: "https://vault.crew.example.invalid",
Path: []string{"keepass", "Internet"},
Attachments: map[string][]byte{
"token.txt": []byte("secret attachment contents"),
},
},
},
Templates: []Entry{
{
ID: "tpl-1",
Title: "Website Login",
Username: "template-user",
Password: "template-password",
Path: []string{"Templates", "Web"},
},
},
Groups: [][]string{
{"keepass", "Internet"},
{"Templates", "Web"},
},
}
var encoded bytes.Buffer
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
t.Fatalf("SaveKDBX() error = %v", err)
}
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
if err != nil {
t.Fatalf("LoadKDBX() error = %v", err)
}
got := loaded.EntriesInPath([]string{"keepass", "Internet"})
if len(got) != 1 {
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
}
if string(got[0].Attachments["token.txt"]) != "secret attachment contents" {
t.Fatalf("attachment contents = %q, want %q", string(got[0].Attachments["token.txt"]), "secret attachment contents")
}
}
func mustGroup(name string, children ...any) gokeepasslib.Group { func mustGroup(name string, children ...any) gokeepasslib.Group {
group := gokeepasslib.NewGroup() group := gokeepasslib.NewGroup()
group.Name = name group.Name = name
+17 -9
View File
@@ -5,19 +5,27 @@ import "git.julianfamily.org/keepassgo/internal/vault"
// HiddenRoot returns the single synthetic top-level vault group that should be // HiddenRoot returns the single synthetic top-level vault group that should be
// treated as an internal storage root rather than as a user-visible group. // treated as an internal storage root rather than as a user-visible group.
func HiddenRoot(model vault.Model) string { func HiddenRoot(model vault.Model) string {
if len(model.EntriesInPath(nil)) != 0 { if !hasGroup(model.Groups, []string{KeepassRoot}) {
return "" return ""
} }
groups := model.ChildGroups(nil) return KeepassRoot
roots := make([]string, 0, len(groups)) }
func hasGroup(groups [][]string, path []string) bool {
for _, group := range groups { for _, group := range groups {
if group == "Recycle Bin" { if len(group) != len(path) {
continue continue
} }
roots = append(roots, group) match := true
for i := range group {
if group[i] != path[i] {
match = false
break
}
}
if match {
return true
}
} }
if len(roots) != 1 { return false
return ""
}
return roots[0]
} }
+427
View File
@@ -0,0 +1,427 @@
package vaultview
import (
"slices"
"git.julianfamily.org/keepassgo/internal/vault"
)
const KeepassRoot = "keepass"
const TemplatesRoot = "Templates"
// View projects the physical vault model into a logical tree for a specific
// product surface.
type View interface {
ChildGroups(path []string) []string
EntriesInPath(path []string) []vault.Entry
EntriesUnderPath(path []string) []vault.Entry
ToPhysicalPath(path []string) []string
FromPhysicalPath(path []string) []string
ToPhysicalEntry(entry vault.Entry) vault.Entry
FromPhysicalEntry(entry vault.Entry) vault.Entry
}
// Vault returns the physical datastore view.
func Vault(model vault.Model) View {
return physicalView{model: model}
}
// VaultRoot returns the logical main-vault view rooted at the physical
// keepass storage group.
func VaultRoot(model vault.Model) View {
return prefixedView{model: model, root: KeepassRoot, rooted: usesTopLevelRoot(model, KeepassRoot)}
}
// VaultTemplates returns the logical templates view rooted at the physical
// Templates storage group.
func VaultTemplates(model vault.Model) View {
return templatesView{model: model}
}
// VaultRecycleBin returns the logical recycle-bin view.
func VaultRecycleBin(model vault.Model) View {
return recycleBinView{model: model}
}
type physicalView struct {
model vault.Model
}
func (v physicalView) ChildGroups(path []string) []string {
return v.model.ChildGroups(path)
}
func (v physicalView) EntriesInPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesInPath(path))
}
func (v physicalView) EntriesUnderPath(path []string) []vault.Entry {
return cloneEntries(v.model.EntriesUnderPath(path))
}
func (v physicalView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v physicalView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v physicalView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
type prefixedView struct {
model vault.Model
root string
rooted bool
}
func (v prefixedView) ChildGroups(path []string) []string {
return v.model.ChildGroups(v.ToPhysicalPath(path))
}
func (v prefixedView) EntriesInPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesInPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) EntriesUnderPath(path []string) []vault.Entry {
return v.mapEntries(v.model.EntriesUnderPath(v.ToPhysicalPath(path)))
}
func (v prefixedView) ToPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return []string{v.root}
}
return append([]string{v.root}, clonePath(path)...)
}
func (v prefixedView) FromPhysicalPath(path []string) []string {
if !v.rooted {
return clonePath(path)
}
if len(path) == 0 {
return nil
}
if path[0] != v.root {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v prefixedView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v prefixedView) mapEntries(entries []vault.Entry) []vault.Entry {
out := make([]vault.Entry, 0, len(entries))
for _, entry := range entries {
out = append(out, v.FromPhysicalEntry(entry))
}
return out
}
type recycleBinView struct {
model vault.Model
}
type templatesView struct {
model vault.Model
}
func (v templatesView) ChildGroups(path []string) []string {
return groupChildren(templateGroupPaths(v.model), v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.EntriesUnderPath(nil), path)
}
func (v templatesView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.Templates {
if len(path) > len(entry.Path) {
continue
}
physical := entry.Path
if len(physical) > 0 && physical[0] == TemplatesRoot {
physical = physical[1:]
}
if len(path) > len(physical) {
continue
}
if !slices.Equal(physical[:len(path)], path) {
continue
}
item := cloneEntry(entry)
item.Path = clonePath(physical)
for i := range item.History {
item.History[i].Path = v.FromPhysicalPath(item.History[i].Path)
}
out = append(out, item)
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v templatesView) ToPhysicalPath(path []string) []string {
if len(path) == 0 {
return []string{TemplatesRoot}
}
return append([]string{TemplatesRoot}, clonePath(path)...)
}
func (v templatesView) FromPhysicalPath(path []string) []string {
if len(path) == 0 {
return nil
}
if path[0] != TemplatesRoot {
return clonePath(path)
}
return clonePath(path[1:])
}
func (v templatesView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.ToPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.ToPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v templatesView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
entry = cloneEntry(entry)
entry.Path = v.FromPhysicalPath(entry.Path)
for i := range entry.History {
entry.History[i].Path = v.FromPhysicalPath(entry.History[i].Path)
}
return entry
}
func (v recycleBinView) ChildGroups(path []string) []string {
return childGroups(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesInPath(path []string) []vault.Entry {
return entriesInPath(v.model.RecycleBin, path)
}
func (v recycleBinView) EntriesUnderPath(path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range v.model.RecycleBin {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
out = append(out, cloneEntry(entry))
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func (v recycleBinView) ToPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) FromPhysicalPath(path []string) []string {
return clonePath(path)
}
func (v recycleBinView) ToPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func (v recycleBinView) FromPhysicalEntry(entry vault.Entry) vault.Entry {
return cloneEntry(entry)
}
func childGroups(entries []vault.Entry, path []string) []string {
return groupChildren(nil, entries, path)
}
func groupChildren(groupPaths [][]string, entries []vault.Entry, path []string) []string {
seen := map[string]bool{}
var groups []string
for _, entry := range entries {
if len(path) > len(entry.Path) {
continue
}
if !slices.Equal(entry.Path[:len(path)], path) {
continue
}
if len(entry.Path) == len(path) {
continue
}
group := entry.Path[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
for _, groupPath := range groupPaths {
if len(path) > len(groupPath) {
continue
}
if !slices.Equal(groupPath[:len(path)], path) {
continue
}
if len(groupPath) == len(path) {
continue
}
group := groupPath[len(path)]
if seen[group] {
continue
}
seen[group] = true
groups = append(groups, group)
}
slices.Sort(groups)
return groups
}
func entriesInPath(entries []vault.Entry, path []string) []vault.Entry {
var out []vault.Entry
for _, entry := range entries {
if slices.Equal(entry.Path, path) {
out = append(out, cloneEntry(entry))
}
}
slices.SortFunc(out, func(a, b vault.Entry) int {
switch {
case a.Title < b.Title:
return -1
case a.Title > b.Title:
return 1
default:
return 0
}
})
return out
}
func cloneEntries(entries []vault.Entry) []vault.Entry {
if len(entries) == 0 {
return nil
}
out := make([]vault.Entry, len(entries))
for i := range entries {
out[i] = cloneEntry(entries[i])
}
return out
}
func cloneEntry(entry vault.Entry) vault.Entry {
entry.Path = clonePath(entry.Path)
entry.Tags = slices.Clone(entry.Tags)
if entry.Fields != nil {
fields := make(map[string]string, len(entry.Fields))
for key, value := range entry.Fields {
fields[key] = value
}
entry.Fields = fields
}
if entry.Attachments != nil {
attachments := make(map[string][]byte, len(entry.Attachments))
for key, value := range entry.Attachments {
attachments[key] = slices.Clone(value)
}
entry.Attachments = attachments
}
if len(entry.History) != 0 {
history := make([]vault.Entry, len(entry.History))
for i := range entry.History {
history[i] = cloneEntry(entry.History[i])
}
entry.History = history
}
return entry
}
func clonePath(path []string) []string {
if len(path) == 0 {
return nil
}
return slices.Clone(path)
}
func templateGroupPaths(model vault.Model) [][]string {
var out [][]string
for _, group := range model.Groups {
if len(group) == 0 || group[0] != TemplatesRoot {
continue
}
out = append(out, clonePath(group[1:]))
}
return out
}
func usesTopLevelRoot(model vault.Model, root string) bool {
if len(model.Entries) == 0 && len(model.Groups) == 0 && len(model.RecycleBin) == 0 {
return root == KeepassRoot
}
return groupsUseRoot(model.Groups, root) ||
entriesUseRoot(model.Entries, root) ||
entriesUseRoot(model.Templates, root) ||
entriesUseRoot(model.RecycleBin, root)
}
func groupsUseRoot(groups [][]string, root string) bool {
for _, group := range groups {
if len(group) > 0 && group[0] == root {
return true
}
}
return false
}
func entriesUseRoot(entries []vault.Entry, root string) bool {
for _, entry := range entries {
if len(entry.Path) > 0 && entry.Path[0] == root {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package vaultview
import (
"slices"
"testing"
"git.julianfamily.org/keepassgo/internal/vault"
)
func TestVaultRootProjectsKeepassStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"keepass", "Crew", "Security"}},
},
Groups: [][]string{
{"keepass"},
{"keepass", "Crew"},
{"keepass", "Crew", "Internet"},
{"keepass", "Crew", "Security"},
{"Recycle Bin"},
},
}
view := VaultRoot(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRoot(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRoot(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).EntriesInPath([Crew Internet]) = %#v, want logical path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).ToPhysicalPath([Crew Internet]) = %v, want [keepass Crew Internet]", got)
}
if got := view.FromPhysicalPath([]string{"keepass", "Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRoot(model).FromPhysicalPath([keepass Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultRecycleBinProjectsRecycleTree(t *testing.T) {
t.Parallel()
model := vault.Model{
RecycleBin: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"Crew", "Internet"}},
{ID: "fountain-cameras", Title: "Fountain Cameras", Path: []string{"Crew", "Security"}},
},
}
view := VaultRecycleBin(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Crew"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups(nil) = %v, want [Crew]", got)
}
if got := view.ChildGroups([]string{"Crew"}); !slices.Equal(got, []string{"Internet", "Security"}) {
t.Fatalf("VaultRecycleBin(model).ChildGroups([Crew]) = %v, want [Internet Security]", got)
}
gotEntries := view.EntriesInPath([]string{"Crew", "Internet"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).EntriesInPath([Crew Internet]) = %#v, want logical recycle-bin path [Crew Internet]", gotEntries)
}
if got := view.ToPhysicalPath([]string{"Crew", "Internet"}); !slices.Equal(got, []string{"Crew", "Internet"}) {
t.Fatalf("VaultRecycleBin(model).ToPhysicalPath([Crew Internet]) = %v, want [Crew Internet]", got)
}
}
func TestVaultTemplatesProjectsTemplatesStorageRoot(t *testing.T) {
t.Parallel()
model := vault.Model{
Templates: []vault.Entry{
{ID: "website-login", Title: "Website Login", Path: []string{"Templates", "Web"}},
{ID: "ssh-login", Title: "SSH Login", Path: []string{"Templates", "Infra"}},
},
Groups: [][]string{
{"Templates"},
{"Templates", "Infra"},
{"Templates", "Web"},
{"keepass"},
},
}
view := VaultTemplates(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"Infra", "Web"}) {
t.Fatalf("VaultTemplates(model).ChildGroups(nil) = %v, want [Infra Web]", got)
}
gotEntries := view.EntriesInPath([]string{"Web"})
if len(gotEntries) != 1 || !slices.Equal(gotEntries[0].Path, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).EntriesInPath([Web]) = %#v, want logical path [Web]", gotEntries)
}
if got := view.ToPhysicalPath(nil); !slices.Equal(got, []string{"Templates"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath(nil) = %v, want [Templates]", got)
}
if got := view.ToPhysicalPath([]string{"Web"}); !slices.Equal(got, []string{"Templates", "Web"}) {
t.Fatalf("VaultTemplates(model).ToPhysicalPath([Web]) = %v, want [Templates Web]", got)
}
if got := view.FromPhysicalPath([]string{"Templates", "Web"}); !slices.Equal(got, []string{"Web"}) {
t.Fatalf("VaultTemplates(model).FromPhysicalPath([Templates Web]) = %v, want [Web]", got)
}
}
func TestVaultReturnsPhysicalPathsUnchanged(t *testing.T) {
t.Parallel()
model := vault.Model{
Entries: []vault.Entry{
{ID: "bellagio-ledger", Title: "Bellagio Ledger", Path: []string{"keepass", "Crew", "Internet"}},
},
}
view := Vault(model)
if got := view.ChildGroups(nil); !slices.Equal(got, []string{"keepass"}) {
t.Fatalf("Vault(model).ChildGroups(nil) = %v, want [keepass]", got)
}
if got := view.ToPhysicalPath([]string{"keepass", "Crew"}); !slices.Equal(got, []string{"keepass", "Crew"}) {
t.Fatalf("Vault(model).ToPhysicalPath([keepass Crew]) = %v, want [keepass Crew]", got)
}
if got := view.FromPhysicalEntry(model.Entries[0]); !slices.Equal(got.Path, []string{"keepass", "Crew", "Internet"}) {
t.Fatalf("Vault(model).FromPhysicalEntry(entry).Path = %v, want [keepass Crew Internet]", got.Path)
}
}