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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions cmd/unixperms.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package cmd

import (
"errors"
"fmt"
"strconv"
"strings"

"github.com/spf13/cobra"
)

// permBit describes one permission bit.
type permBit struct {
symbol string
name string

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

name is never used.

value int
}

var permBits = []permBit{
{"r", "owner read", 0o400},
{"w", "owner write", 0o200},
{"x", "owner execute", 0o100},
{"r", "group read", 0o040},
{"w", "group write", 0o020},
{"x", "group execute", 0o010},
{"r", "other read", 0o004},
{"w", "other write", 0o002},
{"x", "other execute", 0o001},
}

// octalToSymbolic converts an octal permission mode to a 9-char symbolic string (e.g. "rwxr-xr-x").
func octalToSymbolic(mode int) string {
var sb strings.Builder
for _, bit := range permBits {
if mode&bit.value != 0 {
sb.WriteString(bit.symbol)
} else {
sb.WriteString("-")
}
}
return sb.String()
}

// symbolicToOctal parses a 9-char symbolic string and returns the octal mode.
func symbolicToOctal(sym string) (int, error) {
if len(sym) != 9 {
return 0, errors.New("symbolic notation must be exactly 9 characters (e.g. rwxr-xr-x)")
}
mode := 0
for i, bit := range permBits {
ch := string(sym[i])
if ch == bit.symbol {
mode |= bit.value
} else if ch != "-" {
return 0, fmt.Errorf("invalid character %q at position %d (expected %q or \"-\")", ch, i+1, bit.symbol)
}
}
return mode, nil
}

// explainMode prints a human-readable breakdown of the permission mode.
func explainMode(mode int) {
symbolic := octalToSymbolic(mode)
fmt.Printf("Octal: %04o\n", mode)

@skatkov skatkov Jun 7, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please see how other cmd/'s have been implemented.

To render output, you're using fmt.Printf, so the Cobra command cannot use the repo’s normal cmd.OutOrStdout() and the actual CLI output is not tested.

fmt.Printf("Symbolic: %s\n\n", symbolic)

sections := []struct {
label string
bits []permBit
start int
}{
{"Owner", permBits[0:3], 0},
{"Group", permBits[3:6], 3},
{"Other", permBits[6:9], 6},
}

for _, sec := range sections {
var granted []string
for _, bit := range sec.bits {
if mode&bit.value != 0 {
granted = append(granted, bit.symbol)
} else {
granted = append(granted, "-")
}
}
fmt.Printf("%s: %s (%s)\n", sec.label, strings.Join(granted, ""), describeSection(mode, sec.start))
}
}

func describeSection(mode, start int) string {
bits := permBits[start : start+3]
var parts []string
for _, bit := range bits {
if mode&bit.value != 0 {
switch bit.symbol {
case "r":
parts = append(parts, "read")
case "w":
parts = append(parts, "write")
case "x":
parts = append(parts, "execute")
}
}
}
if len(parts) == 0 {
return "no permissions"
}
return strings.Join(parts, ", ")
}

var unixpermsCmd = &cobra.Command{
Use: "unixperms <mode>",
Short: "Explain Unix file permission modes",
Long: `Explain Unix file permission modes.

Accepts either octal notation (e.g. 755, 0644) or 9-character symbolic
notation (e.g. rwxr-xr-x). Prints a human-readable breakdown of each
permission bit for owner, group, and other.`,
Example: ` # Explain octal mode 755
devtui unixperms 755

# Explain octal mode with leading zero
devtui unixperms 0644

# Explain symbolic notation
devtui unixperms rwxr-xr-x`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
input := strings.TrimSpace(args[0])

var mode int

// Detect whether input is octal (all digits, optional leading 0) or symbolic.
if strings.ContainsAny(input, "rwx-") || len(input) == 9 {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's quite an assumption about symbolic value.

How would you handle following values '--S--S--T', 'u+rwx,g+w', 'u-rwx,g-w', 'u=rwx,g=w,o='?

// Symbolic notation
m, err := symbolicToOctal(input)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe instead of reimplementing octal to symbol conversion, rely on existing library that is battle tested?

as example:
https://github.com/ehmicky/unix-permissions/

if err != nil {
return err
}
mode = m
} else {
// Octal notation
stripped := strings.TrimPrefix(input, "0")
if stripped == "" {
stripped = "0"
}
m, err := strconv.ParseInt(stripped, 8, 64)
if err != nil {
return fmt.Errorf("invalid octal mode %q: %v", input, err)
}
if m < 0 || m > 0o777 {
return fmt.Errorf("mode %04o out of range (must be 000-777)", m)
}
mode = int(m)
}

explainMode(mode)
return nil
},
}

func init() {
rootCmd.AddCommand(unixpermsCmd)
}
70 changes: 70 additions & 0 deletions cmd/unixperms_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package cmd

import (
"testing"
)

func TestOctalToSymbolic(t *testing.T) {
cases := []struct {
octal int
symbolic string
}{
{0o755, "rwxr-xr-x"},
{0o644, "rw-r--r--"},
{0o600, "rw-------"},
{0o777, "rwxrwxrwx"},
{0o000, "---------"},
{0o444, "r--r--r--"},
}
for _, tc := range cases {
got := octalToSymbolic(tc.octal)
if got != tc.symbolic {
t.Errorf("octalToSymbolic(%04o) = %q, want %q", tc.octal, got, tc.symbolic)
}
}
}

func TestSymbolicToOctal(t *testing.T) {
cases := []struct {
symbolic string
octal int
}{
{"rwxr-xr-x", 0o755},
{"rw-r--r--", 0o644},
{"rw-------", 0o600},
{"rwxrwxrwx", 0o777},
{"---------", 0o000},
}
for _, tc := range cases {
got, err := symbolicToOctal(tc.symbolic)
if err != nil {
t.Errorf("symbolicToOctal(%q) unexpected error: %v", tc.symbolic, err)
continue
}
if got != tc.octal {
t.Errorf("symbolicToOctal(%q) = %04o, want %04o", tc.symbolic, got, tc.octal)
}
}
}

func TestSymbolicToOctalErrors(t *testing.T) {
bad := []string{"rwx", "rwxr-xr-xx", "abc123xyz"}
for _, s := range bad {
if _, err := symbolicToOctal(s); err == nil {
t.Errorf("symbolicToOctal(%q) expected error, got nil", s)
}
}
}

func TestRoundTrip(t *testing.T) {
for mode := 0; mode <= 0o777; mode++ {
sym := octalToSymbolic(mode)
got, err := symbolicToOctal(sym)
if err != nil {
t.Fatalf("round-trip failed for %04o: %v", mode, err)
}
if got != mode {
t.Errorf("round-trip mismatch for %04o: symbolic=%q -> %04o", mode, sym, got)
}
}
}
29 changes: 17 additions & 12 deletions tui/password/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,18 @@ const (
uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
numbers = "0123456789"
special = "!@#$%^&*()_+-=[]{}|;:,.<>?"

charsetLowercase = "Lowercase (a-z)"

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why did you extract it into separate variables? why not add this value to characterSetOptions?

charsetUppercase = "Uppercase (A-Z)"
charsetNumbers = "Numbers (0-9)"
charsetSpecial = "Special (!@#$%^&*)"
)

var characterSetOptions = []huh.Option[string]{
{Key: "Lowercase (a-z)", Value: "Lowercase (a-z)"},
{Key: "Uppercase (A-Z)", Value: "Uppercase (A-Z)"},
{Key: "Numbers (0-9)", Value: "Numbers (0-9)"},
{Key: "Special (!@#$%^&*)", Value: "Special (!@#$%^&*)"},
{Key: charsetLowercase, Value: charsetLowercase},
{Key: charsetUppercase, Value: charsetUppercase},
{Key: charsetNumbers, Value: charsetNumbers},
{Key: charsetSpecial, Value: charsetSpecial},
}

func main() {
Expand Down Expand Up @@ -120,13 +125,13 @@ func generateSecurePassword(config *PasswordConfig) (string, error) {

for _, set := range config.CharacterSets {
switch set {
case "Lowercase (a-z)":
case charsetLowercase:
charPool.WriteString(lowercase)
case "Uppercase (A-Z)":
case charsetUppercase:
charPool.WriteString(uppercase)
case "Numbers (0-9)":
case charsetNumbers:
charPool.WriteString(numbers)
case "Special (!@#$%^&*)":
case charsetSpecial:
charPool.WriteString(special)
}
}
Expand Down Expand Up @@ -171,13 +176,13 @@ func ensureAllSetsIncluded(password []byte, config *PasswordConfig) error {
for i, set := range config.CharacterSets {
var chars string
switch set {
case "Lowercase (a-z)":
case charsetLowercase:
chars = lowercase
case "Uppercase (A-Z)":
case charsetUppercase:
chars = uppercase
case "Numbers (0-9)":
case charsetNumbers:
chars = numbers
case "Special (!@#$%^&*)":
case charsetSpecial:
chars = special
}

Expand Down