package restic import ( "net/url" "strings" ) // mergeRestCreds embeds basic-auth user:pass into a `rest:` URL, only // at the moment we hand it off to the restic subprocess. The result // is intentionally NOT stored on Env or logged — restic's REST // backend reads basic-auth from the URL only, so we have nowhere // else to put them. Callers must treat the return value as // secret-bearing and feed it straight into exec env. // // No-ops when: // - the URL has no `rest:` prefix (other backends — s3, b2, sftp, // etc. — get creds via their own env vars); // - the URL already embeds user:pass (operator typed creds inline); // - username is empty. // // Returns rawURL unchanged if it can't be parsed; restic will then // reject it and the operator gets a clear error rather than a silent // "I quietly stripped your URL" surprise. func mergeRestCreds(rawURL, username, password string) string { if !strings.HasPrefix(rawURL, "rest:") { return rawURL } if username == "" { return rawURL } inner := strings.TrimPrefix(rawURL, "rest:") u, err := url.Parse(inner) if err != nil || u.Host == "" { // Either unparseable or a relative URL we shouldn't touch — // pass through and let restic complain with a clear message. return rawURL } if u.User != nil { // Operator already embedded creds — don't overwrite. return rawURL } u.User = url.UserPassword(username, password) return "rest:" + u.String() } // RedactURL returns a logging-safe version of u with any password in // the userinfo replaced by ***. Mirrors restic's own redaction so // our logs match what restic prints. Use this — never the bare URL — // whenever a URL might end up in slog output, audit entries, or any // surface an operator can read. // // Non-restic URLs (s3, b2, sftp, …) pass through unchanged unless // they happen to embed userinfo, in which case we redact the same // way for consistency. func RedactURL(u string) string { prefix := "" rest := u if i := strings.Index(u, ":"); i > 0 && i+3 < len(u) && u[i+1:i+3] == "//" { // scheme://… — keep "scheme:" intact. prefix = u[:i+1] rest = u[i+1:] } else if strings.HasPrefix(u, "rest:") { prefix = "rest:" rest = strings.TrimPrefix(u, "rest:") } parsed, err := url.Parse(rest) if err != nil || parsed.User == nil { return u } if _, hasPass := parsed.User.Password(); !hasPass { return u } // Build the redacted form by hand rather than via url.URL.String(), // which percent-encodes the redaction marker into "%2A%2A%2A". user := parsed.User.Username() parsed.User = nil rebuilt := parsed.String() // rebuilt is "scheme://host/path…"; splice user:***@ in after "//". const sep = "//" idx := strings.Index(rebuilt, sep) if idx < 0 { return u } return prefix + rebuilt[:idx+len(sep)] + user + ":***@" + rebuilt[idx+len(sep):] }