From 320dde41d9b2e5781878362bd072dfbf2c953193 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 15:27:06 +0200 Subject: [PATCH] Count an own secret's prompt by character and refuse {member} on a single secret (issue 789) --- internal/catalogue/manifest.go | 18 ++++++++++---- internal/catalogue/own_secret_prompt_test.go | 25 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index b3eded21..2d82cbff 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -14,6 +14,7 @@ import ( "sort" "strconv" "strings" + "unicode/utf8" ) // Scopes a claim can have. @@ -2190,7 +2191,7 @@ func ParseManifest(raw []byte) (Manifest, error) { "may make it (novox/hq ADR 0228)", m.Module, name, own.IssuedBy, IssuedOutside)) } - problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt)...) + problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, IsFamily(name))...) } localOf := map[string]string{} for _, to := range m.SecretRequirements() { @@ -2785,8 +2786,10 @@ var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`) // ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words: // empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line, -// at most PromptMax characters, holding only the `{member}` placeholder. -func ownSecretPromptProblems(m Manifest, name, prompt string) []string { +// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}` +// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so +// `{member}` there would stay in the prompt literally (novox/hq issue 789). +func ownSecretPromptProblems(m Manifest, name, prompt string, family bool) []string { if prompt == "" { return nil } @@ -2796,10 +2799,10 @@ func ownSecretPromptProblems(m Manifest, name, prompt string) []string { "%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)", m.Module, name)) } - if len(prompt) > PromptMax { + if n := utf8.RuneCountInString(prompt); n > PromptMax { problems = append(problems, fmt.Sprintf( "%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters", - m.Module, name, len(prompt), PromptMax)) + m.Module, name, n, PromptMax)) } for _, ph := range promptPlaceholder.FindAllString(prompt, -1) { if ph != "{member}" { @@ -2807,6 +2810,11 @@ func ownSecretPromptProblems(m Manifest, name, prompt string) []string { "%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+ "filled with a family's member suffix (novox/hq issue 789)", m.Module, name, ph)) + } else if !family { + problems = append(problems, fmt.Sprintf( + "%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+ + "family's member (novox/hq issue 789)", + m.Module, name)) } } return problems diff --git a/internal/catalogue/own_secret_prompt_test.go b/internal/catalogue/own_secret_prompt_test.go index fb2419b3..f0d65750 100644 --- a/internal/catalogue/own_secret_prompt_test.go +++ b/internal/catalogue/own_secret_prompt_test.go @@ -58,6 +58,31 @@ func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) { } } +// PromptMax counts characters, not bytes: a prompt of 100 multi-byte characters is well within it even +// though it holds well over 120 bytes (novox/hq issue 789). +func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { + prompt := strings.Repeat("é", 100) + if n := len(prompt); n <= 120 { + t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n) + } + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":` + jsonString(prompt) + `}}}`)) + if err != nil { + t.Fatalf("a 100-character prompt was refused: %v", err) + } +} + +// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789). +func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", + "prompt":"For {member}"}}}`)) + if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") { + t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err) + } +} + // A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). func TestAMalformedPromptIsRefused(t *testing.T) { for name, c := range map[string]struct{ prompt, says string }{