Merge pull request 'mounts: the SMB login is typed as one line at the desk's prompt (issue 379)' (#179) from fix/mounts-one-line-smb-login into main

This commit was merged in pull request #179.
This commit is contained in:
2026-10-10 13:55:03 +00:00
4 changed files with 229 additions and 28 deletions
+20 -4
View File
@@ -73,8 +73,22 @@ A mount point is an absolute, plainly written path, not a directory of the syste
## The secret ## The secret
`smb-credentials` is the module's own secret, issued by an outside party (the device's owner), so the mesh `smb-credentials` is the module's own secret, issued by an outside party (the device's owner), so the mesh
never makes one in its place. The operator gives it with never makes one in its place. It holds one login per SMB source, named by the source's name in brackets.
`secret accept <node> mounts smb-credentials --from <file>`; the file holds one section per SMB source:
The operator gives it at the desk with `secret ask <node> mounts smb-credentials` (ADR 0277): a one-line
prompt opens that shows nothing of what is typed, and the login is typed as one line, the keys
space-separated after the name:
```
[games] username=… password=…
```
`password=` is last and takes everything after it to the end of the line, so a password may hold spaces
and `=`; `domain=`, when the device wants one, comes before it. The line is read trimmed, so a password
does not end with a space; after `password=` it is kept as typed, so it may begin with one. Several
sources are several such lines, which the prompt cannot take at once: a file can, given with
`secret accept <node> mounts smb-credentials --from <file>`, holding
one line per source, or one section per source with one key a line:
``` ```
[games] [games]
@@ -83,8 +97,10 @@ password=…
domain=… (optional) domain=… (optional)
``` ```
A source whose section is missing is not mounted, and is said with that remedy. No tool answers a Both forms give the same login, and a file may mix them. A line the module cannot read is said with the
password: the options, fstab lines and mountinfo the tools read are redacted. form it expected, never with what was typed. A source whose login is missing is not mounted, and is said
with that remedy. No tool answers a password: the options, fstab lines and mountinfo the tools read are
redacted.
## The processes and the bus ## The processes and the bus
+2 -1
View File
@@ -303,7 +303,8 @@ func (a *Applier) Look(ctx context.Context) Status {
c, ok := creds[s.Name] c, ok := creds[s.Name]
if !ok || cfg.State == "" { if !ok || cfg.State == "" {
notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed, notSet[s.Name] = ShareStatus{Name: s.Name, Kind: "smb", Occasional: true, Mountpoint: s.Mountpoint, State: StateFailed,
Why: fmt.Sprintf("its login is not given: put a [%s] section in the module's secret with `secret accept <node> mounts smb-credentials --from <file>`", s.Name)} Why: fmt.Sprintf("its login is not given: at the desk, `secret ask <node> mounts smb-credentials` and type one line `[%s] username=… password=…`; "+
"or put a [%s] section in a file and `secret accept <node> mounts smb-credentials --from <file>`", s.Name, s.Name)}
continue continue
} }
file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials") file := filepath.Join(cfg.State, "smb-"+s.Name+".credentials")
+122 -23
View File
@@ -270,54 +270,98 @@ type SMBCredential struct {
Domain string Domain string
} }
// ParseSMBCredentials reads the secret `smb-credentials`: one section per source, // SMBOneLineForm is the form of a login the operator types at the desk's one-line prompt (`secret ask`,
// novox/hq ADR 0277), said in every refusal so the operator knows what to type next.
const SMBOneLineForm = "[<source>] username=… password=…"
// ParseSMBCredentials reads the secret `smb-credentials`: one login per SMB source, named by its section.
// A login is one line, as typed at the desk's prompt (ADR 0277), with the source's name in brackets and
// the keys after it, space-separated:
//
// [name] username=… password=…
//
// `password=` is last and takes everything after it to the end of the line, so a password may hold spaces
// and `=`; `domain=`, when given, comes before it. Or a login is a section of a file, as `secret accept
// --from <file>` takes it, one key a line:
// //
// [name] // [name]
// username=… // username=…
// password=… // password=…
// domain=… (optional) // domain=… (optional)
// //
// Its values are never answered by any tool; a problem names the section and the key, never the value. // The two forms give the same login, and a file may mix them. A line is read trimmed, so a password does
// not end with a space; after `password=` it is kept as typed, so it may begin with one. The values are
// never answered by any tool; a problem names the line or the section, never a value nor a key the
// operator typed.
func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) { func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) {
out := map[string]SMBCredential{} out := map[string]SMBCredential{}
var problems []string var problems []string
// section is the open section of the file form, whose key=value lines follow; "" is none. A one-line
// login closes it, so a key on a later line cannot land in a login given whole.
section := "" section := ""
for i, raw := range strings.Split(text, "\n") { for i, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw) line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue continue
} }
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { if !strings.HasPrefix(line, "[") {
section = strings.TrimSpace(line[1 : len(line)-1]) if section == "" {
if _, dup := out[section]; dup { problems = append(problems, fmt.Sprintf("line %d is outside a [section]: a login is one line as %s, or a [section] with username= and password= lines", i+1, SMBOneLineForm))
problems = append(problems, fmt.Sprintf("the section [%s] is given twice; the first is kept", section))
section = ""
continue continue
} }
out[section] = SMBCredential{} k, v, ok := strings.Cut(line, "=")
if !ok {
problems = append(problems, fmt.Sprintf("line %d in [%s] is not key=value", i+1, section))
continue
}
c := out[section]
if !c.read(k, v) {
problems = append(problems, fmt.Sprintf("[%s] has a key this module does not read; it reads username, password and domain", section))
}
out[section] = c
continue continue
} }
k, v, ok := strings.Cut(line, "=") // `[` opens a section: the file form when nothing follows the `]` on the line, the one-line form when
if !ok || section == "" { // the keys do. The name is the first `]`'s, and a source's name holds no `]`, so nothing typed after it
problems = append(problems, fmt.Sprintf("line %d is not key=value inside a [section]", i+1)) // can open or close another section.
section = ""
name, rest, closed := strings.Cut(line[1:], "]")
name = strings.TrimSpace(name)
if !closed || !nameRe.MatchString(name) {
problems = append(problems, fmt.Sprintf("line %d opens no source: the name in brackets is lower-case letters, digits and dashes, as %s", i+1, SMBOneLineForm))
continue continue
} }
c := out[section] if _, dup := out[name]; dup {
switch strings.ToLower(strings.TrimSpace(k)) { problems = append(problems, fmt.Sprintf("the section [%s] is given twice; the first is kept", name))
case "username", "user": continue
c.Username = strings.TrimSpace(v)
case "password", "pass":
c.Password = strings.TrimRight(v, "\r")
case "domain":
c.Domain = strings.TrimSpace(v)
default:
problems = append(problems, fmt.Sprintf("[%s] has a key %q this module does not read", section, strings.TrimSpace(k)))
} }
out[section] = c if strings.TrimSpace(rest) == "" {
out[name] = SMBCredential{}
section = name
continue
}
// From here the line may be a password typed alone that happens to start like a name, so until it
// reads as a login nothing of it is said: not the name, not what follows it.
if rest[0] != ' ' && rest[0] != '\t' {
problems = append(problems, fmt.Sprintf("line %d: the name in brackets is not followed by a space; the one-line form is %s", i+1, SMBOneLineForm))
continue
}
c, ok := readOneLineLogin(rest)
if !ok {
problems = append(problems, fmt.Sprintf("line %d: what follows the name in brackets is not key=value pairs; the one-line form is %s, password last", i+1, SMBOneLineForm))
continue
}
if c.unknownKey {
problems = append(problems, fmt.Sprintf("[%s] has a key this module does not read; it reads username, password and domain", name))
}
out[name] = c.SMBCredential
} }
// A login that cannot be used is not answered at all: apply says the source as not set up, rather than
// writing a credentials file with a hole in it.
for name, c := range out { for name, c := range out {
if c.Username == "" { if c.Username == "" {
problems = append(problems, fmt.Sprintf("[%s] gives no username", name)) problems = append(problems, fmt.Sprintf("[%s] gives no username; a login is %s", name, SMBOneLineForm))
delete(out, name)
} }
if strings.ContainsAny(c.Username+c.Password+c.Domain, "\n") { if strings.ContainsAny(c.Username+c.Password+c.Domain, "\n") {
problems = append(problems, fmt.Sprintf("[%s] holds a line break", name)) problems = append(problems, fmt.Sprintf("[%s] holds a line break", name))
@@ -327,6 +371,61 @@ func ParseSMBCredentials(text string) (map[string]SMBCredential, []string) {
return out, problems return out, problems
} }
// read puts one key's value in the login and says whether the key is one this module reads. The key is
// matched lower-cased and trimmed; a username or domain is trimmed, a password is kept as given.
func (c *SMBCredential) read(k, v string) bool {
switch strings.ToLower(strings.TrimSpace(k)) {
case "username", "user":
c.Username = strings.TrimSpace(v)
case "password", "pass":
c.Password = v
case "domain":
c.Domain = strings.TrimSpace(v)
default:
return false
}
return true
}
// oneLineLogin is a login read from the one-line form, and whether a key in it is one this module does
// not read (said by the caller; the key itself is never said).
type oneLineLogin struct {
SMBCredential
unknownKey bool
}
// readOneLineLogin reads what follows `[name]` on a line: space-separated key=value pairs, where
// `password=` takes everything after it to the end of the line and so is last. It is not ok when a
// word is not key=value: that word is never said, as it may be the password typed without its key.
func readOneLineLogin(rest string) (oneLineLogin, bool) {
var c oneLineLogin
for {
rest = strings.TrimLeft(rest, " \t")
if rest == "" {
return c, true
}
word := rest
if sp := strings.IndexAny(rest, " \t"); sp >= 0 {
word = rest[:sp]
}
k, v, ok := strings.Cut(word, "=")
key := strings.ToLower(strings.TrimSpace(k))
if !ok || key == "" {
return oneLineLogin{}, false
}
switch key {
case "password", "pass":
// To the end of the line: a password may hold spaces and `=`.
c.read(key, rest[len(k)+1:])
return c, true
}
if !c.read(key, v) {
c.unknownKey = true
}
rest = rest[len(word):]
}
}
// CredentialsFile is a source's credentials in mount.cifs's format. // CredentialsFile is a source's credentials in mount.cifs's format.
func (c SMBCredential) CredentialsFile() string { func (c SMBCredential) CredentialsFile() string {
s := "username=" + c.Username + "\npassword=" + c.Password + "\n" s := "username=" + c.Username + "\npassword=" + c.Password + "\n"
+85
View File
@@ -587,3 +587,88 @@ func (f *fakeSystem) setUnit(unit string, state map[string]string) {
defer f.mu.Unlock() defer f.mu.Unlock()
f.units[unit] = state f.units[unit] = state
} }
// The login as typed at the desk's one-line prompt (novox/hq ADR 0277) is the login the file form gives
// (issue 379): the password is last and runs to the end of the line, with its spaces and its `=`.
func TestAOneLineLoginIsTheLoginTheFileFormGives(t *testing.T) {
pw := "a b=c " + testPassword + "=="
file := "[recalbox]\nusername=root\npassword=" + pw + "\ndomain=WORKGROUP\n"
line := "[recalbox] username=root domain=WORKGROUP password=" + pw + "\n"
fromFile, fileProblems := ParseSMBCredentials(file)
fromLine, lineProblems := ParseSMBCredentials(line)
want := SMBCredential{Username: "root", Password: pw, Domain: "WORKGROUP"}
if len(fileProblems) != 0 || len(lineProblems) != 0 || fromFile["recalbox"] != want || fromLine["recalbox"] != want {
t.Fatalf("file %+v %q, line %+v %q", fromFile, fileProblems, fromLine, lineProblems)
}
// After `password=` it is kept as typed: a leading space is the password's, in both forms.
fromFile, _ = ParseSMBCredentials("[recalbox]\nusername=root\npassword= " + pw + "\n")
fromLine, _ = ParseSMBCredentials("[recalbox] username=root password= " + pw + "\n")
if fromFile["recalbox"].Password != " "+pw || fromLine["recalbox"] != fromFile["recalbox"] {
t.Errorf("a leading space: file %+v, line %+v", fromFile, fromLine)
}
fromLine, _ = ParseSMBCredentials(line)
if fromLine["recalbox"].CredentialsFile() != "username=root\npassword="+pw+"\ndomain=WORKGROUP\n" {
t.Errorf("credentials file %q", strings.ReplaceAll(fromLine["recalbox"].CredentialsFile(), pw, "<pw>"))
}
// Several sources are several lines, in either form, mixed; a `\r` from a terminal is not the password's.
mixed := "[recalbox] username=root password=" + pw + "\r\n[nas]\nusername=u\npassword=p\n[usb] user=x pass=y\n"
got, problems := ParseSMBCredentials(mixed)
if len(problems) != 0 || len(got) != 3 || got["recalbox"].Password != pw || got["nas"].Password != "p" || got["usb"] != (SMBCredential{Username: "x", Password: "y"}) {
t.Errorf("mixed %v %q", len(got), problems)
}
// A one-line login closes the file-form section open before it: a key on the next line lands in no
// login, neither the one given whole nor the one that was open.
got, problems = ParseSMBCredentials("[nas]\nusername=u\npassword=p\n[recalbox] username=root password=x\nusername=other\n")
if got["recalbox"].Username != "root" || got["nas"].Username != "u" || len(problems) != 1 || !strings.Contains(problems[0], "outside a [section]") {
t.Errorf("a stray line after a one-line login: %+v %q", got, problems)
}
}
// A line the module cannot read is refused with the form it expected, and nothing typed is said back:
// not the password, not a word that may be the password typed without its key.
func TestAMalformedLoginIsRefusedWithTheExpectedFormAndSaysNothingTyped(t *testing.T) {
cases := map[string]string{
"the password alone, as the prompt invites": testPassword + "\n",
"a line without a section": "username=root password=" + testPassword + "\n",
"a word that is not key=value": "[recalbox] root " + testPassword + "\n",
"a value without a key": "[recalbox] =" + testPassword + "\n",
"no space after the name": "[recalbox]username=root password=" + testPassword + "\n",
"a name that is not a source's": "[Recal box] username=root password=" + testPassword + "\n",
"a bracket never closed": "[recalbox username=root password=" + testPassword + "\n",
"a password alone that looks like a name": "[abc-1]" + testPassword + "\n",
"no username, line form": "[recalbox] password=" + testPassword + "\n",
"no username, file form": "[recalbox]\npassword=" + testPassword + "\n",
"a key this module does not read, file form": "[recalbox]\nusername=root\n" + testPassword + "=1\npassword=x\n",
"a key this module does not read, line form": "[recalbox] username=root " + testPassword + "=1 password=x\n",
"a section given twice, the second one-line": "[recalbox]\nusername=root\npassword=x\n[recalbox] username=u password=" + testPassword + "\n",
}
for what, text := range cases {
got, problems := ParseSMBCredentials(text)
said := strings.Join(problems, " ")
if len(problems) == 0 {
t.Errorf("%s: not refused: %+v", what, got)
continue
}
if strings.Contains(said, testPassword) || strings.Contains(said, "root") || strings.Contains(said, "abc-1") {
t.Errorf("%s: a refusal says what was typed: %q", what, said)
}
if strings.Contains(what, "does not read") {
if !strings.Contains(said, "it reads username, password and domain") || got["recalbox"].Password != "x" {
t.Errorf("%s: %q %+v", what, said, got)
}
continue
}
if strings.Contains(what, "twice") {
if got["recalbox"].Username != "root" || got["recalbox"].Password != "x" {
t.Errorf("%s: the first is not kept: %+v", what, got)
}
continue
}
if !strings.Contains(said, SMBOneLineForm) {
t.Errorf("%s: the refusal does not say the expected form: %q", what, said)
}
if _, kept := got["recalbox"]; kept {
t.Errorf("%s: a refused login is answered: %+v", what, got)
}
}
}