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(command): port file from just-bash

Determine file types using net/http.DetectContentType for magic byte
detection and mime.TypeByExtension for MIME lookup, with content
heuristics (shebang, XML/HTML, line endings, unicode) for human-readable
text descriptions. Flags: -b/--brief, -i/--mime, -L/--dereference.

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: GLM 5.1 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso e9eb91da f962132e

+585
+339
command/internal/file/file.go
··· 1 + package file 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "mime" 9 + "net/http" 10 + "path" 11 + "strings" 12 + 13 + "github.com/pborman/getopt/v2" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + func resolvePath(ec *command.ExecContext, p string) string { 21 + dir := ec.Dir 22 + if dir == "" { 23 + dir = "." 24 + } 25 + if path.IsAbs(p) { 26 + p = strings.TrimPrefix(p, "/") 27 + if p == "" { 28 + return "." 29 + } 30 + return path.Clean(p) 31 + } 32 + joined := path.Join(dir, p) 33 + if joined == "" { 34 + return "." 35 + } 36 + return joined 37 + } 38 + 39 + func getExtension(filename string) string { 40 + base := path.Base(filename) 41 + if strings.HasPrefix(base, ".") && !strings.Contains(base[1:], ".") { 42 + return base 43 + } 44 + dot := strings.LastIndex(base, ".") 45 + if dot <= 0 { 46 + return "" 47 + } 48 + return base[dot:] 49 + } 50 + 51 + var extDescriptions = map[string]string{ 52 + ".js": "JavaScript source", 53 + ".mjs": "JavaScript module", 54 + ".cjs": "CommonJS module", 55 + ".ts": "TypeScript source", 56 + ".tsx": "TypeScript JSX source", 57 + ".jsx": "JavaScript JSX source", 58 + ".py": "Python script", 59 + ".rb": "Ruby script", 60 + ".go": "Go source", 61 + ".rs": "Rust source", 62 + ".c": "C source", 63 + ".h": "C header", 64 + ".cpp": "C++ source", 65 + ".hpp": "C++ header", 66 + ".java": "Java source", 67 + ".sh": "Bourne-Again shell script", 68 + ".bash": "Bourne-Again shell script", 69 + ".zsh": "Zsh shell script", 70 + ".json": "JSON data", 71 + ".yaml": "YAML data", 72 + ".yml": "YAML data", 73 + ".xml": "XML document", 74 + ".csv": "CSV text", 75 + ".toml": "TOML data", 76 + ".html": "HTML document", 77 + ".htm": "HTML document", 78 + ".css": "CSS stylesheet", 79 + ".svg": "SVG image", 80 + ".md": "Markdown document", 81 + ".markdown": "Markdown document", 82 + ".txt": "ASCII text", 83 + ".rst": "reStructuredText", 84 + } 85 + 86 + func detectTextType(content string, filename string) string { 87 + // Shebang 88 + if strings.HasPrefix(content, "#!") { 89 + firstLine, _, _ := strings.Cut(content, "\n") 90 + switch { 91 + case strings.Contains(firstLine, "python"): 92 + return "Python script, ASCII text executable" 93 + case strings.Contains(firstLine, "node") || strings.Contains(firstLine, "bun") || strings.Contains(firstLine, "deno"): 94 + return "JavaScript script, ASCII text executable" 95 + case strings.Contains(firstLine, "bash"): 96 + return "Bourne-Again shell script, ASCII text executable" 97 + case strings.Contains(firstLine, "sh"): 98 + return "POSIX shell script, ASCII text executable" 99 + case strings.Contains(firstLine, "ruby"): 100 + return "Ruby script, ASCII text executable" 101 + case strings.Contains(firstLine, "perl"): 102 + return "Perl script, ASCII text executable" 103 + default: 104 + return "script, ASCII text executable" 105 + } 106 + } 107 + 108 + // XML/HTML 109 + trimmed := strings.TrimLeft(content, " \t\n\r") 110 + if strings.HasPrefix(trimmed, "<?xml") { 111 + return "XML document" 112 + } 113 + lower := strings.ToLower(trimmed) 114 + if strings.HasPrefix(lower, "<!doctype html") || strings.HasPrefix(lower, "<html") { 115 + return "HTML document" 116 + } 117 + 118 + // Line endings 119 + lineEnding := "" 120 + if strings.Contains(content, "\r\n") { 121 + lineEnding = ", with CRLF line terminators" 122 + } else if strings.Contains(content, "\r") { 123 + lineEnding = ", with CR line terminators" 124 + } 125 + 126 + // Extension-based 127 + ext := getExtension(filename) 128 + if desc, ok := extDescriptions[ext]; ok { 129 + return desc + lineEnding 130 + } 131 + 132 + // Unicode check (first 8KB) 133 + unicode := false 134 + for _, r := range content[:min(len(content), 8192)] { 135 + if r > 127 { 136 + unicode = true 137 + break 138 + } 139 + } 140 + if unicode { 141 + return "UTF-8 Unicode text" + lineEnding 142 + } 143 + return "ASCII text" + lineEnding 144 + } 145 + 146 + func detectFileType(filename string, data []byte) string { 147 + if len(data) == 0 { 148 + return "empty" 149 + } 150 + 151 + mimeType := http.DetectContentType(data) 152 + 153 + // For text content, use extension and content heuristics for 154 + // human-readable descriptions (e.g. "JSON data" vs "text/plain"). 155 + if strings.HasPrefix(mimeType, "text/") { 156 + return detectTextType(string(data), filename) 157 + } 158 + 159 + // http.DetectContentType identified a concrete type (image, PDF, etc). 160 + if mimeType != "application/octet-stream" { 161 + return mimeType 162 + } 163 + 164 + // Unidentified — try text heuristics as a last resort. 165 + if isValidText(data) { 166 + return detectTextType(string(data), filename) 167 + } 168 + 169 + return "data" 170 + } 171 + 172 + // isValidText does a quick heuristic check: if the first 512 bytes are 173 + // mostly printable ASCII or common whitespace, treat it as text. 174 + func isValidText(data []byte) bool { 175 + check := data 176 + if len(check) > 512 { 177 + check = check[:512] 178 + } 179 + nonPrint := 0 180 + for _, b := range check { 181 + if b < 0x20 && b != '\n' && b != '\r' && b != '\t' { 182 + nonPrint++ 183 + } 184 + } 185 + return nonPrint <= len(check)/10 186 + } 187 + 188 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 189 + if ec == nil { 190 + return errors.New("file: nil ExecContext") 191 + } 192 + if ec.FS == nil { 193 + return errors.New("file: ExecContext has no filesystem") 194 + } 195 + 196 + stderr := ec.Stderr 197 + if stderr == nil { 198 + stderr = io.Discard 199 + } 200 + stdout := ec.Stdout 201 + if stdout == nil { 202 + stdout = io.Discard 203 + } 204 + 205 + set := getopt.New() 206 + set.SetProgram("file") 207 + set.SetParameters("FILE...") 208 + 209 + usage := func() { 210 + fmt.Fprint(stderr, "Usage: file [OPTION]... FILE...\n") 211 + fmt.Fprint(stderr, "Determine file type.\n\n") 212 + fmt.Fprint(stderr, " -b, --brief do not prepend filenames to output\n") 213 + fmt.Fprint(stderr, " -i, --mime output MIME type strings\n") 214 + fmt.Fprint(stderr, " -L, --dereference follow symlinks\n") 215 + fmt.Fprint(stderr, " --help display this help and exit\n") 216 + } 217 + set.SetUsage(usage) 218 + 219 + brief := set.BoolLong("brief", 'b', "do not prepend filenames to output") 220 + mimeMode := set.BoolLong("mime", 'i', "output MIME type strings") 221 + _ = set.BoolLong("dereference", 'L', "follow symlinks") 222 + help := set.BoolLong("help", 0, "display this help and exit") 223 + 224 + if err := set.Getopt(append([]string{"file"}, args...), nil); err != nil { 225 + fmt.Fprintf(stderr, "file: %s\n", err) 226 + usage() 227 + return interp.ExitStatus(1) 228 + } 229 + if *help { 230 + usage() 231 + return nil 232 + } 233 + 234 + files := set.Args() 235 + if len(files) == 0 { 236 + fmt.Fprint(stderr, "Usage: file [-bLi] FILE...\n") 237 + return interp.ExitStatus(1) 238 + } 239 + 240 + exitCode := 0 241 + 242 + for _, name := range files { 243 + p := resolvePath(ec, name) 244 + 245 + info, err := ec.FS.Stat(p) 246 + if err != nil { 247 + if *brief { 248 + fmt.Fprintln(stdout, "cannot open") 249 + } else { 250 + fmt.Fprintf(stdout, "%s: cannot open (No such file or directory)\n", name) 251 + } 252 + exitCode = 1 253 + continue 254 + } 255 + 256 + if info.IsDir() { 257 + result := "directory" 258 + if *mimeMode { 259 + result = "inode/directory" 260 + } 261 + if *brief { 262 + fmt.Fprintln(stdout, result) 263 + } else { 264 + fmt.Fprintf(stdout, "%s: %s\n", name, result) 265 + } 266 + continue 267 + } 268 + 269 + f, err := ec.FS.Open(p) 270 + if err != nil { 271 + if *brief { 272 + fmt.Fprintln(stdout, "cannot open") 273 + } else { 274 + fmt.Fprintf(stdout, "%s: cannot open (No such file or directory)\n", name) 275 + } 276 + exitCode = 1 277 + continue 278 + } 279 + data, err := io.ReadAll(f) 280 + f.Close() 281 + if err != nil { 282 + if *brief { 283 + fmt.Fprintln(stdout, "cannot open") 284 + } else { 285 + fmt.Fprintf(stdout, "%s: cannot open (No such file or directory)\n", name) 286 + } 287 + exitCode = 1 288 + continue 289 + } 290 + 291 + desc := detectFileType(name, data) 292 + if *mimeMode { 293 + // When mime mode is on, convert human-readable descriptions 294 + // back to a MIME type where possible. 295 + desc = toMIME(desc, name) 296 + } 297 + if *brief { 298 + fmt.Fprintln(stdout, desc) 299 + } else { 300 + fmt.Fprintf(stdout, "%s: %s\n", name, desc) 301 + } 302 + } 303 + 304 + if exitCode != 0 { 305 + return interp.ExitStatus(uint8(exitCode)) 306 + } 307 + return nil 308 + } 309 + 310 + func toMIME(desc string, filename string) string { 311 + switch desc { 312 + case "empty": 313 + return "inode/x-empty" 314 + case "directory": 315 + return "inode/directory" 316 + } 317 + 318 + // If detectFileType already returned a MIME type via 319 + // http.DetectContentType, it looks like "image/png" etc. 320 + if strings.Contains(desc, "/") { 321 + return strings.SplitN(desc, ";", 2)[0] 322 + } 323 + 324 + // Text-based descriptions: try extension first. 325 + ext := getExtension(filename) 326 + if ext != "" { 327 + if m := mime.TypeByExtension(ext); m != "" { 328 + return strings.SplitN(m, ";", 2)[0] 329 + } 330 + } 331 + 332 + // Common text patterns. 333 + switch { 334 + case strings.Contains(desc, "text"), strings.Contains(desc, "script"), strings.Contains(desc, "source"), strings.Contains(desc, "document"): 335 + return "text/plain" 336 + default: 337 + return "application/octet-stream" 338 + } 339 + }
+244
command/internal/file/file_test.go
··· 1 + package file 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "os" 7 + "testing" 8 + 9 + "github.com/go-git/go-billy/v5" 10 + "github.com/go-git/go-billy/v5/memfs" 11 + "tangled.org/xeiaso.net/kefka/command" 12 + ) 13 + 14 + func newFS(t *testing.T) billy.Filesystem { 15 + t.Helper() 16 + fs := memfs.New() 17 + write := func(name string, data []byte) { 18 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 19 + if err != nil { 20 + t.Fatal(err) 21 + } 22 + f.Write(data) 23 + f.Close() 24 + } 25 + write("hello.txt", []byte("hello world")) 26 + write("empty.txt", []byte{}) 27 + write("script.sh", []byte("#!/bin/bash\necho hi\n")) 28 + write("python.py", []byte("#!/usr/bin/env python3\nprint('hi')\n")) 29 + write("data.json", []byte("{\"key\": \"value\"}\n")) 30 + write("index.html", []byte("<!doctype html><html></html>")) 31 + write("style.css", []byte("body { margin: 0; }")) 32 + write("readme.md", []byte("# Hello\n\nWorld")) 33 + write("crlf.txt", []byte("line1\r\nline2\r\n")) 34 + write("unicode.txt", []byte("Héllo wörld")) 35 + write("unicode.dat", []byte("Héllo wörld")) 36 + write("binary.bin", []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00}) // PNG header 37 + write("subdir/inner.txt", []byte("inner")) 38 + 39 + // Create directory manually 40 + fs.MkdirAll("mydir", 0o755) 41 + return fs 42 + } 43 + 44 + func TestExec(t *testing.T) { 45 + tests := []struct { 46 + name string 47 + args []string 48 + wantStdout string 49 + wantStderr string 50 + wantErr bool 51 + }{ 52 + { 53 + name: "text file detection", 54 + args: []string{"hello.txt"}, 55 + wantStdout: "hello.txt: ASCII text\n", 56 + }, 57 + { 58 + name: "empty file", 59 + args: []string{"empty.txt"}, 60 + wantStdout: "empty.txt: empty\n", 61 + }, 62 + { 63 + name: "shell script with shebang", 64 + args: []string{"script.sh"}, 65 + wantStdout: "script.sh: Bourne-Again shell script, ASCII text executable\n", 66 + }, 67 + { 68 + name: "python script with shebang", 69 + args: []string{"python.py"}, 70 + wantStdout: "python.py: Python script, ASCII text executable\n", 71 + }, 72 + { 73 + name: "json file", 74 + args: []string{"data.json"}, 75 + wantStdout: "data.json: JSON data\n", 76 + }, 77 + { 78 + name: "html file", 79 + args: []string{"index.html"}, 80 + wantStdout: "index.html: HTML document\n", 81 + }, 82 + { 83 + name: "css file", 84 + args: []string{"style.css"}, 85 + wantStdout: "style.css: CSS stylesheet\n", 86 + }, 87 + { 88 + name: "markdown file", 89 + args: []string{"readme.md"}, 90 + wantStdout: "readme.md: Markdown document\n", 91 + }, 92 + { 93 + name: "crlf text", 94 + args: []string{"crlf.txt"}, 95 + wantStdout: "crlf.txt: ASCII text, with CRLF line terminators\n", 96 + }, 97 + { 98 + name: "unicode text no extension hint", 99 + args: []string{"unicode.dat"}, 100 + wantStdout: "unicode.dat: UTF-8 Unicode text\n", 101 + }, 102 + { 103 + name: "png binary", 104 + args: []string{"binary.bin"}, 105 + wantStdout: "binary.bin: image/png\n", 106 + }, 107 + { 108 + name: "directory", 109 + args: []string{"mydir"}, 110 + wantStdout: "mydir: directory\n", 111 + }, 112 + { 113 + name: "directory mime mode", 114 + args: []string{"-i", "mydir"}, 115 + wantStdout: "mydir: inode/directory\n", 116 + }, 117 + { 118 + name: "brief mode", 119 + args: []string{"-b", "hello.txt"}, 120 + wantStdout: "ASCII text\n", 121 + }, 122 + { 123 + name: "brief mode empty", 124 + args: []string{"-b", "empty.txt"}, 125 + wantStdout: "empty\n", 126 + }, 127 + { 128 + name: "mime mode text", 129 + args: []string{"-i", "hello.txt"}, 130 + wantStdout: "hello.txt: text/plain\n", 131 + }, 132 + { 133 + name: "mime mode json", 134 + args: []string{"-i", "data.json"}, 135 + wantStdout: "data.json: application/json\n", 136 + }, 137 + { 138 + name: "mime mode html", 139 + args: []string{"-i", "index.html"}, 140 + wantStdout: "index.html: text/html\n", 141 + }, 142 + { 143 + name: "brief mime combined", 144 + args: []string{"-bi", "data.json"}, 145 + wantStdout: "application/json\n", 146 + }, 147 + { 148 + name: "multiple files", 149 + args: []string{"hello.txt", "empty.txt"}, 150 + wantStdout: "hello.txt: ASCII text\nempty.txt: empty\n", 151 + }, 152 + { 153 + name: "missing file", 154 + args: []string{"nope.txt"}, 155 + wantStdout: "nope.txt: cannot open (No such file or directory)\n", 156 + wantErr: true, 157 + }, 158 + { 159 + name: "missing file brief", 160 + args: []string{"-b", "nope.txt"}, 161 + wantStdout: "cannot open\n", 162 + wantErr: true, 163 + }, 164 + { 165 + name: "no files prints usage", 166 + args: []string{}, 167 + wantStderr: "Usage: file [-bLi] FILE...\n", 168 + wantErr: true, 169 + }, 170 + { 171 + name: "help flag", 172 + args: []string{"--help"}, 173 + wantStderr: "Usage: file [OPTION]... FILE...\n" + 174 + "Determine file type.\n\n" + 175 + " -b, --brief do not prepend filenames to output\n" + 176 + " -i, --mime output MIME type strings\n" + 177 + " -L, --dereference follow symlinks\n" + 178 + " --help display this help and exit\n", 179 + }, 180 + { 181 + name: "unknown flag", 182 + args: []string{"--nope"}, 183 + wantErr: true, 184 + wantStderr: "file: unknown option: --nope\n" + 185 + "Usage: file [OPTION]... FILE...\n" + 186 + "Determine file type.\n\n" + 187 + " -b, --brief do not prepend filenames to output\n" + 188 + " -i, --mime output MIME type strings\n" + 189 + " -L, --dereference follow symlinks\n" + 190 + " --help display this help and exit\n", 191 + }, 192 + { 193 + name: "subdirectory file", 194 + args: []string{"subdir/inner.txt"}, 195 + wantStdout: "subdir/inner.txt: ASCII text\n", 196 + }, 197 + { 198 + name: "dereference flag accepted", 199 + args: []string{"-L", "hello.txt"}, 200 + wantStdout: "hello.txt: ASCII text\n", 201 + }, 202 + } 203 + 204 + for _, tc := range tests { 205 + t.Run(tc.name, func(t *testing.T) { 206 + var stdout, stderr bytes.Buffer 207 + ec := &command.ExecContext{ 208 + Stdout: &stdout, 209 + Stderr: &stderr, 210 + Dir: ".", 211 + FS: newFS(t), 212 + } 213 + err := Impl{}.Exec(context.Background(), ec, tc.args) 214 + if tc.wantErr && err == nil { 215 + t.Fatalf("expected error, got nil") 216 + } 217 + if !tc.wantErr && err != nil { 218 + t.Fatalf("unexpected error: %v", err) 219 + } 220 + if got := stdout.String(); got != tc.wantStdout { 221 + t.Errorf("stdout mismatch\nwant: %q\ngot: %q", tc.wantStdout, got) 222 + } 223 + if got := stderr.String(); got != tc.wantStderr { 224 + t.Errorf("stderr mismatch\nwant: %q\ngot: %q", tc.wantStderr, got) 225 + } 226 + }) 227 + } 228 + } 229 + 230 + func TestExec_NilContext(t *testing.T) { 231 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 232 + t.Fatal("expected error for nil ExecContext") 233 + } 234 + } 235 + 236 + func TestExec_NoFS(t *testing.T) { 237 + ec := &command.ExecContext{ 238 + Stdout: &bytes.Buffer{}, 239 + Stderr: &bytes.Buffer{}, 240 + } 241 + if err := (Impl{}).Exec(context.Background(), ec, nil); err == nil { 242 + t.Fatal("expected error for missing filesystem") 243 + } 244 + }
+2
command/registry/coreutils/coreutils.go
··· 14 14 "tangled.org/xeiaso.net/kefka/command/internal/du" 15 15 "tangled.org/xeiaso.net/kefka/command/internal/expand" 16 16 "tangled.org/xeiaso.net/kefka/command/internal/expr" 17 + "tangled.org/xeiaso.net/kefka/command/internal/file" 17 18 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 18 19 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 19 20 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 36 37 reg.Register("du", du.Impl{}) 37 38 reg.Register("expand", expand.Impl{}) 38 39 reg.Register("expr", expr.Impl{}) 40 + reg.Register("file", file.Impl{}) 39 41 reg.Register("false", falsecmd.Impl{}) 40 42 reg.Register("hostname", hostname.Impl{}) 41 43 reg.Register("ls", ls.Impl{})