A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(mkdir): add -m mode (octal and symbolic), umask default

Implements GNU coreutils compatibility for mkdir:

- -m mode flag accepts octal (0700, 755) and symbolic
forms (u=rwx,g=rx,o=, u+x, g-w, a=rwx) via a new
parseSymbolicMode helper that will be reused by chmod
- Default mode is 0o777 & ~umask (assumed 0o022 -> 0o755)
rather than hardcoded 0o755
- -p intermediates use (S_IWUSR|S_IXUSR|~filemask) before
the final mode; existing dirs are left untouched
- billy.Chmod fallback enforces exact -m mode

Retains -p and GNU -v/--verbose.

Refs: docs/posix2018/CONFORMANCE.md
Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso b34577b4 d9055678

+457 -7
+246 -7
command/internal/mkdir/mkdir.go
··· 5 5 "errors" 6 6 "fmt" 7 7 "io" 8 + "os" 8 9 "path" 10 + "strconv" 9 11 "strings" 10 12 13 + "github.com/go-git/go-billy/v5" 11 14 "github.com/pborman/getopt/v2" 12 15 "mvdan.cc/sh/v3/interp" 13 16 "tangled.org/xeiaso.net/kefka/command" 14 17 ) 18 + 19 + const assumedUmask os.FileMode = 0o022 15 20 16 21 type Impl struct{} 17 22 ··· 39 44 usage := func() { 40 45 fmt.Fprint(stderr, "Usage: mkdir [OPTION]... DIRECTORY...\n") 41 46 fmt.Fprint(stderr, "Create the DIRECTORY(ies), if they do not already exist.\n\n") 42 - fmt.Fprint(stderr, " -p, --parents no error if existing, make parent directories as needed\n") 43 - fmt.Fprint(stderr, " -v, --verbose print a message for each created directory\n") 44 - fmt.Fprint(stderr, " --help display this help and exit\n") 47 + fmt.Fprint(stderr, " -m, --mode=MODE set file mode (as in chmod), not a=rwx - umask\n") 48 + fmt.Fprint(stderr, " -p, --parents no error if existing, make parent directories as needed\n") 49 + fmt.Fprint(stderr, " -v, --verbose print a message for each created directory\n") 50 + fmt.Fprint(stderr, " --help display this help and exit\n") 45 51 } 46 52 set.SetUsage(usage) 47 53 54 + modeSpec := set.StringLong("mode", 'm', "", "set file mode (as in chmod), not a=rwx - umask") 48 55 parents := set.BoolLong("parents", 'p', "no error if existing, make parent directories as needed") 49 56 verbose := set.BoolLong("verbose", 'v', "print a message for each created directory") 50 57 help := set.BoolLong("help", 0, "display this help and exit") ··· 65 72 return interp.ExitStatus(1) 66 73 } 67 74 75 + // Default mode for the final directory: 0o777 with umask applied. 76 + // Intermediate directories created with -p get u+wx forced on top of 77 + // the default so the user can always traverse them, regardless of the 78 + // umask, per POSIX (S_IWUSR|S_IXUSR|~filemask) & 0777. 79 + leafMode := os.FileMode(0o777) &^ assumedUmask 80 + intermediateMode := (os.FileMode(0o300) | (os.FileMode(0o777) &^ assumedUmask)) & 0o777 81 + modeSet := false 82 + if *modeSpec != "" { 83 + m, ok := parseMode(*modeSpec, assumedUmask) 84 + if !ok { 85 + fmt.Fprintf(stderr, "mkdir: invalid mode '%s'\n", *modeSpec) 86 + return interp.ExitStatus(1) 87 + } 88 + leafMode = m 89 + modeSet = true 90 + } 91 + 68 92 exitCode := 0 69 93 for _, dir := range dirs { 70 94 full := resolvePath(ec, dir) ··· 83 107 continue 84 108 } 85 109 } 110 + 111 + // Without -p, just create the leaf directly. 112 + if err := ec.FS.MkdirAll(full, leafMode); err != nil { 113 + fmt.Fprintf(stderr, "mkdir: cannot create directory '%s': %s\n", dir, err) 114 + exitCode = 1 115 + continue 116 + } 117 + } else { 118 + // With -p, create each missing intermediate with the 119 + // intermediate mode; create (or leave alone) the leaf with the 120 + // leaf mode. 121 + if err := mkdirAllWithModes(ec.FS, full, leafMode, intermediateMode); err != nil { 122 + fmt.Fprintf(stderr, "mkdir: cannot create directory '%s': %s\n", dir, err) 123 + exitCode = 1 124 + continue 125 + } 86 126 } 87 127 88 - if err := ec.FS.MkdirAll(full, 0o755); err != nil { 89 - fmt.Fprintf(stderr, "mkdir: cannot create directory '%s': %s\n", dir, err) 90 - exitCode = 1 91 - continue 128 + // If -m was given, ensure the leaf has exactly the requested mode 129 + // (the chmod is required by POSIX since the mkdir mode argument's 130 + // effective value is unspecified when -m is in use). 131 + if modeSet { 132 + if ch, ok := ec.FS.(billy.Chmod); ok { 133 + if err := ch.Chmod(full, leafMode|os.ModeDir); err != nil { 134 + fmt.Fprintf(stderr, "mkdir: cannot set permissions of '%s': %s\n", dir, err) 135 + exitCode = 1 136 + continue 137 + } 138 + } 92 139 } 93 140 94 141 if *verbose { ··· 100 147 return interp.ExitStatus(uint8(exitCode)) 101 148 } 102 149 return nil 150 + } 151 + 152 + // mkdirAllWithModes is the -p path: it creates each missing intermediate 153 + // directory of full with intermediateMode, and the leaf with leafMode. 154 + // Existing directories along the path are left alone (silent, no error). 155 + func mkdirAllWithModes(fs billy.Filesystem, full string, leafMode, intermediateMode os.FileMode) error { 156 + if full == "" || full == "." || full == "/" { 157 + return nil 158 + } 159 + cleaned := path.Clean(full) 160 + parts := strings.Split(cleaned, "/") 161 + cur := "" 162 + for i, p := range parts { 163 + if p == "" { 164 + // Leading "/" produces an empty first segment; skip it. 165 + continue 166 + } 167 + if cur == "" { 168 + cur = p 169 + } else { 170 + cur = cur + "/" + p 171 + } 172 + isLeaf := i == len(parts)-1 173 + mode := intermediateMode 174 + if isLeaf { 175 + mode = leafMode 176 + } 177 + if info, err := fs.Stat(cur); err == nil { 178 + if !info.IsDir() { 179 + return fmt.Errorf("not a directory: %s", cur) 180 + } 181 + // Already exists: leave permissions as-is. The leaf will be 182 + // chmodded by the caller iff -m was supplied. 183 + continue 184 + } 185 + if err := fs.MkdirAll(cur, mode); err != nil { 186 + return err 187 + } 188 + if ch, ok := fs.(billy.Chmod); ok { 189 + if err := ch.Chmod(cur, mode|os.ModeDir); err != nil { 190 + return err 191 + } 192 + } 193 + } 194 + return nil 195 + } 196 + 197 + func parseMode(spec string, umask os.FileMode) (os.FileMode, bool) { 198 + if spec == "" { 199 + return 0, false 200 + } 201 + if isOctalSpec(spec) { 202 + return parseOctalMode(spec) 203 + } 204 + return parseSymbolicMode(spec, umask) 205 + } 206 + 207 + func isOctalSpec(spec string) bool { 208 + s := spec 209 + if strings.HasPrefix(s, "0o") || strings.HasPrefix(s, "0O") { 210 + s = s[2:] 211 + } 212 + if s == "" { 213 + return false 214 + } 215 + for _, r := range s { 216 + if r < '0' || r > '7' { 217 + return false 218 + } 219 + } 220 + return true 221 + } 222 + 223 + func parseOctalMode(spec string) (os.FileMode, bool) { 224 + s := spec 225 + if strings.HasPrefix(s, "0o") || strings.HasPrefix(s, "0O") { 226 + s = s[2:] 227 + } 228 + if len(s) < 1 || len(s) > 4 { 229 + return 0, false 230 + } 231 + n, err := strconv.ParseUint(s, 8, 32) 232 + if err != nil { 233 + return 0, false 234 + } 235 + if n > 0o7777 { 236 + return 0, false 237 + } 238 + return os.FileMode(n), true 239 + } 240 + 241 + func parseSymbolicMode(spec string, umask os.FileMode) (os.FileMode, bool) { 242 + mode := os.FileMode(0o777) &^ umask 243 + for clause := range strings.SplitSeq(spec, ",") { 244 + m, ok := applySymbolicClause(mode, clause) 245 + if !ok { 246 + return 0, false 247 + } 248 + mode = m 249 + } 250 + return mode & 0o7777, true 251 + } 252 + 253 + func applySymbolicClause(mode os.FileMode, clause string) (os.FileMode, bool) { 254 + i := 0 255 + whoMask := os.FileMode(0) 256 + whoSet := false 257 + for i < len(clause) { 258 + c := clause[i] 259 + switch c { 260 + case 'u': 261 + whoMask |= 0o700 | os.ModeSetuid 262 + case 'g': 263 + whoMask |= 0o070 | os.ModeSetgid 264 + case 'o': 265 + whoMask |= 0o007 266 + case 'a': 267 + whoMask |= 0o777 | os.ModeSetuid | os.ModeSetgid 268 + default: 269 + goto doneWho 270 + } 271 + whoSet = true 272 + i++ 273 + } 274 + doneWho: 275 + if i >= len(clause) { 276 + return 0, false 277 + } 278 + op := clause[i] 279 + if op != '+' && op != '-' && op != '=' { 280 + return 0, false 281 + } 282 + i++ 283 + 284 + perm := os.FileMode(0) 285 + permSet := false 286 + for i < len(clause) { 287 + c := clause[i] 288 + switch c { 289 + case 'r': 290 + perm |= 0o444 291 + case 'w': 292 + perm |= 0o222 293 + case 'x', 'X': 294 + perm |= 0o111 295 + case 's': 296 + perm |= os.ModeSetuid | os.ModeSetgid 297 + case 't': 298 + perm |= os.ModeSticky 299 + default: 300 + return 0, false 301 + } 302 + permSet = true 303 + i++ 304 + } 305 + 306 + if op != '=' && !whoSet && !permSet { 307 + return 0, false 308 + } 309 + 310 + var applyMask os.FileMode 311 + if whoSet { 312 + applyMask = whoMask 313 + } else { 314 + applyMask = 0o777 | os.ModeSetuid | os.ModeSetgid | os.ModeSticky 315 + } 316 + 317 + switch op { 318 + case '+': 319 + if whoSet { 320 + mode |= perm & applyMask 321 + } else { 322 + mode |= perm &^ os.FileMode(assumedUmask) 323 + } 324 + case '-': 325 + if whoSet { 326 + mode &^= perm & applyMask 327 + } else { 328 + mode &^= perm 329 + } 330 + case '=': 331 + mode &^= applyMask 332 + if permSet { 333 + if whoSet { 334 + mode |= perm & applyMask 335 + } else { 336 + mode |= perm &^ os.FileMode(assumedUmask) 337 + } 338 + } 339 + } 340 + 341 + return mode, true 103 342 } 104 343 105 344 func resolvePath(ec *command.ExecContext, p string) string {
+211
command/internal/mkdir/mkdir_test.go
··· 190 190 } 191 191 }, 192 192 }, 193 + { 194 + name: "mode numeric leading zero", 195 + args: []string{"-m", "0700", "newdir"}, 196 + check: func(t *testing.T, fs billy.Filesystem) { 197 + assertDirMode(t, fs, "newdir", 0o700) 198 + }, 199 + }, 200 + { 201 + name: "mode numeric no leading zero", 202 + args: []string{"-m", "755", "newdir"}, 203 + check: func(t *testing.T, fs billy.Filesystem) { 204 + assertDirMode(t, fs, "newdir", 0o755) 205 + }, 206 + }, 207 + { 208 + name: "mode numeric 0o prefix", 209 + args: []string{"-m", "0o750", "newdir"}, 210 + check: func(t *testing.T, fs billy.Filesystem) { 211 + assertDirMode(t, fs, "newdir", 0o750) 212 + }, 213 + }, 214 + { 215 + name: "mode symbolic clauses", 216 + args: []string{"-m", "u=rwx,g=rx,o=", "newdir"}, 217 + check: func(t *testing.T, fs billy.Filesystem) { 218 + assertDirMode(t, fs, "newdir", 0o750) 219 + }, 220 + }, 221 + { 222 + name: "mode symbolic plus", 223 + args: []string{"-m", "u+w", "newdir"}, 224 + check: func(t *testing.T, fs billy.Filesystem) { 225 + assertDirMode(t, fs, "newdir", 0o755) 226 + }, 227 + }, 228 + { 229 + name: "mode symbolic minus", 230 + args: []string{"-m", "g-x", "newdir"}, 231 + check: func(t *testing.T, fs billy.Filesystem) { 232 + assertDirMode(t, fs, "newdir", 0o745) 233 + }, 234 + }, 235 + { 236 + name: "mode invalid spec", 237 + args: []string{"-m", "garbage", "newdir"}, 238 + wantErr: true, 239 + wantErrSub: "invalid mode 'garbage'", 240 + check: func(t *testing.T, fs billy.Filesystem) { 241 + if isDir(t, fs, "newdir") { 242 + t.Errorf("newdir should not have been created on parse failure") 243 + } 244 + }, 245 + }, 246 + { 247 + name: "mode invalid octal too long", 248 + args: []string{"-m", "12345", "newdir"}, 249 + wantErr: true, 250 + wantErrSub: "invalid mode '12345'", 251 + }, 252 + { 253 + name: "mode invalid octal digit", 254 + args: []string{"-m", "799", "newdir"}, 255 + wantErr: true, 256 + wantErrSub: "invalid mode '799'", 257 + }, 258 + { 259 + name: "mode long flag", 260 + args: []string{"--mode=0700", "newdir"}, 261 + check: func(t *testing.T, fs billy.Filesystem) { 262 + assertDirMode(t, fs, "newdir", 0o700) 263 + }, 264 + }, 265 + { 266 + name: "mode with parents leaf only", 267 + args: []string{"-p", "-m", "0700", "a/b/c"}, 268 + check: func(t *testing.T, fs billy.Filesystem) { 269 + assertDirMode(t, fs, "a/b/c", 0o700) 270 + if !isDir(t, fs, "a") { 271 + t.Errorf("intermediate a was not created") 272 + } 273 + if !isDir(t, fs, "a/b") { 274 + t.Errorf("intermediate a/b was not created") 275 + } 276 + }, 277 + }, 278 + { 279 + name: "parents intermediates traversable with restrictive leaf mode", 280 + args: []string{"-p", "-m", "0700", "i1/i2/leaf"}, 281 + check: func(t *testing.T, fs billy.Filesystem) { 282 + // Per POSIX, intermediates get 283 + // (S_IWUSR|S_IXUSR|~filemask) & 0777 so the user can 284 + // always traverse them. With our assumed umask of 022 285 + // that resolves to 0755. 286 + assertDirMode(t, fs, "i1", 0o755) 287 + assertDirMode(t, fs, "i1/i2", 0o755) 288 + assertDirMode(t, fs, "i1/i2/leaf", 0o700) 289 + }, 290 + }, 291 + { 292 + name: "default leaf mode applies umask", 293 + args: []string{"newdir"}, 294 + check: func(t *testing.T, fs billy.Filesystem) { 295 + // Default = 0777 &^ umask (0022) = 0755. 296 + assertDirMode(t, fs, "newdir", 0o755) 297 + }, 298 + }, 299 + { 300 + name: "parents leaves existing directory mode untouched", 301 + args: []string{"-p", "-m", "0700", "parent/child"}, 302 + check: func(t *testing.T, fs billy.Filesystem) { 303 + // parent already existed at 0o755 from newFS; 304 + // -p must not change its mode. 305 + assertDirMode(t, fs, "parent", 0o755) 306 + assertDirMode(t, fs, "parent/child", 0o700) 307 + }, 308 + }, 309 + { 310 + name: "parents existing with -p is silent and exit zero", 311 + args: []string{"-p", "existing"}, 312 + check: func(t *testing.T, fs billy.Filesystem) { 313 + if !isDir(t, fs, "existing") { 314 + t.Errorf("existing was removed") 315 + } 316 + }, 317 + }, 318 + { 319 + name: "mode on existing dir errors", 320 + args: []string{"-m", "0700", "existing"}, 321 + wantErr: true, 322 + wantErrSub: "File exists", 323 + }, 324 + { 325 + name: "symbolic mode setuid", 326 + args: []string{"-m", "u=rwxs,g=rx,o=", "newdir"}, 327 + check: func(t *testing.T, fs billy.Filesystem) { 328 + // We accept the setuid bit being set on the dir, but 329 + // memfs does not necessarily round-trip the mode-flag 330 + // portion. Just check the perm bits. 331 + assertDirMode(t, fs, "newdir", 0o750) 332 + }, 333 + }, 334 + { 335 + name: "symbolic mode all clauses", 336 + args: []string{"-m", "a=rwx", "newdir"}, 337 + check: func(t *testing.T, fs billy.Filesystem) { 338 + assertDirMode(t, fs, "newdir", 0o777) 339 + }, 340 + }, 341 + { 342 + name: "verbose with mode", 343 + args: []string{"-v", "-m", "0700", "newdir"}, 344 + check: func(t *testing.T, fs billy.Filesystem) { 345 + assertDirMode(t, fs, "newdir", 0o700) 346 + }, 347 + wantStdout: "mkdir: created directory 'newdir'\n", 348 + }, 193 349 } 194 350 195 351 for _, tt := range tests { ··· 216 372 } 217 373 } 218 374 375 + func assertDirMode(t *testing.T, fs billy.Filesystem, name string, want os.FileMode) { 376 + t.Helper() 377 + info, err := fs.Stat(name) 378 + if err != nil { 379 + t.Fatalf("stat %s: %v", name, err) 380 + } 381 + if !info.IsDir() { 382 + t.Fatalf("%s is not a directory after mkdir", name) 383 + } 384 + got := info.Mode().Perm() 385 + if got != want { 386 + t.Errorf("%s mode = %#o, want %#o", name, got, want) 387 + } 388 + } 389 + 390 + func TestParseMode(t *testing.T) { 391 + const umask os.FileMode = 0o022 392 + tests := []struct { 393 + spec string 394 + want os.FileMode 395 + ok bool 396 + }{ 397 + {"0700", 0o700, true}, 398 + {"700", 0o700, true}, 399 + {"755", 0o755, true}, 400 + {"0o755", 0o755, true}, 401 + {"7777", 0o7777, true}, 402 + {"u=rwx,g=rx,o=", 0o750, true}, 403 + {"u+w", 0o755, true}, 404 + {"g-x", 0o745, true}, 405 + {"a=", 0, true}, 406 + {"o=r", 0o754, true}, 407 + {"", 0, false}, 408 + {"garbage", 0, false}, 409 + {"12345", 0, false}, 410 + {"799", 0, false}, 411 + {"u=q", 0, false}, 412 + {"+", 0, false}, 413 + } 414 + for _, tt := range tests { 415 + t.Run(tt.spec, func(t *testing.T) { 416 + got, ok := parseMode(tt.spec, umask) 417 + if ok != tt.ok { 418 + t.Fatalf("parseMode(%q) ok = %v, want %v", tt.spec, ok, tt.ok) 419 + } 420 + if ok && got != tt.want { 421 + t.Errorf("parseMode(%q) = %#o, want %#o", tt.spec, got, tt.want) 422 + } 423 + }) 424 + } 425 + } 426 + 219 427 func TestHelp(t *testing.T) { 220 428 fs := newFS(t) 221 429 stdout, stderr, err := run(t, []string{"--help"}, fs) ··· 233 441 } 234 442 if !strings.Contains(stderr, "--verbose") { 235 443 t.Errorf("--verbose flag missing from help output: %q", stderr) 444 + } 445 + if !strings.Contains(stderr, "--mode") { 446 + t.Errorf("--mode flag missing from help output: %q", stderr) 236 447 } 237 448 } 238 449