Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement os:chmod #1730

Closed
wants to merge 1 commit into from
Closed
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
2 changes: 2 additions & 0 deletions 0.20.0-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Draft release notes for Elvish 0.20.0.
variable. Builtin UI elements as well as styled texts will no have colors if
it is set and non-empty.

- A new `os:chmod` command ([#1659](https://b.elv.sh/1659)).

# Notable bugfixes

- `has-value $li $v` now works correctly when `$li` is a list and `$v` is a
Expand Down
7 changes: 7 additions & 0 deletions pkg/eval/errs/errs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
package errs

import (
"errors"
"fmt"
"strconv"

"src.elv.sh/pkg/parse"
)

// Generic is an error whose specific value does not matter. Use it anywhere you
// might use `errors.New("")`; i.e., when the specific message does not matter.
//
//lint:ignore ST1012 test code
var Generic = errors.New("generic error")

// OutOfRange encodes an error where a value is out of its valid range.
type OutOfRange struct {
What string
Expand Down
8 changes: 2 additions & 6 deletions pkg/eval/go_fn_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package eval_test

import (
"errors"
"math/big"
"reflect"
"testing"
Expand All @@ -19,9 +18,6 @@ type someOptions struct {

func (o *someOptions) SetDefaultOptions() { o.Bar = "default" }

//lint:ignore ST1012 test code
var anError = errors.New("an error")

type namedSlice []string

func TestGoFn_RawOptions(t *testing.T) {
Expand Down Expand Up @@ -141,8 +137,8 @@ func TestGoFn_RawOptions(t *testing.T) {
WithSetup(f(func() namedSlice { return namedSlice{"foo", "bar"} })),

// Error return value
That("f").Throws(anError).
WithSetup(f(func() (string, error) { return "x", anError })),
That("f").Throws(errs.Generic).
WithSetup(f(func() (string, error) { return "x", errs.Generic })),
That("f").DoesNothing().
WithSetup(f(func() error { return nil })),
)
Expand Down
39 changes: 39 additions & 0 deletions pkg/mods/os/os.d.elv
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,45 @@ fn -is-exist {|exc| }
# does not exist.
fn -is-not-exist {|exc| }

# Change the mode of `$path`.
#
# The `$mode` argument must be an integer. It is used as the absolute mode
# value to assign to `$path`. If `$mode` is a string rather than a
# [`num`](language.html#number) it can have a radix prefix (e.g., `0o` to
# indicate the following digits are octal). If there is not an explicit radix
# prefix octal is assumed. If that implicit radix conversion fails then the
# value is assumed to be radix 10. This is different from nearly all other
# Elvish string to number conversions which assume a string without an
# explicit radix are decimal.
#
# On Unix systems the permission bits (0o777, representing the owner, group,
# and other permission groups), SetUid (0o4000), SetGid (0o2000), and Sticky
# (0o1000) bits are valid. The permission bits are:
#
# - 0o400 Allow reads by the owner.
# - 0o200 Allow writes by the owner.
# - 0o100 Allow execution by the owner if a file, else search if a directory.
# - 0o040 Allow reads by group members.
# - 0o020 Allow writes by group members.
# - 0o010 Allow execution by group members if a file, else search if a directory.
# - 0o004 Allow reads by others.
# - 0o002 Allow writes by others.
# - 0o001 Allow execution by others if a file, else search if a directory.
#
# On Windows only the user write bit (0o200) is valid (the other bits are
# ignored). If that bit is set the file mode allows writing; otherwise, the
# file is read-only.
#
# ```elvish-transcript
# ~> echo > f
# ~> ls -l f
# -rw-r----- 1 user group 1 Mar 29 20:28 f
# ~> os:chmod 0o2123 f
# ~> ls -l f # Note the `S` for the SetGid mode
# ---x-wS-wx 1 user group 1 Mar 29 20:28 f*
# ```
fn chmod {|mode path| }

# Creates a new directory with the specified name and permission (before umask).
fn mkdir {|&perm=0o755 path| }

Expand Down
86 changes: 86 additions & 0 deletions pkg/mods/os/os.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ package os

import (
_ "embed"
"fmt"
"io/fs"
"math/big"
"os"
"path/filepath"
"strconv"

"src.elv.sh/pkg/eval"
"src.elv.sh/pkg/eval/errs"
Expand All @@ -22,6 +26,7 @@ var Ns = eval.BuildNsNamed("os").
"-is-exist": isExist,
"-is-not-exist": isNotExist,

"chmod": chmod,
"mkdir": mkdir,
"remove": remove,
"remove-all": removeAll,
Expand Down Expand Up @@ -163,3 +168,84 @@ func optionalTempPattern(args []string) (string, error) {
ValidLow: 0, ValidHigh: 1, Actual: len(args)}
}
}

const (
// These are the publicly visible non-permission file mode bits. We map
// these to the Go fs package equivalents. Other fs.FileMode bits (ignoring
// the permission bits) are excluded from the mode value we make public.
stickyBit = uint32(0o1000)
setGidBit = uint32(0o2000)
setUidBit = uint32(0o4000)
)

// convertModes does two things:
//
// 1) map various representations of file mode bits to a simple unsigned integer
// suitable for the `os.Chmod` function, and
//
// 2) map legacy POSIX non-permission special bits to the equivalent bits
// recognized by the `os.Chmod` function.
func convertModes(val any) (uint32, error) {
var mode uint64
var err error
switch val := val.(type) {
case string:
// Assume the user provided an octal number without an explicit base
// prefix. If that fails try to parse it as if it has an explicit prefix
// or the value is base 10. This is consistent with the historical POSIX
// chmod command.
//
// TODO: Decide if support for symbolic absolute and relative modes
// should be added. Such as `=rw`, `go=`, or `go=u-w` (all from the
// chmod man page on macOS). If the decision is not to support symbolic
// modes then this comment should be replaced with a comment explaining
// whey symbolic modes are not supported.
if mode, err = strconv.ParseUint(val, 8, 32); err != nil {
mode, err = strconv.ParseUint(val, 0, 32)
}
case int:
mode = uint64(val)
case *big.Int:
if val.IsUint64() {
mode = val.Uint64()
} else {
err = errs.Generic
}
default:
err = errs.Generic
}

if err != nil || (mode&0o7777) != mode {
return 0, errs.OutOfRange{
What: "mode (an integer)",
ValidLow: "0",
ValidHigh: "0o7777",
Actual: fmt.Sprintf("%v", val),
}
}

// We've validated mode is a 32 bit value via the range test above.
// This block exists to map the non-permission mode special bits.
filePerms := uint32(mode)
if filePerms&stickyBit == stickyBit {
filePerms = uint32(fs.ModeSticky) | (filePerms & ^stickyBit)
}
if filePerms&setGidBit == setGidBit {
filePerms = uint32(fs.ModeSetgid) | (filePerms & ^setGidBit)
}
if filePerms&setUidBit == setUidBit {
filePerms = uint32(fs.ModeSetuid) | (filePerms & ^setUidBit)
}
return filePerms, nil
}

// chmod modifies the mode (primarily the permissions) of a filesystem path.
func chmod(modes any, path string) error {
fileMode, err := convertModes(modes)
if err != nil {
return err
}

newMode := fs.FileMode(fileMode)
return os.Chmod(path, newMode)
}
8 changes: 8 additions & 0 deletions pkg/mods/os/os_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func TestFSModifications(t *testing.T) {
testutil.InTempDir(t)
useOS(ev)
},
//chmod
That("os:chmod").Throws(
errs.ArityMismatch{What: "arguments", ValidLow: 2, ValidHigh: 2, Actual: 0}),
That("os:chmod x y").Throws(ErrorWithType(errs.OutOfRange{})),
That("os:chmod 0o10777 x").Throws(ErrorWithType(errs.OutOfRange{})),
That("os:chmod (inexact-num 0o777) x").Throws(ErrorWithType(errs.OutOfRange{})),
// See the os_*_test.go files for os:chmod platform specific tests.

// mkdir
That(`os:mkdir d; os:is-dir d`).Puts(true),
That(`os:mkdir d; try { os:mkdir d } catch e { os:-is-exist $e }`).
Expand Down
29 changes: 29 additions & 0 deletions pkg/mods/os/os_unix_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package os_test

import (
"runtime"
"testing"

"src.elv.sh/pkg/testutil"
Expand All @@ -28,3 +29,31 @@ func TestExists_BadSymlink(t *testing.T) {
Puts(true, false),
)
}

func TestChmod(t *testing.T) {
testutil.InTempDir(t)

TestWithEvalerSetup(t, useOS,
That("echo >a; os:chmod 0o777 a; put (os:stat a)[perm]").Puts(0o777),
That("echo >b; os:chmod 0o123 b; put (os:stat b)[perm]").Puts(0o123),
That("echo >c; os:chmod 753 c; put (os:stat c)[perm]").Puts(0o753),
That("echo >d; os:chmod 321 d; put (os:stat d)[perm]").Puts(0o321),
That("echo >e; os:chmod (num 0o666) e; put (os:stat e)[perm]").Puts(0o666),
That("echo >h; os:chmod (num 0o4664) h; var s = (os:stat h); "+
"put $s[perm] (count $s[special-modes]) $s[special-modes][0]").Puts(0o664, 1, "setuid"),
)
// These two tests fail on FreeBSD with "inappropriate file type or format"
// and "operation not permitted" respectively. I don't know why but will
// trust that if they pass on Linux and macOS then the mode bits are
// correctly mapped.
if runtime.GOOS != "freebsd" {
TestWithEvalerSetup(t, useOS,
That("echo >f; os:chmod (num 0o1567) f; var s = (os:stat f); "+
"put $s[perm] (count $s[special-modes]) $s[special-modes][0]").
Puts(0o567, 1, "sticky"),
That("echo >g; os:chmod (num 0o2444) g; var s = (os:stat g); "+
"put $s[perm] (count $s[special-modes]) $s[special-modes][0]").
Puts(0o444, 1, "setgid"),
)
}
}
23 changes: 23 additions & 0 deletions pkg/mods/os/os_windows_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
//go:build windows

package os_test

import (
"testing"

"src.elv.sh/pkg/testutil"
)

func TestChmod(t *testing.T) {
testutil.InTempDir(t)

TestWithEvalerSetup(t, useOS,
// These tests are weird for someone expecting Unix semantics. On MS
// Windows the read bit is always set, there is no execute bit, and only
// the write bit can be changed. There is no distinction between
// user/group/public permissions.
That("echo >a; os:chmod 0o707 a; put (os:stat a)[perm]").Puts(0o666),
That("echo >b; os:chmod 0o131 b; put (os:stat b)[perm]").Puts(0o444),
That("echo >c; os:chmod 0o113 c; put (os:stat c)[perm]").Puts(0o444),
)
}