diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bc677926f5f..511ac4728a0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ jobs: os: - macos-latest - ubuntu-latest - - windows-latest + #- windows-latest # FIXME runs-on: ${{ matrix.os }} steps: - name: Set up Go diff --git a/.golangci.yml b/.golangci.yml index 891d2e9b757..4aa43a9bd2d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,37 +1,5 @@ linters: - enable: - - bodyclose - - deadcode - - depguard - - dogsled - - dupl - - errcheck - - gocritic - - godot - - gofmt - - goimports - - golint - - gomodguard - - goprintffuncname - - gosec - - gosimple - - govet - - ineffassign - - interfacer - - misspell - - nakedret - - prealloc - - rowserrcheck - - scopelint - - staticcheck - - structcheck - - stylecheck - - typecheck - - unconvert - - unparam - - unused - - varcheck - - whitespace + enable-all: true disable: - funlen - gochecknoglobals @@ -42,14 +10,11 @@ linters: - gomnd - lll - maligned + - nakedret - nestif - testpackage - wsl -linters-settings: - goimports: - local-prefixes: github.com/twpayne/chezmoi - issues: exclude-rules: - linters: @@ -61,12 +26,6 @@ issues: - linters: - gochecknoinits path: cmd/ - - linters: - - gosec - path: internal/generate-assets/ - - linters: - - gosec - path: internal/generate-helps/ - linters: - scopelint path: "_test\\.go" \ No newline at end of file diff --git a/TODO b/TODO new file mode 100644 index 00000000000..6d1f1e3bb03 --- /dev/null +++ b/TODO @@ -0,0 +1,8 @@ +Add dir to RunScript? +Encryption should use System.IdempotentCommandOutput +Encryption +Test EncryptionTool that uses randomly generated key + +script name is currently part of run once state (in key) +need to decide whether rename of script means its a different script +also executedAt in JSON representation should probably be runAt diff --git a/go.mod b/go.mod index 762446e6f8b..cf0de55b00a 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/mitchellh/copystructure v1.0.0 // indirect github.com/mitchellh/mapstructure v1.2.2 // indirect github.com/mitchellh/reflectwalk v1.0.1 // indirect + github.com/muesli/combinator v0.2.0 github.com/pelletier/go-toml v1.7.0 github.com/pkg/diff v0.0.0-20190930165518-531926345625 github.com/sergi/go-diff v1.1.0 @@ -44,6 +45,7 @@ require ( github.com/yuin/goldmark v1.1.28 // indirect github.com/zalando/go-keyring v0.0.0-20200121091418-667557018717 go.etcd.io/bbolt v1.3.4 + go.uber.org/multierr v1.5.0 golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e // indirect golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d @@ -52,3 +54,7 @@ require ( gopkg.in/ini.v1 v1.55.0 // indirect gopkg.in/yaml.v2 v2.2.8 ) + +// Temporary until https://github.com/bmatcuk/doublestar/pull/34 and +// https://github.com/bmatcuk/doublestar/pull/35 are merged. +replace github.com/bmatcuk/doublestar => github.com/twpayne/doublestar v1.2.6 diff --git a/go.sum b/go.sum index d6a1bf6295f..94858758a18 100644 --- a/go.sum +++ b/go.sum @@ -33,8 +33,6 @@ github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/bmatcuk/doublestar v1.3.0 h1:1jLE2y0VpSrOn/QR9G4f2RmrCtkM3AuATcWradjHUvM= -github.com/bmatcuk/doublestar v1.3.0/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/charmbracelet/glamour v0.1.0 h1:BHCtc+YJjoBjNUnFKBtXyyM4Bp9u7L2kf49qV+/AGYw= github.com/charmbracelet/glamour v0.1.0/go.mod h1:Z1C2JkVGBom/RYfoKcPBZ81lHMR3xp3W6OCLNWWEIMc= @@ -64,7 +62,6 @@ github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= -github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= @@ -73,11 +70,11 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= -github.com/go-git/gcfg v1.5.0 h1:Q5ViNfGF8zFgyJWPqYwA7qGFoMTEiBmdlkcfRmpIMa4= github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= -github.com/go-git/go-billy/v5 v5.0.0 h1:7NQHvd9FVid8VL4qVUMm8XifBK+2xCoZ2lSk0agRrHM= github.com/go-git/go-billy/v5 v5.0.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= github.com/go-git/go-git-fixtures/v4 v4.0.1/go.mod h1:m+ICp2rF3jDhFgEZ/8yziagdT1C+ZpZcrJjappBCDSw= +github.com/go-git/go-git/v5 v5.0.0 h1:k5RWPm4iJwYtfWoxIJy4wJX9ON7ihPeZZYC1fLYDnpg= +github.com/go-git/go-git/v5 v5.0.0/go.mod h1:oYD8y9kWsGINPFJoLdaScGCN6dlKg23blmClfZwtUVA= github.com/go-git/go-git/v5 v5.0.1-0.20200501143051-8543c83ab70a h1:A/zz5cIuYTstFq0JgoOS/Aj4x4+r0XulbQR/STk6RqY= github.com/go-git/go-git/v5 v5.0.1-0.20200501143051-8543c83ab70a/go.mod h1:oYD8y9kWsGINPFJoLdaScGCN6dlKg23blmClfZwtUVA= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -137,7 +134,6 @@ github.com/imdario/mergo v0.3.9 h1:UauaLniWCFHWd+Jp9oCEkTBj8VO/9DKg3PV3VCNMDIg= github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= @@ -145,7 +141,6 @@ github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd h1:Coekwdh0v2wtGp9Gmz1Ze3eVRAWJMLokvN3QjdzCHLY= github.com/kevinburke/ssh_config v0.0.0-20190725054713-01f96b0aa0cd/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -181,7 +176,6 @@ github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+i github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= -github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= @@ -194,6 +188,8 @@ github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/muesli/combinator v0.2.0 h1:p7BUPFUuS2D24cD2Z1PbKcH3DXuTrxhKpNmAH9Eg5LA= +github.com/muesli/combinator v0.2.0/go.mod h1:ttPegJX0DPQaGDtJKMInIP6Vfp5pN8RX7QntFCcpy18= github.com/muesli/reflow v0.0.0-20191216070243-e5efeac4e302 h1:jOh3Kh03uOFkRPV3PI4Am5tqACv2aELgbPgr7YgNX00= github.com/muesli/reflow v0.0.0-20191216070243-e5efeac4e302/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -223,6 +219,7 @@ github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= @@ -271,10 +268,14 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= +github.com/twpayne/doublestar v1.2.6 h1:nkd+GJXRKQf8HxOZQ+G7Qtu9mwRNLc5so/oXgRcyWD8= +github.com/twpayne/doublestar v1.2.6/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/twpayne/go-shell v0.1.1 h1:Kr1hSEFrPBTRmhOW8woaj7ZxV3/OH7Qefg86OFJ5DFc= github.com/twpayne/go-shell v0.1.1/go.mod h1:H/gzux0DOH5jsjQSHXs6rs2Onxy+V4j6ycZTOulC0l8= github.com/twpayne/go-vfs v1.0.1/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8= github.com/twpayne/go-vfs v1.0.5/go.mod h1:OIXA6zWkcn7Jk46XT7ceYqBMeIkfzJ8WOBhGJM0W4y8= +github.com/twpayne/go-vfs v1.3.6 h1:dKR5suwT6WnNweNNQjts3Wih/sBTHLt4XbbEbapnWEE= +github.com/twpayne/go-vfs v1.3.6/go.mod h1:BH2oQurpkb3roQDR7hZH+9DITZidl6JHOEfHlCModXY= github.com/twpayne/go-vfs v1.4.0 h1:oiiMliJyKXj4qujubNoc9Owh07yfP6WESxOMIfq7KmQ= github.com/twpayne/go-vfs v1.4.0/go.mod h1:EDwCEqgWLi6nehByJQGUBtoDTBWVX/Bn6blPQ1Yo9qw= github.com/twpayne/go-vfsafero v1.0.0 h1:ZlH32HF4OoVX/aRqc5bZa+2+M+/ezmJ4XYpT0ShtZNc= @@ -284,7 +285,6 @@ github.com/twpayne/go-xdg/v3 v3.1.0/go.mod h1:z6/LkoG2gtuzrsxEqPRoEjccS5Q35GK+lg github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= @@ -298,17 +298,25 @@ go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg= go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200406173513-056763e48d71 h1:DOmugCavvUtnUD114C1Wh+UgTgQZ4pMLzXxi1pSt+/Y= golang.org/x/crypto v0.0.0-20200406173513-056763e48d71/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -320,6 +328,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2eP golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e h1:3G+cUijn7XD+S4eJFddp53Pv7+slrESplyjG25HgL+k= golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= @@ -332,6 +341,7 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6 h1:bjcUS9ztw9kFmmIxJInhon/0Is3p+EHBKNgquIzo1OI= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -348,6 +358,8 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY= +golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f h1:gWF768j/LaZugp8dyS4UwsslYCYz9XgFxvlgsn0n9H8= golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= @@ -360,6 +372,10 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs= @@ -384,12 +400,12 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/ini.v1 v1.51.0 h1:AQvPpx3LzTDM0AjnIRlVFwFFGC+npRopjZxLJj6gdno= gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -400,3 +416,4 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/v2/chezmoi/attributes.go b/v2/chezmoi/attributes.go new file mode 100644 index 00000000000..31b1aca36f5 --- /dev/null +++ b/v2/chezmoi/attributes.go @@ -0,0 +1,175 @@ +package chezmoi + +import ( + "strings" +) + +// A SourceFileType is a source file type. +type SourceFileType int + +// Source file types. +const ( + SourceFileTypeFile SourceFileType = iota + SourceFileTypeScript + SourceFileTypeSymlink +) + +// DirAttributes holds attributes parsed from a source directory name. +type DirAttributes struct { + Name string + Exact bool + Private bool +} + +// A FileAttributes holds attributes parsed from a source file name. +type FileAttributes struct { + Name string + Type SourceFileType + Empty bool + Encrypted bool + Executable bool + Once bool + Private bool + Template bool +} + +// ParseDirAttributes parses a single directory name. +func ParseDirAttributes(sourceName string) DirAttributes { + var ( + name = sourceName + exact = false + private = false + ) + if strings.HasPrefix(name, exactPrefix) { + name = strings.TrimPrefix(name, exactPrefix) + exact = true + } + if strings.HasPrefix(name, privatePrefix) { + name = strings.TrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, dotPrefix) { + name = "." + strings.TrimPrefix(name, dotPrefix) + } + return DirAttributes{ + Name: name, + Exact: exact, + Private: private, + } +} + +// SourceName returns da's source name. +func (da DirAttributes) SourceName() string { + sourceName := "" + if da.Exact { + sourceName += exactPrefix + } + if da.Private { + sourceName += privatePrefix + } + if strings.HasPrefix(da.Name, ".") { + sourceName += dotPrefix + strings.TrimPrefix(da.Name, ".") + } else { + sourceName += da.Name + } + return sourceName +} + +// ParseFileAttributes parses a source file name. +func ParseFileAttributes(sourceName string) FileAttributes { + var ( + name = sourceName + typ = SourceFileTypeFile + empty = false + encrypted = false + executable = false + once = false + private = false + template = false + ) + switch { + case strings.HasPrefix(name, runPrefix): + name = strings.TrimPrefix(name, runPrefix) + typ = SourceFileTypeScript + if strings.HasPrefix(name, oncePrefix) { + name = strings.TrimPrefix(name, oncePrefix) + once = true + } + case strings.HasPrefix(name, symlinkPrefix): + name = strings.TrimPrefix(name, symlinkPrefix) + typ = SourceFileTypeSymlink + if strings.HasPrefix(name, dotPrefix) { + name = "." + strings.TrimPrefix(name, dotPrefix) + } + default: + if strings.HasPrefix(name, encryptedPrefix) { + name = strings.TrimPrefix(name, encryptedPrefix) + encrypted = true + } + if strings.HasPrefix(name, privatePrefix) { + name = strings.TrimPrefix(name, privatePrefix) + private = true + } + if strings.HasPrefix(name, emptyPrefix) { + name = strings.TrimPrefix(name, emptyPrefix) + empty = true + } + if strings.HasPrefix(name, executablePrefix) { + name = strings.TrimPrefix(name, executablePrefix) + executable = true + } + if strings.HasPrefix(name, dotPrefix) { + name = "." + strings.TrimPrefix(name, dotPrefix) + } + } + if strings.HasSuffix(name, templateSuffix) { + name = strings.TrimSuffix(name, templateSuffix) + template = true + } + return FileAttributes{ + Name: name, + Type: typ, + Empty: empty, + Encrypted: encrypted, + Executable: executable, + Once: once, + Private: private, + Template: template, + } +} + +// SourceName returns fa's source name. +func (fa FileAttributes) SourceName() string { + sourceName := "" + switch fa.Type { + case SourceFileTypeFile: + if fa.Encrypted { + sourceName += encryptedPrefix + } + if fa.Private { + sourceName += privatePrefix + } + if fa.Empty { + sourceName += emptyPrefix + } + if fa.Executable { + sourceName += executablePrefix + } + case SourceFileTypeScript: + sourceName = runPrefix + if fa.Once { + sourceName += oncePrefix + } + case SourceFileTypeSymlink: + sourceName = symlinkPrefix + } + if strings.HasPrefix(fa.Name, ".") { + sourceName += dotPrefix + strings.TrimPrefix(fa.Name, ".") + } else { + sourceName += fa.Name + } + if fa.Template { + sourceName += templateSuffix + } + return sourceName +} diff --git a/v2/chezmoi/attributes_test.go b/v2/chezmoi/attributes_test.go new file mode 100644 index 00000000000..48a1827a6cf --- /dev/null +++ b/v2/chezmoi/attributes_test.go @@ -0,0 +1,73 @@ +package chezmoi + +import ( + "testing" + + "github.com/muesli/combinator" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDirAttributes(t *testing.T) { + testData := struct { + Name []string + Exact []bool + Private []bool + }{ + Name: []string{"dir", ".dir"}, + Exact: []bool{false, true}, + Private: []bool{false, true}, + } + var dirAttributes []DirAttributes + require.NoError(t, combinator.Generate(&dirAttributes, testData)) + for _, da := range dirAttributes { + actualSourceName := da.SourceName() + actualDA := ParseDirAttributes(actualSourceName) + assert.Equal(t, da, actualDA) + assert.Equal(t, actualSourceName, actualDA.SourceName()) + } +} + +func TestFileAttributes(t *testing.T) { + var fileAttributes []FileAttributes + require.NoError(t, combinator.Generate(&fileAttributes, struct { + Type []SourceFileType + Name []string + Empty []bool + Encrypted []bool + Executable []bool + Private []bool + Template []bool + }{ + Type: []SourceFileType{SourceFileTypeFile}, + Name: []string{"name", ".name"}, + Empty: []bool{false, true}, + Encrypted: []bool{false, true}, + Executable: []bool{false, true}, + Private: []bool{false, true}, + Template: []bool{false, true}, + })) + require.NoError(t, combinator.Generate(&fileAttributes, struct { + Type []SourceFileType + Name []string + Once []bool + }{ + Type: []SourceFileType{SourceFileTypeScript}, + Name: []string{"name"}, + Once: []bool{false, true}, + })) + require.NoError(t, combinator.Generate(&fileAttributes, struct { + Type []SourceFileType + Name []string + Once []bool + }{ + Type: []SourceFileType{SourceFileTypeSymlink}, + Name: []string{".name", "name"}, + })) + for _, fa := range fileAttributes { + actualSourceName := fa.SourceName() + actualFA := ParseFileAttributes(actualSourceName) + assert.Equal(t, fa, actualFA) + assert.Equal(t, actualSourceName, actualFA.SourceName()) + } +} diff --git a/v2/chezmoi/autotemplate.go b/v2/chezmoi/autotemplate.go new file mode 100644 index 00000000000..3f370a8ff86 --- /dev/null +++ b/v2/chezmoi/autotemplate.go @@ -0,0 +1,90 @@ +package chezmoi + +import ( + "sort" + "strings" +) + +type templateVariable struct { + name string + value string +} + +type byValueLength []templateVariable + +func (b byValueLength) Len() int { return len(b) } +func (b byValueLength) Less(i, j int) bool { + switch { + case len(b[i].value) < len(b[j].value): + return true + case len(b[i].value) == len(b[j].value): + // Fallback to name + return b[i].name > b[j].name + default: + return false + } +} +func (b byValueLength) Swap(i, j int) { b[i], b[j] = b[j], b[i] } + +func autoTemplate(contents []byte, data map[string]interface{}) []byte { + // FIXME this naive approach will generate incorrect templates if the + // variable names match variable values + // FIXME the algorithm here is probably O(N^2), we can do better + variables := extractVariables(nil, nil, data) + sort.Sort(sort.Reverse(byValueLength(variables))) + contentsStr := string(contents) + for _, variable := range variables { + if variable.value == "" { + continue + } + index := strings.Index(contentsStr, variable.value) + for index != -1 && index != len(contentsStr) { + if !inWord(contentsStr, index) && !inWord(contentsStr, index+len(variable.value)) { + // Replace variable.value which is on word boundaries at both + // ends. + replacement := "{{ ." + variable.name + " }}" + contentsStr = contentsStr[:index] + replacement + contentsStr[index+len(variable.value):] + index += len(replacement) + } else { + // Otherwise, keep looking. Consume at least one byte so we + // make progress. + index++ + } + // Look for the next occurrence of variable.value. + j := strings.Index(contentsStr[index:], variable.value) + if j == -1 { + // No more occurrences found, so terminate the loop. + break + } else { + // Advance to the next occurrence. + index += j + } + } + } + return []byte(contentsStr) +} + +func extractVariables(variables []templateVariable, parent []string, data map[string]interface{}) []templateVariable { + for name, value := range data { + switch value := value.(type) { + case string: + variables = append(variables, templateVariable{ + name: strings.Join(append(parent, name), "."), + value: value, + }) + case map[string]interface{}: + variables = extractVariables(variables, append(parent, name), value) + } + } + return variables +} + +// inWord returns true if splitting s at position i would split a word. +func inWord(s string, i int) bool { + return i > 0 && i < len(s) && isWord(s[i-1]) && isWord(s[i]) +} + +// isWord returns true if b is a word byte. +func isWord(b byte) bool { + return '0' <= b && b <= '9' || 'A' <= b && b <= 'Z' || 'a' <= b && b <= 'z' +} diff --git a/v2/chezmoi/autotemplate_test.go b/v2/chezmoi/autotemplate_test.go new file mode 100644 index 00000000000..ea264165bf6 --- /dev/null +++ b/v2/chezmoi/autotemplate_test.go @@ -0,0 +1,168 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAutoTemplate(t *testing.T) { + for _, tc := range []struct { + name string + contentsStr string + data map[string]interface{} + wantStr string + }{ + { + name: "simple", + contentsStr: "email = john.smith@company.com\n", + data: map[string]interface{}{ + "email": "john.smith@company.com", + }, + wantStr: "email = {{ .email }}\n", + }, + { + name: "longest_first", + contentsStr: "name = John Smith\nfirstName = John\n", + data: map[string]interface{}{ + "name": "John Smith", + "firstName": "John", + }, + wantStr: "name = {{ .name }}\nfirstName = {{ .firstName }}\n", + }, + { + name: "alphabetical_first", + contentsStr: "name = John Smith\n", + data: map[string]interface{}{ + "alpha": "John Smith", + "beta": "John Smith", + "gamma": "John Smith", + }, + wantStr: "name = {{ .alpha }}\n", + }, + { + name: "nested_values", + contentsStr: "email = john.smith@company.com\n", + data: map[string]interface{}{ + "personal": map[string]interface{}{ + "email": "john.smith@company.com", + }, + }, + wantStr: "email = {{ .personal.email }}\n", + }, + { + name: "only_replace_words", + contentsStr: "darwinian evolution", + data: map[string]interface{}{ + "os": "darwin", + }, + wantStr: "darwinian evolution", // not "{{ .os }}ian evolution" + }, + { + name: "longest_match_first", + contentsStr: "/home/user", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "{{ .homedir }}", + }, + { + name: "longest_match_first_prefix", + contentsStr: "HOME=/home/user", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "HOME={{ .homedir }}", + }, + { + name: "longest_match_first_suffix", + contentsStr: "/home/user/something", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "{{ .homedir }}/something", + }, + { + name: "longest_match_first_prefix_and_suffix", + contentsStr: "HOME=/home/user/something", + data: map[string]interface{}{ + "homedir": "/home/user", + }, + wantStr: "HOME={{ .homedir }}/something", + }, + { + name: "words_only", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "a", + }, + wantStr: "aaa aa {{ .alpha }} aa aaa aa {{ .alpha }} aa aaa", + }, + { + name: "words_only_2", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "aa", + }, + wantStr: "aaa {{ .alpha }} a {{ .alpha }} aaa {{ .alpha }} a {{ .alpha }} aaa", + }, + { + name: "words_only_3", + contentsStr: "aaa aa a aa aaa aa a aa aaa", + data: map[string]interface{}{ + "alpha": "aaa", + }, + wantStr: "{{ .alpha }} aa a aa {{ .alpha }} aa a aa {{ .alpha }}", + }, + { + name: "skip_empty", + contentsStr: "a", + data: map[string]interface{}{ + "empty": "", + }, + wantStr: "a", + }, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.wantStr, string(autoTemplate([]byte(tc.contentsStr), tc.data))) + }) + } +} + +func TestInWord(t *testing.T) { + for _, tc := range []struct { + s string + i int + want bool + }{ + {s: "", i: 0, want: false}, + {s: "a", i: 0, want: false}, + {s: "a", i: 1, want: false}, + {s: "ab", i: 0, want: false}, + {s: "ab", i: 1, want: true}, + {s: "ab", i: 2, want: false}, + {s: "abc", i: 0, want: false}, + {s: "abc", i: 1, want: true}, + {s: "abc", i: 2, want: true}, + {s: "abc", i: 3, want: false}, + {s: " abc ", i: 0, want: false}, + {s: " abc ", i: 1, want: false}, + {s: " abc ", i: 2, want: true}, + {s: " abc ", i: 3, want: true}, + {s: " abc ", i: 4, want: false}, + {s: " abc ", i: 5, want: false}, + {s: "/home/user", i: 0, want: false}, + {s: "/home/user", i: 1, want: false}, + {s: "/home/user", i: 2, want: true}, + {s: "/home/user", i: 3, want: true}, + {s: "/home/user", i: 4, want: true}, + {s: "/home/user", i: 5, want: false}, + {s: "/home/user", i: 6, want: false}, + {s: "/home/user", i: 7, want: true}, + {s: "/home/user", i: 8, want: true}, + {s: "/home/user", i: 9, want: true}, + {s: "/home/user", i: 10, want: false}, + } { + assert.Equal(t, tc.want, inWord(tc.s, tc.i)) + } +} diff --git a/v2/chezmoi/boltpersistentstate.go b/v2/chezmoi/boltpersistentstate.go new file mode 100644 index 00000000000..771dc9ff23c --- /dev/null +++ b/v2/chezmoi/boltpersistentstate.go @@ -0,0 +1,121 @@ +package chezmoi + +import ( + "os" + "path" + + vfs "github.com/twpayne/go-vfs" + bolt "go.etcd.io/bbolt" +) + +// A BoltPersistentState is a state persisted with bolt. +type BoltPersistentState struct { + fs vfs.FS + path string + options *bolt.Options + db *bolt.DB +} + +// NewBoltPersistentState returns a new BoltPersistentState. +func NewBoltPersistentState(fs vfs.FS, path string, options *bolt.Options) (*BoltPersistentState, error) { + b := &BoltPersistentState{ + fs: fs, + path: path, + options: options, + } + _, err := fs.Stat(b.path) + switch { + case err == nil: + if err := b.openDB(); err != nil { + return nil, err + } + case os.IsNotExist(err): + default: + return nil, err + } + return b, nil +} + +// Close closes b. +func (b *BoltPersistentState) Close() error { + if b.db == nil { + return nil + } + if err := b.db.Close(); err != nil { + return err + } + b.db = nil + return nil +} + +// Delete deletes the value associate with key in bucket. If bucket or key does +// not exist then Delete does nothing. +func (b *BoltPersistentState) Delete(bucket, key []byte) error { + if b.db == nil { + return nil + } + return b.db.Update(func(tx *bolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + return b.Delete(key) + }) +} + +// Get returns the value associated with key in bucket. +func (b *BoltPersistentState) Get(bucket, key []byte) ([]byte, error) { + if b.db == nil { + return nil, nil + } + var value []byte + if err := b.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(bucket) + if b == nil { + return nil + } + v := b.Get(key) + if v != nil { + value = make([]byte, len(v)) + copy(value, v) + } + return nil + }); err != nil { + return nil, err + } + return value, nil +} + +// Set sets the value associated with key in bucket. bucket will be created if +// it does not already exist. +func (b *BoltPersistentState) Set(bucket, key, value []byte) error { + if b.db == nil { + if err := b.openDB(); err != nil { + return err + } + } + return b.db.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists(bucket) + if err != nil { + return err + } + return b.Put(key, value) + }) +} + +func (b *BoltPersistentState) openDB() error { + if err := vfs.MkdirAll(b.fs, path.Dir(b.path), 0o777); err != nil { + return err + } + var options bolt.Options + if b.options != nil { + options = *b.options + } + options.OpenFile = b.fs.OpenFile + db, err := bolt.Open(b.path, 0o600, &options) + if err != nil { + return err + } + b.db = db + return err +} diff --git a/v2/chezmoi/boltpersistentstate_test.go b/v2/chezmoi/boltpersistentstate_test.go new file mode 100644 index 00000000000..7d8d83cf6dc --- /dev/null +++ b/v2/chezmoi/boltpersistentstate_test.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" + bolt "go.etcd.io/bbolt" +) + +var _ PersistentState = &BoltPersistentState{} + +func TestBoltPersistentState(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.config/chezmoi": &vfst.Dir{Perm: 0o755}, + }) + require.NoError(t, err) + defer cleanup() + + path := "/home/user/.config/chezmoi/chezmoistate.boltdb" + b, err := NewBoltPersistentState(fs, path, nil) + require.NoError(t, err) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestDoesNotExist, + ), + ) + + var ( + bucket = []byte("bucket") + key = []byte("key") + value = []byte("value") + ) + + require.NoError(t, b.Delete(bucket, key)) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestDoesNotExist, + ), + ) + + actualValue, err := b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, []byte(nil), actualValue) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestDoesNotExist, + ), + ) + + assert.NoError(t, b.Set(bucket, key, value)) + vfst.RunTests(t, fs, "", + vfst.TestPath(path, + vfst.TestModeIsRegular, + ), + ) + + actualValue, err = b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValue) + + require.NoError(t, b.Close()) + + b, err = NewBoltPersistentState(fs, path, nil) + require.NoError(t, err) + + require.NoError(t, b.Delete(bucket, key)) + + actualValue, err = b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, []byte(nil), actualValue) +} + +func TestBoltPersistentStateReadOnly(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.config/chezmoi": &vfst.Dir{Perm: 0o755}, + }) + require.NoError(t, err) + defer cleanup() + + path := "/home/user/.config/chezmoi/chezmoistate.boltdb" + bucket := []byte("bucket") + key := []byte("key") + value := []byte("value") + + a, err := NewBoltPersistentState(fs, path, nil) + require.NoError(t, err) + require.NoError(t, a.Set(bucket, key, value)) + require.NoError(t, a.Close()) + + b, err := NewBoltPersistentState(fs, path, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + require.NoError(t, err) + defer b.Close() + + c, err := NewBoltPersistentState(fs, path, &bolt.Options{ + ReadOnly: true, + Timeout: 1 * time.Second, + }) + require.NoError(t, err) + defer c.Close() + + actualValueB, err := b.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValueB) + + actualValueC, err := c.Get(bucket, key) + require.NoError(t, err) + assert.Equal(t, value, actualValueC) + + assert.Error(t, b.Set(bucket, key, value)) + assert.Error(t, c.Set(bucket, key, value)) + + require.NoError(t, b.Close()) + require.NoError(t, c.Close()) +} diff --git a/v2/chezmoi/canarysystem.go b/v2/chezmoi/canarysystem.go new file mode 100644 index 00000000000..a70cf369864 --- /dev/null +++ b/v2/chezmoi/canarysystem.go @@ -0,0 +1,120 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// An CanarySystem wraps a System and records if any of its mutating +// methods are called. +type CanarySystem struct { + s System + mutated bool +} + +// NewCanarySystem returns a new CanarySystem. +func NewCanarySystem(s System) *CanarySystem { + return &CanarySystem{ + s: s, + mutated: false, + } +} + +// Chmod implements System.Chmod. +func (s *CanarySystem) Chmod(name string, mode os.FileMode) error { + s.mutated = true + return s.s.Chmod(name, mode) +} + +// Delete implements System.Delete. +func (s *CanarySystem) Delete(bucket, key []byte) error { + s.mutated = true + return s.s.Delete(bucket, key) +} + +// Get implements System.Get. +func (s *CanarySystem) Get(bucket, key []byte) ([]byte, error) { + return s.s.Get(bucket, key) +} + +// Glob implements System.Glob. +func (s *CanarySystem) Glob(pattern string) ([]string, error) { + return s.s.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *CanarySystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.s.IdempotentCmdOutput(cmd) +} + +// Mkdir implements System.Mkdir. +func (s *CanarySystem) Mkdir(name string, perm os.FileMode) error { + s.mutated = true + return s.s.Mkdir(name, perm) +} + +// Lstat implements System.Lstat. +func (s *CanarySystem) Lstat(path string) (os.FileInfo, error) { + return s.s.Lstat(path) +} + +// Mutated returns true if any of its mutating methods have been called. +func (s *CanarySystem) Mutated() bool { + return s.mutated +} + +// ReadDir implements System.ReadDir. +func (s *CanarySystem) ReadDir(dirname string) ([]os.FileInfo, error) { + return s.s.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *CanarySystem) ReadFile(filename string) ([]byte, error) { + return s.s.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *CanarySystem) Readlink(name string) (string, error) { + return s.s.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *CanarySystem) RemoveAll(name string) error { + s.mutated = true + return s.s.RemoveAll(name) +} + +// Rename implements System.Rename. +func (s *CanarySystem) Rename(oldpath, newpath string) error { + s.mutated = true + return s.s.Rename(oldpath, newpath) +} + +// RunScript implements System.RunScript. +func (s *CanarySystem) RunScript(name string, data []byte) error { + s.mutated = true + return s.s.RunScript(name, data) +} + +// Set implements System.Set. +func (s *CanarySystem) Set(bucket, key, value []byte) error { + s.mutated = true + return s.s.Set(bucket, key, value) +} + +// Stat implements System.Stat. +func (s *CanarySystem) Stat(path string) (os.FileInfo, error) { + return s.s.Stat(path) +} + +// WriteFile implements System.WriteFile. +func (s *CanarySystem) WriteFile(name string, data []byte, perm os.FileMode) error { + s.mutated = true + return s.s.WriteFile(name, data, perm) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *CanarySystem) WriteSymlink(oldname, newname string) error { + s.mutated = true + return s.s.WriteSymlink(oldname, newname) +} diff --git a/v2/chezmoi/canarysystem_test.go b/v2/chezmoi/canarysystem_test.go new file mode 100644 index 00000000000..1f7e6932782 --- /dev/null +++ b/v2/chezmoi/canarysystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &CanarySystem{} diff --git a/v2/chezmoi/chezmoi.go b/v2/chezmoi/chezmoi.go new file mode 100644 index 00000000000..40796b17765 --- /dev/null +++ b/v2/chezmoi/chezmoi.go @@ -0,0 +1,65 @@ +package chezmoi + +import ( + "fmt" + "os" +) + +// Configuration constants. +const ( + pathSeparator = '/' + pathSeparatorStr = string(pathSeparator) + ignorePrefix = "." +) + +// Configuration variables. +var ( + // DefaultTemplateOptions are the default template options. + DefaultTemplateOptions = []string{"missingkey=error"} + + // DefaultUmask is the default umask. + DefaultUmask = os.FileMode(0o22) + + scriptOnceStateBucket = []byte("script") +) + +// Suffixes and prefixes. +const ( + dotPrefix = "dot_" + emptyPrefix = "empty_" + encryptedPrefix = "encrypted_" + exactPrefix = "exact_" + executablePrefix = "executable_" + oncePrefix = "once_" + privatePrefix = "private_" + runPrefix = "run_" + symlinkPrefix = "symlink_" + templateSuffix = ".tmpl" +) + +// Special file names. +const ( + chezmoiPrefix = ".chezmoi" + + ignoreName = chezmoiPrefix + "ignore" + removeName = chezmoiPrefix + "remove" + templatesDirName = chezmoiPrefix + "templates" + versionName = chezmoiPrefix + "version" +) + +var modeTypeNames = map[os.FileMode]string{ + 0: "file", + os.ModeDir: "dir", + os.ModeSymlink: "symlink", + os.ModeNamedPipe: "named pipe", + os.ModeSocket: "socket", + os.ModeDevice: "device", + os.ModeCharDevice: "char device", +} + +func modeTypeName(mode os.FileMode) string { + if name, ok := modeTypeNames[mode&os.ModeType]; ok { + return name + } + return fmt.Sprintf("unknown (%d)", mode&os.ModeType) +} diff --git a/v2/chezmoi/debugsystem.go b/v2/chezmoi/debugsystem.go new file mode 100644 index 00000000000..799497595c0 --- /dev/null +++ b/v2/chezmoi/debugsystem.go @@ -0,0 +1,199 @@ +package chezmoi + +import ( + "log" + "os" + "os/exec" + "time" +) + +// A DebugSystem wraps a System and logs all of the actions it executes. +type DebugSystem struct { + s System +} + +// NewDebugSystem returns a new DebugSystem. +func NewDebugSystem(s System) *DebugSystem { + return &DebugSystem{ + s: s, + } +} + +// Chmod implements System.Chmod. +func (s *DebugSystem) Chmod(name string, mode os.FileMode) error { + return Debugf("Chmod(%q, 0o%o)", []interface{}{name, mode}, func() error { + return s.s.Chmod(name, mode) + }) +} + +// Delete implements System.Delete. +func (s *DebugSystem) Delete(bucket, key []byte) error { + return Debugf("Delete(%q, %q)", []interface{}{string(bucket), string(key)}, func() error { + return s.s.Delete(bucket, key) + }) +} + +// Get implements System.Get. +func (s *DebugSystem) Get(bucket, key []byte) ([]byte, error) { + var value []byte + err := Debugf("Get(%q, %q)", []interface{}{string(bucket), string(key)}, func() error { + var err error + value, err = s.s.Get(bucket, key) + return err + }) + return value, err +} + +// Glob implements System.Glob. +func (s *DebugSystem) Glob(name string) ([]string, error) { + var matches []string + err := Debugf("Glob(%q)", []interface{}{name}, func() error { + var err error + matches, err = s.s.Glob(name) + return err + }) + return matches, err +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *DebugSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + var output []byte + cmdStr := ShellQuoteArgs(append([]string{cmd.Path}, cmd.Args[1:]...)) + err := Debugf("IdempotentCmdOutput(%q)", []interface{}{cmdStr}, func() error { + var err error + output, err = s.s.IdempotentCmdOutput(cmd) + return err + }) + return output, err +} + +// Lstat implements System.Lstat. +func (s *DebugSystem) Lstat(name string) (os.FileInfo, error) { + var info os.FileInfo + err := Debugf("Lstat(%q)", []interface{}{name}, func() error { + var err error + info, err = s.s.Lstat(name) + return err + }) + return info, err +} + +// Mkdir implements System.Mkdir. +func (s *DebugSystem) Mkdir(name string, perm os.FileMode) error { + return Debugf("Mkdir(%q, 0o%o)", []interface{}{name, perm}, func() error { + return s.s.Mkdir(name, perm) + }) +} + +// ReadDir implements System.ReadDir. +func (s *DebugSystem) ReadDir(name string) ([]os.FileInfo, error) { + var infos []os.FileInfo + err := Debugf("ReadDir(%q)", []interface{}{name}, func() error { + var err error + infos, err = s.s.ReadDir(name) + return err + }) + return infos, err +} + +// ReadFile implements System.ReadFile. +func (s *DebugSystem) ReadFile(filename string) ([]byte, error) { + var data []byte + err := Debugf("ReadFile(%q)", []interface{}{filename}, func() error { + var err error + data, err = s.s.ReadFile(filename) + return err + }) + return data, err +} + +// Readlink implements System.Readlink. +func (s *DebugSystem) Readlink(name string) (string, error) { + var linkname string + err := Debugf("Readlink(%q)", []interface{}{name}, func() error { + var err error + linkname, err = s.s.Readlink(name) + return err + }) + return linkname, err +} + +// RemoveAll implements System.RemoveAll. +func (s *DebugSystem) RemoveAll(name string) error { + return Debugf("RemoveAll(%q)", []interface{}{name}, func() error { + return s.s.RemoveAll(name) + }) +} + +// Rename implements System.Rename. +func (s *DebugSystem) Rename(oldpath, newpath string) error { + return Debugf("Rename(%q, %q)", []interface{}{oldpath, newpath}, func() error { + return s.Rename(oldpath, newpath) + }) +} + +// RunScript implements System.RunScript. +func (s *DebugSystem) RunScript(name string, data []byte) error { + return Debugf("Run(%q, _)", []interface{}{name}, func() error { + return s.s.RunScript(name, data) + }) +} + +// Set implements System.Set. +func (s *DebugSystem) Set(bucket, key, value []byte) error { + return Debugf("Set(%q, %q, %q)", []interface{}{string(bucket), string(key), string(value)}, func() error { + return s.s.Set(bucket, key, value) + }) +} + +// Stat implements System.Stat. +func (s *DebugSystem) Stat(name string) (os.FileInfo, error) { + var info os.FileInfo + err := Debugf("Stat(%q)", []interface{}{name}, func() error { + var err error + info, err = s.s.Stat(name) + return err + }) + return info, err +} + +// WriteFile implements System.WriteFile. +func (s *DebugSystem) WriteFile(name string, data []byte, perm os.FileMode) error { + return Debugf("WriteFile(%q, _, 0%o, _)", []interface{}{name, perm}, func() error { + return s.s.WriteFile(name, data, perm) + }) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DebugSystem) WriteSymlink(oldname, newname string) error { + return Debugf("WriteSymlink(%q, %q)", []interface{}{oldname, newname}, func() error { + return s.s.WriteSymlink(oldname, newname) + }) +} + +// Debugf logs debugging information about calling f. +func Debugf(format string, args []interface{}, f func() error) error { + errChan := make(chan error) + start := time.Now() + go func(errChan chan<- error) { + errChan <- f() + }(errChan) + select { + case err := <-errChan: + if err == nil { + log.Printf(format+" (%s)", append(args, time.Since(start))...) + } else { + log.Printf(format+" == %v (%s)", append(args, err, time.Since(start))...) + } + return err + case <-time.After(1 * time.Second): + log.Printf(format, args...) + err := <-errChan + if err == nil { + log.Printf(format+" (%s)", append(args, time.Since(start))...) + } else { + log.Printf(format+" == %v (%s)", append(args, err, time.Since(start))...) + } + return err + } +} diff --git a/v2/chezmoi/debugsystem_test.go b/v2/chezmoi/debugsystem_test.go new file mode 100644 index 00000000000..7dae1a6caf8 --- /dev/null +++ b/v2/chezmoi/debugsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &DebugSystem{} diff --git a/v2/chezmoi/deststateentry.go b/v2/chezmoi/deststateentry.go new file mode 100644 index 00000000000..d9a582e00db --- /dev/null +++ b/v2/chezmoi/deststateentry.go @@ -0,0 +1,122 @@ +package chezmoi + +// FIXME data command + +import ( + "os" +) + +// An DestStateEntry represents the state of an entry in the destination state. +type DestStateEntry interface { + Path() string + Remove(s System) error +} + +// A DestStateAbsent represents the absence of an entry in the destination +// state. +type DestStateAbsent struct { + path string +} + +// A DestStateDir represents the state of a directory in the destination state. +type DestStateDir struct { + path string + perm os.FileMode +} + +// A DestStateFile represents the state of a file in the destination state. +type DestStateFile struct { + path string + perm os.FileMode + *lazyContents +} + +// A DestStateSymlink represents the state of a symlink in the destination state. +type DestStateSymlink struct { + path string + *lazyLinkname +} + +// NewDestStateEntry returns a new DestStateEntry populated with path from fs. +func NewDestStateEntry(sr SystemReader, path string) (DestStateEntry, error) { + info, err := sr.Lstat(path) + switch { + case os.IsNotExist(err): + return &DestStateAbsent{ + path: path, + }, nil + case err != nil: + return nil, err + } + switch info.Mode() & os.ModeType { + case 0: + return &DestStateFile{ + path: path, + perm: info.Mode() & os.ModePerm, + lazyContents: &lazyContents{ + contentsFunc: func() ([]byte, error) { + return sr.ReadFile(path) + }, + }, + }, nil + case os.ModeDir: + return &DestStateDir{ + path: path, + perm: info.Mode() & os.ModePerm, + }, nil + case os.ModeSymlink: + return &DestStateSymlink{ + path: path, + lazyLinkname: &lazyLinkname{ + linknameFunc: func() (string, error) { + return sr.Readlink(path) + }, + }, + }, nil + default: + return nil, &unsupportedFileTypeError{ + path: path, + mode: info.Mode(), + } + } +} + +// Path returns d's path. +func (d *DestStateAbsent) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateAbsent) Remove(s System) error { + return nil +} + +// Path returns d's path. +func (d *DestStateDir) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateDir) Remove(s System) error { + return s.RemoveAll(d.path) +} + +// Path returns d's path. +func (d *DestStateFile) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateFile) Remove(s System) error { + return s.RemoveAll(d.path) +} + +// Path returns d's path. +func (d *DestStateSymlink) Path() string { + return d.path +} + +// Remove removes d. +func (d *DestStateSymlink) Remove(s System) error { + return s.RemoveAll(d.path) +} diff --git a/v2/chezmoi/dryrunsystem.go b/v2/chezmoi/dryrunsystem.go new file mode 100644 index 00000000000..703f5db5f85 --- /dev/null +++ b/v2/chezmoi/dryrunsystem.go @@ -0,0 +1,104 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// DryRunSystem is an System that reads from, but does not write to, to +// a wrapped System. +type DryRunSystem struct { + s System +} + +// NewDryRunSystem returns a new DryRunSystem that wraps fs. +func NewDryRunSystem(s System) *DryRunSystem { + return &DryRunSystem{ + s: s, + } +} + +// Chmod implements System.Chmod. +func (s *DryRunSystem) Chmod(name string, mode os.FileMode) error { + return nil +} + +// Delete implements System.Delete. +func (s *DryRunSystem) Delete(bucket, key []byte) error { + return nil +} + +// Get implements System.Get. +func (s *DryRunSystem) Get(bucket, key []byte) ([]byte, error) { + return s.s.Get(bucket, key) +} + +// Glob implements System.Glob. +func (s *DryRunSystem) Glob(pattern string) ([]string, error) { + return s.s.Glob(pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *DryRunSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.s.IdempotentCmdOutput(cmd) +} + +// Lstat implements System.Lstat. +func (s *DryRunSystem) Lstat(name string) (os.FileInfo, error) { + return s.s.Stat(name) +} + +// Mkdir implements System.Mkdir. +func (s *DryRunSystem) Mkdir(name string, perm os.FileMode) error { + return nil +} + +// ReadDir implements System.ReadDir. +func (s *DryRunSystem) ReadDir(dirname string) ([]os.FileInfo, error) { + return s.s.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *DryRunSystem) ReadFile(filename string) ([]byte, error) { + return s.s.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *DryRunSystem) Readlink(name string) (string, error) { + return s.s.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *DryRunSystem) RemoveAll(string) error { + return nil +} + +// Rename implements System.Rename. +func (s *DryRunSystem) Rename(oldpath, newpath string) error { + return nil +} + +// RunScript implements System.RunScript. +func (s *DryRunSystem) RunScript(name string, data []byte) error { + return nil +} + +// Set implements System.Set. +func (s *DryRunSystem) Set(bucket, key, value []byte) error { + return nil +} + +// Stat implements System.Stat. +func (s *DryRunSystem) Stat(name string) (os.FileInfo, error) { + return s.s.Stat(name) +} + +// WriteFile implements System.WriteFile. +func (s *DryRunSystem) WriteFile(string, []byte, os.FileMode) error { + return nil +} + +// WriteSymlink implements System.WriteSymlink. +func (s *DryRunSystem) WriteSymlink(string, string) error { + return nil +} diff --git a/v2/chezmoi/dryrunsystem_test.go b/v2/chezmoi/dryrunsystem_test.go new file mode 100644 index 00000000000..1d9eceff53b --- /dev/null +++ b/v2/chezmoi/dryrunsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = &DryRunSystem{} diff --git a/v2/chezmoi/encryptiontool.go b/v2/chezmoi/encryptiontool.go new file mode 100644 index 00000000000..5a569ff3fed --- /dev/null +++ b/v2/chezmoi/encryptiontool.go @@ -0,0 +1,12 @@ +package chezmoi + +// A CleanupFunc cleans up. +type CleanupFunc func() error + +// An EncryptionTool encrypts and decrypts data. +type EncryptionTool interface { + Decrypt(filenameHint string, ciphertext []byte) ([]byte, error) + DecryptToFile(filenameHint string, ciphertext []byte) (string, CleanupFunc, error) + Encrypt(plaintext []byte) ([]byte, error) + EncryptFile(filename string) ([]byte, error) +} diff --git a/v2/chezmoi/encryptiontool_test.go b/v2/chezmoi/encryptiontool_test.go new file mode 100644 index 00000000000..9a2f2eb423e --- /dev/null +++ b/v2/chezmoi/encryptiontool_test.go @@ -0,0 +1,140 @@ +package chezmoi + +import ( + "io/ioutil" + "math/rand" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/multierr" +) + +type testEncryptionTool struct { + key byte +} + +var _ EncryptionTool = &testEncryptionTool{} + +type testEncryptionToolOption func(*testEncryptionTool) + +func newTestEncryptionTool(options ...testEncryptionToolOption) *testEncryptionTool { + t := &testEncryptionTool{ + //nolint:gosec + key: byte(rand.Int() + 1), + } + for _, option := range options { + option(t) + } + return t +} + +func (t *testEncryptionTool) Decrypt(filenameHint string, ciphertext []byte) ([]byte, error) { + return t.xorWithKey(ciphertext), nil +} + +func (t *testEncryptionTool) DecryptToFile(filenameHint string, ciphertext []byte) (filename string, cleanupFunc CleanupFunc, err error) { + tempDir, err := ioutil.TempDir("", "chezmoi-test-decrypt") + if err != nil { + return + } + cleanupFunc = func() error { + return os.RemoveAll(tempDir) + } + + filename = filepath.Join(tempDir, filepath.Base(filenameHint)) + if err = ioutil.WriteFile(filename, t.xorWithKey(ciphertext), 0o600); err != nil { + err = multierr.Append(err, cleanupFunc()) + return + } + + return +} + +func (t *testEncryptionTool) Encrypt(plaintext []byte) ([]byte, error) { + return t.xorWithKey(plaintext), nil +} + +func (t *testEncryptionTool) EncryptFile(filename string) ([]byte, error) { + plaintext, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + return t.xorWithKey(plaintext), nil +} + +func (t *testEncryptionTool) xorWithKey(input []byte) []byte { + output := make([]byte, 0, len(input)) + for _, b := range input { + output = append(output, b^t.key) + } + return output +} + +func testEncryptionToolDecryptToFile(t *testing.T, et EncryptionTool) { + t.Run("DecryptToFile", func(t *testing.T) { + expectedPlaintext := []byte("secret") + + actualCiphertext, err := et.Encrypt(expectedPlaintext) + require.NoError(t, err) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + filenameHint := "filename.txt" + filename, cleanup, err := et.DecryptToFile(filenameHint, actualCiphertext) + require.NoError(t, err) + assert.True(t, strings.Contains(filename, filenameHint)) + assert.NotNil(t, cleanup) + defer func() { + assert.NoError(t, cleanup()) + }() + + actualPlaintext, err := ioutil.ReadFile(filename) + require.NoError(t, err) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func testEncryptionToolEncryptDecrypt(t *testing.T, et EncryptionTool) { + t.Run("EncryptDecrypt", func(t *testing.T) { + expectedPlaintext := []byte("secret") + + actualCiphertext, err := et.Encrypt(expectedPlaintext) + require.NoError(t, err) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + actualPlaintext, err := et.Decrypt("", actualCiphertext) + require.NoError(t, err) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func testEncryptionToolEncryptFile(t *testing.T, et EncryptionTool) { + t.Run("EncryptFile", func(t *testing.T) { + expectedPlaintext := []byte("secret") + + tempFile, err := ioutil.TempFile("", "chezmoi-test-encryption-tool") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(tempFile.Name())) + }() + require.NoError(t, ioutil.WriteFile(tempFile.Name(), expectedPlaintext, 0o600)) + + actualCiphertext, err := et.EncryptFile(tempFile.Name()) + require.NoError(t, err) + assert.NotEqual(t, expectedPlaintext, actualCiphertext) + + actualPlaintext, err := et.Decrypt("", actualCiphertext) + require.NoError(t, err) + assert.Equal(t, expectedPlaintext, actualPlaintext) + }) +} + +func TestTestEncruptionTool(t *testing.T) { + et := newTestEncryptionTool() + testEncryptionToolDecryptToFile(t, et) + testEncryptionToolEncryptDecrypt(t, et) + testEncryptionToolEncryptFile(t, et) +} diff --git a/v2/chezmoi/errors.go b/v2/chezmoi/errors.go new file mode 100644 index 00000000000..928677f3a03 --- /dev/null +++ b/v2/chezmoi/errors.go @@ -0,0 +1,25 @@ +package chezmoi + +import ( + "fmt" + "os" + "strings" +) + +type duplicateTargetError struct { + targetName string + sourcePaths []string +} + +func (e *duplicateTargetError) Error() string { + return fmt.Sprintf("%s: duplicate target (%s)", e.targetName, strings.Join(e.sourcePaths, ", ")) +} + +type unsupportedFileTypeError struct { + path string + mode os.FileMode +} + +func (e *unsupportedFileTypeError) Error() string { + return fmt.Sprintf("%s: unsupported file type %s", e.path, modeTypeName(e.mode)) +} diff --git a/v2/chezmoi/gitdiffsystem.go b/v2/chezmoi/gitdiffsystem.go new file mode 100644 index 00000000000..98ada7f3905 --- /dev/null +++ b/v2/chezmoi/gitdiffsystem.go @@ -0,0 +1,335 @@ +package chezmoi + +import ( + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/filemode" + "github.com/go-git/go-git/v5/plumbing/format/diff" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// A GitDiffSystem wraps a SystemReader and logs all of the actions it +// would execute as a git diff. +type GitDiffSystem struct { + sr SystemReader + prefix string + unifiedEncoder *diff.UnifiedEncoder +} + +// NewGitDiffSystem returns a new GitDiffSystem. +func NewGitDiffSystem(unifiedEncoder *diff.UnifiedEncoder, sr SystemReader, prefix string) *GitDiffSystem { + return &GitDiffSystem{ + sr: sr, + prefix: prefix, + unifiedEncoder: unifiedEncoder, + } +} + +// Chmod implements System.Chmod. +func (s *GitDiffSystem) Chmod(name string, mode os.FileMode) error { + fromFileMode, info, err := s.getFileMode(name) + if err != nil { + return err + } + // Assume that we're only changing permissions. + toFileMode, err := filemode.NewFromOSFileMode(info.Mode()&^os.ModePerm | mode) + if err != nil { + return err + } + path := s.trimPrefix(name) + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fromFileMode, + path: path, + hash: plumbing.ZeroHash, + }, + to: &gitDiffFile{ + fileMode: toFileMode, + path: path, + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// Delete implements System.Delete. +func (s *GitDiffSystem) Delete(bucket, key []byte) error { + return nil +} + +// Get implements System.Get. +func (s *GitDiffSystem) Get(bucket, key []byte) ([]byte, error) { + return s.sr.Get(bucket, key) +} + +// Glob implements System.Glob. +func (s *GitDiffSystem) Glob(pattern string) ([]string, error) { + return s.sr.Glob(pattern) +} + +// Lstat implements System.Lstat. +func (s *GitDiffSystem) Lstat(name string) (os.FileInfo, error) { + return s.sr.Lstat(name) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *GitDiffSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return s.sr.IdempotentCmdOutput(cmd) +} + +// Mkdir implements System.Mkdir. +func (s *GitDiffSystem) Mkdir(name string, perm os.FileMode) error { + toFileMode, err := filemode.NewFromOSFileMode(os.ModeDir | perm) + if err != nil { + return err + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + to: &gitDiffFile{ + fileMode: toFileMode, + path: s.trimPrefix(name), + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// ReadDir implements System.ReadDir. +func (s *GitDiffSystem) ReadDir(dirname string) ([]os.FileInfo, error) { + return s.sr.ReadDir(dirname) +} + +// ReadFile implements System.ReadFile. +func (s *GitDiffSystem) ReadFile(filename string) ([]byte, error) { + return s.sr.ReadFile(filename) +} + +// Readlink implements System.Readlink. +func (s *GitDiffSystem) Readlink(name string) (string, error) { + return s.sr.Readlink(name) +} + +// RemoveAll implements System.RemoveAll. +func (s *GitDiffSystem) RemoveAll(name string) error { + fromFileMode, _, err := s.getFileMode(name) + if err != nil { + return err + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fromFileMode, + path: s.trimPrefix(name), + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// RunScript implements System.RunScript. +func (s *GitDiffSystem) RunScript(name string, data []byte) error { + isBinary := isBinary(data) + var chunks []diff.Chunk + if !isBinary { + chunk := &gitDiffChunk{ + content: string(data), + operation: diff.Add, + } + chunks = append(chunks, chunk) + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + isBinary: isBinary, + to: &gitDiffFile{ + fileMode: filemode.Executable, + path: s.trimPrefix(name), + hash: plumbing.ComputeHash(plumbing.BlobObject, data), + }, + chunks: chunks, + }, + }, + }) +} + +// Stat implements System.Stat. +func (s *GitDiffSystem) Stat(name string) (os.FileInfo, error) { + return s.sr.Stat(name) +} + +// Rename implements System.Rename. +func (s *GitDiffSystem) Rename(oldpath, newpath string) error { + fileMode, _, err := s.getFileMode(oldpath) + if err != nil { + return err + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + from: &gitDiffFile{ + fileMode: fileMode, + path: s.trimPrefix(oldpath), + hash: plumbing.ZeroHash, + }, + to: &gitDiffFile{ + fileMode: fileMode, + path: s.trimPrefix(newpath), + hash: plumbing.ZeroHash, + }, + }, + }, + }) +} + +// Set implements System.Set. +func (s *GitDiffSystem) Set(bucket, key, value []byte) error { + return nil +} + +// WriteFile implements System.WriteFile. +func (s *GitDiffSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + fromFileMode, _, err := s.getFileMode(filename) + if err != nil { + return err + } + fromData, err := s.sr.ReadFile(filename) + if err != nil { + return err + } + toFileMode, err := filemode.NewFromOSFileMode(perm) + if err != nil { + return err + } + path := s.trimPrefix(filename) + isBinary := isBinary(fromData) || isBinary(data) + var chunks []diff.Chunk + if !isBinary { + chunks = diffChunks(string(fromData), string(data)) + } + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + isBinary: isBinary, + from: &gitDiffFile{ + fileMode: fromFileMode, + path: path, + hash: plumbing.ComputeHash(plumbing.BlobObject, fromData), + }, + to: &gitDiffFile{ + fileMode: toFileMode, + path: path, + hash: plumbing.ComputeHash(plumbing.BlobObject, data), + }, + chunks: chunks, + }, + }, + }) +} + +// WriteSymlink implements System.WriteSymlink. +func (s *GitDiffSystem) WriteSymlink(oldname, newname string) error { + return s.unifiedEncoder.Encode(&gitDiffPatch{ + filePatches: []diff.FilePatch{ + &gitDiffFilePatch{ + to: &gitDiffFile{ + fileMode: filemode.Symlink, + path: s.trimPrefix(newname), + hash: plumbing.ComputeHash(plumbing.BlobObject, []byte(oldname)), + }, + chunks: []diff.Chunk{ + &gitDiffChunk{ + content: oldname, + operation: diff.Add, + }, + }, + }, + }, + }) +} + +func (s *GitDiffSystem) getFileMode(name string) (filemode.FileMode, os.FileInfo, error) { + info, err := s.sr.Stat(name) + if err != nil { + return filemode.Empty, nil, err + } + fileMode, err := filemode.NewFromOSFileMode(info.Mode()) + return fileMode, info, err +} + +func (s *GitDiffSystem) trimPrefix(path string) string { + return strings.TrimPrefix(path, s.prefix) +} + +var gitDiffOperation = map[diffmatchpatch.Operation]diff.Operation{ + diffmatchpatch.DiffDelete: diff.Delete, + diffmatchpatch.DiffEqual: diff.Equal, + diffmatchpatch.DiffInsert: diff.Add, +} + +type gitDiffChunk struct { + content string + operation diff.Operation +} + +func (c *gitDiffChunk) Content() string { return c.content } +func (c *gitDiffChunk) Type() diff.Operation { return c.operation } + +type gitDiffFile struct { + hash plumbing.Hash + fileMode filemode.FileMode + path string +} + +func (f *gitDiffFile) Hash() plumbing.Hash { return f.hash } +func (f *gitDiffFile) Mode() filemode.FileMode { return f.fileMode } +func (f *gitDiffFile) Path() string { return f.path } + +type gitDiffFilePatch struct { + isBinary bool + from, to diff.File + chunks []diff.Chunk +} + +func (fp *gitDiffFilePatch) IsBinary() bool { return fp.isBinary } +func (fp *gitDiffFilePatch) Files() (diff.File, diff.File) { return fp.from, fp.to } +func (fp *gitDiffFilePatch) Chunks() []diff.Chunk { return fp.chunks } + +type gitDiffPatch struct { + filePatches []diff.FilePatch + message string +} + +func (p *gitDiffPatch) FilePatches() []diff.FilePatch { return p.filePatches } +func (p *gitDiffPatch) Message() string { return p.message } + +func diffChunks(from, to string) []diff.Chunk { + dmp := diffmatchpatch.New() + dmp.DiffTimeout = time.Second + fromRunes, toRunes, runesToLines := dmp.DiffLinesToRunes(from, to) + diffs := dmp.DiffCharsToLines(dmp.DiffMainRunes(fromRunes, toRunes, false), runesToLines) + chunks := make([]diff.Chunk, 0, len(diffs)) + for _, d := range diffs { + chunk := &gitDiffChunk{ + content: d.Text, + operation: gitDiffOperation[d.Type], + } + chunks = append(chunks, chunk) + } + return chunks +} + +func isBinary(data []byte) bool { + return len(data) != 0 && !strings.HasPrefix(http.DetectContentType(data), "text/") +} diff --git a/v2/chezmoi/gitdiffsystem_test.go b/v2/chezmoi/gitdiffsystem_test.go new file mode 100644 index 00000000000..d0a58b21e14 --- /dev/null +++ b/v2/chezmoi/gitdiffsystem_test.go @@ -0,0 +1,11 @@ +package chezmoi + +import "github.com/go-git/go-git/v5/plumbing/format/diff" + +var ( + _ System = &GitDiffSystem{} + _ diff.Chunk = &gitDiffChunk{} + _ diff.File = &gitDiffFile{} + _ diff.FilePatch = &gitDiffFilePatch{} + _ diff.Patch = &gitDiffPatch{} +) diff --git a/v2/chezmoi/gpgencryptiontool.go b/v2/chezmoi/gpgencryptiontool.go new file mode 100644 index 00000000000..f3e2f6afdab --- /dev/null +++ b/v2/chezmoi/gpgencryptiontool.go @@ -0,0 +1,131 @@ +package chezmoi + +// FIXME document gpg.args + +import ( + "io/ioutil" + "os" + "os/exec" + "path/filepath" + + "go.uber.org/multierr" +) + +// A GPGEncryptionTool uses gpg for encryption and decryption. +type GPGEncryptionTool struct { + Command string + Args []string + Recipient string + Symmetric bool +} + +// Decrypt implements EncyptionTool.Decrypt. +func (t *GPGEncryptionTool) Decrypt(filenameHint string, ciphertext []byte) (plaintext []byte, err error) { + filename, cleanup, err := t.DecryptToFile(filenameHint, ciphertext) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, cleanup()) + }() + return ioutil.ReadFile(filename) +} + +// DecryptToFile implements EncyptionTool.DecryptToFile. +func (t *GPGEncryptionTool) DecryptToFile(filenameHint string, ciphertext []byte) (filename string, cleanupFunc CleanupFunc, err error) { + tempDir, err := ioutil.TempDir("", "chezmoi-gpg-decrypt") + if err != nil { + return + } + cleanupFunc = func() error { + return os.RemoveAll(tempDir) + } + + filename = filepath.Join(tempDir, filepath.Base(filenameHint)) + inputFilename := filename + ".gpg" + if err = ioutil.WriteFile(inputFilename, ciphertext, 0o600); err != nil { + err = multierr.Append(err, cleanupFunc()) + return + } + + args := []string{ + "--armor", + "--decrypt", + "--output", filename, + "--quiet", + } + if t.Symmetric { + args = append(args, "--symmetric") + } + args = append(args, inputFilename) + + if err = t.runWithArgs(args); err != nil { + err = multierr.Append(err, cleanupFunc()) + return + } + + return +} + +// Encrypt implements EncryptionTool.Encypt. +func (t *GPGEncryptionTool) Encrypt(plaintext []byte) (ciphertext []byte, err error) { + tempFile, err := ioutil.TempFile("", "chezmoi-gpg-encrypt") + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(tempFile.Name())) + }() + + if err = tempFile.Chmod(0o600); err != nil { + return + } + + if err = ioutil.WriteFile(tempFile.Name(), ciphertext, 0o600); err != nil { + return + } + + return t.EncryptFile(tempFile.Name()) +} + +// EncryptFile implements EncryptionTool.EncryptFile. +func (t *GPGEncryptionTool) EncryptFile(filename string) (ciphertext []byte, err error) { + tempDir, err := ioutil.TempDir("", "chezmoi-gpg-encrypt") + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(tempDir)) + }() + + outputFilename := filepath.Join(tempDir, filepath.Base(filename)+".gpg") + args := []string{ + "--armor", + "--encrypt", + "--output", outputFilename, + "--quiet", + } + switch { + case t.Symmetric: + args = append(args, "--symmetric") + case t.Recipient != "": + args = append(args, "--recipient", t.Recipient) + } + args = append(args, filename) + + if err = t.runWithArgs(args); err != nil { + return + } + + ciphertext, err = ioutil.ReadFile(outputFilename) + return +} + +func (t *GPGEncryptionTool) runWithArgs(args []string) error { + //nolint:gosec + cmd := exec.Command(t.Command, append(t.Args, args...)...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/v2/chezmoi/gpgencryptiontool_test.go b/v2/chezmoi/gpgencryptiontool_test.go new file mode 100644 index 00000000000..782f0e14e53 --- /dev/null +++ b/v2/chezmoi/gpgencryptiontool_test.go @@ -0,0 +1,47 @@ +package chezmoi + +// FIXME fix integration test and code +// FIXME add integration build flag + +import ( + "io/ioutil" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var _ EncryptionTool = &GPGEncryptionTool{} + +func TestGPGEncryptionTool(t *testing.T) { + t.Skip("broken test") + + tempDir, err := ioutil.TempDir("", "chezmoi-test-gpg-encryption-tool") + require.NoError(t, err) + defer func() { + assert.NoError(t, os.RemoveAll(tempDir)) + }() + + require.NoError(t, os.Chmod(tempDir, 0o700)) + + et := &GPGEncryptionTool{ + Command: "gpg", + Args: []string{ + "--batch", + "--homedir", tempDir, + "--passphrase", "passphrase", + "--pinentry-mode", "loopback", + "--no-tty", + "--yes", + }, + Symmetric: true, + } + require.NoError(t, et.runWithArgs([]string{ + "--quick-generate-key", "chezmoi", + })) + + testEncryptionToolDecryptToFile(t, et) + testEncryptionToolEncryptDecrypt(t, et) + testEncryptionToolEncryptFile(t, et) +} diff --git a/v2/chezmoi/lazy.go b/v2/chezmoi/lazy.go new file mode 100644 index 00000000000..17a2e23fa86 --- /dev/null +++ b/v2/chezmoi/lazy.go @@ -0,0 +1,83 @@ +package chezmoi + +import "crypto/sha256" + +type contentsFunc func() ([]byte, error) + +// A lazyContents evaluates its contents lazily. +type lazyContents struct { + contentsFunc contentsFunc + contents []byte + contentsErr error + contentsSHA256 []byte +} + +type linknameFunc func() (string, error) + +// A lazyLinkname evaluates its linkname lazily. +type lazyLinkname struct { + linknameFunc linknameFunc + linkname string + linknameErr error +} + +// newLazyContents returns a new lazyContents with contents. +func newLazyContents(contents []byte) *lazyContents { + return &lazyContents{ + contents: contents, + } +} + +// Contents returns e's contents. +func (lc *lazyContents) Contents() ([]byte, error) { + if lc == nil { + return nil, nil + } + if lc.contentsFunc != nil { + lc.contents, lc.contentsErr = lc.contentsFunc() + lc.contentsFunc = nil + if lc.contentsErr == nil { + lc.contentsSHA256 = sha256Sum(lc.contents) + } + } + return lc.contents, lc.contentsErr +} + +// ContentsSHA256 returns the SHA256 sum of f's contents. +func (lc *lazyContents) ContentsSHA256() ([]byte, error) { + if lc == nil { + return sha256Sum(nil), nil + } + if lc.contentsSHA256 == nil { + contents, err := lc.Contents() + if err != nil { + return nil, err + } + lc.contentsSHA256 = sha256Sum(contents) + } + return lc.contentsSHA256, nil +} + +// newLazyLinkname returns a new lazyLinkname with linkname. +func newLazyLinkname(linkname string) *lazyLinkname { + return &lazyLinkname{ + linkname: linkname, + } +} + +// Linkname returns s's linkname. +func (ll *lazyLinkname) Linkname() (string, error) { + if ll == nil { + return "", nil + } + if ll.linknameFunc != nil { + ll.linkname, ll.linknameErr = ll.linknameFunc() + ll.linknameFunc = nil + } + return ll.linkname, ll.linknameErr +} + +func sha256Sum(data []byte) []byte { + sha256SumArr := sha256.Sum256(data) + return sha256SumArr[:] +} diff --git a/v2/chezmoi/maybeshellquote.go b/v2/chezmoi/maybeshellquote.go new file mode 100644 index 00000000000..6d527d16fe3 --- /dev/null +++ b/v2/chezmoi/maybeshellquote.go @@ -0,0 +1,61 @@ +package chezmoi + +import ( + "regexp" + "strings" +) + +var needShellQuoteRegexp = regexp.MustCompile(`[^+\-./0-9=A-Z_a-z]`) + +const ( + backslash = '\\' + singleQuote = '\'' +) + +// MaybeShellQuote returns s quoted as a shell argument, if necessary. +func MaybeShellQuote(s string) string { + switch { + case s == "": + return "''" + case needShellQuoteRegexp.MatchString(s): + result := make([]byte, 0, 2+len(s)) + inSingleQuotes := false + for _, b := range []byte(s) { + switch b { + case backslash: + if !inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = true + } + result = append(result, backslash, backslash) + case singleQuote: + if inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = false + } + result = append(result, backslash, singleQuote) + default: + if !inSingleQuotes { + result = append(result, singleQuote) + inSingleQuotes = true + } + result = append(result, b) + } + } + if inSingleQuotes { + result = append(result, singleQuote) + } + return string(result) + default: + return s + } +} + +// ShellQuoteArgs returs args shell quoted and joined into a single string. +func ShellQuoteArgs(args []string) string { + shellQuotedArgs := make([]string, 0, len(args)) + for _, arg := range args { + shellQuotedArgs = append(shellQuotedArgs, MaybeShellQuote(arg)) + } + return strings.Join(shellQuotedArgs, " ") +} diff --git a/v2/chezmoi/maybeshellquote_test.go b/v2/chezmoi/maybeshellquote_test.go new file mode 100644 index 00000000000..cb145721cc0 --- /dev/null +++ b/v2/chezmoi/maybeshellquote_test.go @@ -0,0 +1,26 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMaybeShellQuote(t *testing.T) { + for s, expected := range map[string]string{ + ``: `''`, + `'`: `\'`, + `''`: `\'\'`, + `'a'`: `\''a'\'`, + `\`: `'\\'`, + `\a`: `'\\a'`, + `$a`: `'$a'`, + `a`: `a`, + `a/b`: `a/b`, + `a b`: `'a b'`, + `--arg`: `--arg`, + `--arg=value`: `--arg=value`, + } { + assert.Equal(t, expected, MaybeShellQuote(s), "quoting %q", s) + } +} diff --git a/v2/chezmoi/nullencryptiontool.go b/v2/chezmoi/nullencryptiontool.go new file mode 100644 index 00000000000..23168f59e86 --- /dev/null +++ b/v2/chezmoi/nullencryptiontool.go @@ -0,0 +1,25 @@ +package chezmoi + +import "io/ioutil" + +// A nullEncryptionTool returns its input unchanged. +type nullEncryptionTool struct{} + +func (*nullEncryptionTool) Decrypt(filenameHint string, ciphertext []byte) ([]byte, error) { + return ciphertext, nil +} + +func (*nullEncryptionTool) DecryptToFile(filenameHint string, ciphertext []byte) (string, CleanupFunc, error) { + return filenameHint, nullCleanupFunc, nil +} + +func (*nullEncryptionTool) Encrypt(plaintext []byte) ([]byte, error) { + return plaintext, nil +} + +func (*nullEncryptionTool) EncryptFile(filename string) ([]byte, error) { + return ioutil.ReadFile(filename) +} + +// nullCleanupFunc does nothing. +func nullCleanupFunc() error { return nil } diff --git a/v2/chezmoi/nullencryptiontool_test.go b/v2/chezmoi/nullencryptiontool_test.go new file mode 100644 index 00000000000..a69e5fc5200 --- /dev/null +++ b/v2/chezmoi/nullencryptiontool_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ EncryptionTool = &nullEncryptionTool{} diff --git a/v2/chezmoi/nullpersistentstate.go b/v2/chezmoi/nullpersistentstate.go new file mode 100644 index 00000000000..3c2779917f8 --- /dev/null +++ b/v2/chezmoi/nullpersistentstate.go @@ -0,0 +1,7 @@ +package chezmoi + +type nullPersistentState struct{} + +func (nullPersistentState) Delete(buckey, key []byte) error { return nil } +func (nullPersistentState) Get(bucket, key []byte) ([]byte, error) { return nil, nil } +func (nullPersistentState) Set(bucket, key, value []byte) error { return nil } diff --git a/v2/chezmoi/nullsystem.go b/v2/chezmoi/nullsystem.go new file mode 100644 index 00000000000..360f20cf22f --- /dev/null +++ b/v2/chezmoi/nullsystem.go @@ -0,0 +1,26 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// An nullSystem represents an null System. +type nullSystem struct { + nullPersistentState +} + +func (nullSystem) Chmod(name string, mode os.FileMode) error { return nil } +func (nullSystem) Glob(pattern string) ([]string, error) { return nil, nil } +func (nullSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { return cmd.Output() } +func (nullSystem) Lstat(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (nullSystem) Mkdir(dirname string, perm os.FileMode) error { return nil } +func (nullSystem) ReadDir(dirname string) ([]os.FileInfo, error) { return nil, os.ErrNotExist } +func (nullSystem) ReadFile(filename string) ([]byte, error) { return nil, os.ErrNotExist } +func (nullSystem) Readlink(name string) (string, error) { return "", os.ErrNotExist } +func (nullSystem) RemoveAll(name string) error { return nil } +func (nullSystem) Rename(oldpath, newpath string) error { return nil } +func (nullSystem) RunScript(name string, data []byte) error { return nil } +func (nullSystem) Stat(name string) (os.FileInfo, error) { return nil, os.ErrNotExist } +func (nullSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { return nil } +func (nullSystem) WriteSymlink(oldname, newname string) error { return nil } diff --git a/v2/chezmoi/nullsystem_test.go b/v2/chezmoi/nullsystem_test.go new file mode 100644 index 00000000000..826f3efd3e9 --- /dev/null +++ b/v2/chezmoi/nullsystem_test.go @@ -0,0 +1,3 @@ +package chezmoi + +var _ System = nullSystem{} diff --git a/v2/chezmoi/patternset.go b/v2/chezmoi/patternset.go new file mode 100644 index 00000000000..8037c9f9b9a --- /dev/null +++ b/v2/chezmoi/patternset.go @@ -0,0 +1,52 @@ +package chezmoi + +import "github.com/bmatcuk/doublestar" + +// An PatternSet is a set of patterns. +type PatternSet struct { + includes StringSet + excludes StringSet +} + +// A PatternSetOption sets an option on a pattern set. +type PatternSetOption func(*PatternSet) + +// NewPatternSet returns a new PatternSet. +func NewPatternSet(options ...PatternSetOption) *PatternSet { + ps := &PatternSet{ + includes: NewStringSet(), + excludes: NewStringSet(), + } + for _, option := range options { + option(ps) + } + return ps +} + +// Add adds a pattern to ps. +func (ps *PatternSet) Add(pattern string, include bool) error { + if _, err := doublestar.Match(pattern, ""); err != nil { + return nil + } + if include { + ps.includes.Add(pattern) + } else { + ps.excludes.Add(pattern) + } + return nil +} + +// Match returns if name matches any pattern in ps. +func (ps *PatternSet) Match(name string) bool { + for pattern := range ps.excludes { + if ok, _ := doublestar.Match(pattern, name); ok { + return false + } + } + for pattern := range ps.includes { + if ok, _ := doublestar.Match(pattern, name); ok { + return true + } + } + return false +} diff --git a/v2/chezmoi/patternset_test.go b/v2/chezmoi/patternset_test.go new file mode 100644 index 00000000000..9d47ee31be0 --- /dev/null +++ b/v2/chezmoi/patternset_test.go @@ -0,0 +1,88 @@ +package chezmoi + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPatternSet(t *testing.T) { + for _, tc := range []struct { + name string + ps *PatternSet + expectMatches map[string]bool + }{ + { + name: "empty", + ps: NewPatternSet(), + expectMatches: map[string]bool{ + "foo": false, + }, + }, + { + name: "exact", + ps: mustNewPatternSet(t, map[string]bool{ + "foo": true, + }), + expectMatches: map[string]bool{ + "foo": true, + "bar": false, + }, + }, + { + name: "wildcard", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + }), + expectMatches: map[string]bool{ + "foo": false, + "bar": true, + "baz": true, + }, + }, + { + name: "exclude", + ps: mustNewPatternSet(t, map[string]bool{ + "b*": true, + "baz": false, + }), + expectMatches: map[string]bool{ + "foo": false, + "bar": true, + "baz": false, + }, + }, + { + name: "doublestar", + ps: mustNewPatternSet(t, map[string]bool{ + "**/foo": true, + }), + expectMatches: map[string]bool{ + "foo": true, + "bar/foo": true, + "baz/bar/foo": true, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for s, expectMatch := range tc.expectMatches { + assert.Equal(t, expectMatch, tc.ps.Match(s)) + } + }) + } +} + +func mustNewPatternSet(t *testing.T, patterns map[string]bool) *PatternSet { + ps := NewPatternSet() + for pattern, exclude := range patterns { + require.NoError(t, ps.Add(pattern, exclude)) + } + return ps +} + +func withAdd(t *testing.T, pattern string, include bool) PatternSetOption { + return func(ps *PatternSet) { + require.NoError(t, ps.Add(pattern, include)) + } +} diff --git a/v2/chezmoi/persistentstate.go b/v2/chezmoi/persistentstate.go new file mode 100644 index 00000000000..3cff25f0ac0 --- /dev/null +++ b/v2/chezmoi/persistentstate.go @@ -0,0 +1,21 @@ +package chezmoi + +// FIXME should not need both PersistentState and PersistentStateWriter +// FIXME PersistentState should be just PersistentStateReader + Delete + Set + +// A PersistentStateReader reads a persistent state. +type PersistentStateReader interface { + Get(bucket, key []byte) ([]byte, error) +} + +// A PersistentStateWriter writes to a persistent state. +type PersistentStateWriter interface { + Delete(bucket, key []byte) error + Set(bucket, key, value []byte) error +} + +// A PersistentState is a persistent state. +type PersistentState interface { + PersistentStateReader + PersistentStateWriter +} diff --git a/v2/chezmoi/persistentstate_test.go b/v2/chezmoi/persistentstate_test.go new file mode 100644 index 00000000000..9035dc87ca5 --- /dev/null +++ b/v2/chezmoi/persistentstate_test.go @@ -0,0 +1,34 @@ +package chezmoi + +type testPersistentState map[string]map[string][]byte + +func newTestPersistentState() testPersistentState { + return make(testPersistentState) +} + +func (s testPersistentState) Delete(bucket, key []byte) error { + bucketMap, ok := s[string(bucket)] + if !ok { + return nil + } + delete(bucketMap, string(key)) + return nil +} + +func (s testPersistentState) Get(bucket, key []byte) ([]byte, error) { + bucketMap, ok := s[string(bucket)] + if !ok { + return nil, nil + } + return bucketMap[string(key)], nil +} + +func (s testPersistentState) Set(bucket, key, value []byte) error { + bucketMap, ok := s[string(bucket)] + if !ok { + bucketMap = make(map[string][]byte) + s[string(bucket)] = bucketMap + } + bucketMap[string(key)] = value + return nil +} diff --git a/v2/chezmoi/private.go b/v2/chezmoi/private.go new file mode 100644 index 00000000000..1929ee820d2 --- /dev/null +++ b/v2/chezmoi/private.go @@ -0,0 +1,24 @@ +// +build !windows + +package chezmoi + +import ( + "runtime" + + vfs "github.com/twpayne/go-vfs" +) + +// IsPrivate returns whether path should be considered private. +func IsPrivate(fs vfs.Stater, path string, want bool) (bool, error) { + // Private has no real equivalent on Windows, so always return what the + // caller wants. + if runtime.GOOS == "windows" { + return want, nil + } + + info, err := fs.Stat(path) + if err != nil { + return false, err + } + return info.Mode().Perm()&0o77 == 0, nil +} diff --git a/v2/chezmoi/realsystem.go b/v2/chezmoi/realsystem.go new file mode 100644 index 00000000000..408f4201ea1 --- /dev/null +++ b/v2/chezmoi/realsystem.go @@ -0,0 +1,117 @@ +package chezmoi + +import ( + "io/ioutil" + "os" + "os/exec" + "path" + + "github.com/bmatcuk/doublestar" + "github.com/google/renameio" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" +) + +// An RealSystem is a System that writes to a filesystem and executes scripts. +type RealSystem struct { + vfs.FS + PersistentState + devCache map[string]uint // devCache maps directories to device numbers. + tempDirCache map[uint]string // tempDir maps device numbers to renameio temporary directories. +} + +// NewRealSystem returns a System that acts on fs. +func NewRealSystem(fs vfs.FS, persistentState PersistentState) *RealSystem { + return &RealSystem{ + FS: fs, + PersistentState: persistentState, + devCache: make(map[string]uint), + tempDirCache: make(map[uint]string), + } +} + +// Glob implements System.Glob. +func (s *RealSystem) Glob(pattern string) ([]string, error) { + return doublestar.GlobOS(s, pattern) +} + +// IdempotentCmdOutput implements System.IdempotentCmdOutput. +func (s *RealSystem) IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) { + return cmd.Output() +} + +// PathSeparator implements doublestar.OS.PathSeparator. +func (s *RealSystem) PathSeparator() rune { + return pathSeparator +} + +// RunScript implements System.RunScript. +func (s *RealSystem) RunScript(name string, data []byte) (err error) { + // Write the temporary script file. Put the randomness at the front of the + // filename to preserve any file extension for Windows scripts. + f, err := ioutil.TempFile("", "*."+path.Base(name)) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, os.RemoveAll(f.Name())) + }() + + // Make the script private before writing it in case it contains any + // secrets. + if err = f.Chmod(0o700); err != nil { + return + } + _, err = f.Write(data) + err = multierr.Append(err, f.Close()) + if err != nil { + return + } + + // Run the temporary script file. + //nolint:gosec + c := exec.Command(f.Name()) + // c.Dir = path.Join(applyOptions.DestDir, filepath.Dir(s.targetName)) // FIXME + c.Stdin = os.Stdin + c.Stdout = os.Stdout + c.Stderr = os.Stderr + err = c.Run() + return +} + +// WriteSymlink implements System.WriteSymlink. +func (s *RealSystem) WriteSymlink(oldname, newname string) error { + // Special case: if writing to the real filesystem, use + // github.com/google/renameio. + if s.FS == vfs.OSFS { + return renameio.Symlink(oldname, newname) + } + if err := s.FS.RemoveAll(newname); err != nil && !os.IsNotExist(err) { + return err + } + return s.FS.Symlink(oldname, newname) +} + +// WriteFile is like ioutil.WriteFile but always sets perm before writing data. +// ioutil.WriteFile only sets the permissions when creating a new file. We need +// to ensure permissions, so we use our own implementation. +func WriteFile(fs vfs.FS, filename string, data []byte, perm os.FileMode) (err error) { + // Create a new file, or truncate any existing one. + f, err := fs.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return + } + defer func() { + err = multierr.Append(err, f.Close()) + }() + + // Set permissions after truncation but before writing any data, in case the + // file contained private data before, but before writing the new contents, + // in case the contents contain private data after. + if err = f.Chmod(perm); err != nil { + return + } + + _, err = f.Write(data) + return err +} diff --git a/v2/chezmoi/realsystem_posix.go b/v2/chezmoi/realsystem_posix.go new file mode 100644 index 00000000000..a39222316d0 --- /dev/null +++ b/v2/chezmoi/realsystem_posix.go @@ -0,0 +1,56 @@ +//+build !windows + +package chezmoi + +import ( + "errors" + "os" + "path" + "syscall" + + "github.com/google/renameio" + vfs "github.com/twpayne/go-vfs" +) + +// WriteFile implements System.WriteFile. +func (s *RealSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + // Special case: if writing to the real filesystem on a non-Windows system, + // use github.com/google/renameio. + if s.FS == vfs.OSFS { + dir := path.Dir(filename) + dev, ok := s.devCache[dir] + if !ok { + info, err := s.Stat(dir) + if err != nil { + return err + } + statT, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("os.FileInfo.Sys() cannot be converted to a *syscall.Stat_t") + } + dev = uint(statT.Dev) + s.devCache[dir] = dev + } + tempDir, ok := s.tempDirCache[dev] + if !ok { + tempDir = renameio.TempDir(dir) + s.tempDirCache[dev] = tempDir + } + t, err := renameio.TempFile(tempDir, filename) + if err != nil { + return err + } + defer func() { + _ = t.Cleanup() + }() + if err := t.Chmod(perm); err != nil { + return err + } + if _, err := t.Write(data); err != nil { + return err + } + return t.CloseAtomicallyReplace() + } + + return WriteFile(s.FS, filename, data, perm) +} diff --git a/v2/chezmoi/realsystem_test.go b/v2/chezmoi/realsystem_test.go new file mode 100644 index 00000000000..df1160a774c --- /dev/null +++ b/v2/chezmoi/realsystem_test.go @@ -0,0 +1,68 @@ +package chezmoi + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + vfs "github.com/twpayne/go-vfs" + "github.com/twpayne/go-vfs/vfst" +) + +var _ System = &RealSystem{} + +func TestRealSystemGlob(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user": map[string]interface{}{ + "bar": "", + "baz": "", + "foo": "", + "dir/bar": "", + "dir/foo": "", + "dir/subdir/foo": "", + }, + }) + require.NoError(t, err) + defer cleanup() + + s := NewRealSystem(fs, newTestPersistentState()) + for _, tc := range []struct { + pattern string + expectedMatches []string + }{ + { + pattern: "/home/user/foo", + expectedMatches: []string{ + "/home/user/foo", + }, + }, + { + pattern: "/home/user/**/foo", + expectedMatches: []string{ + "/home/user/dir/foo", + "/home/user/dir/subdir/foo", + "/home/user/foo", + }, + }, + { + pattern: "/home/user/**/ba*", + expectedMatches: []string{ + "/home/user/bar", + "/home/user/baz", + "/home/user/dir/bar", + }, + }, + } { + t.Run(tc.pattern, func(t *testing.T) { + actualMatches, err := s.Glob(tc.pattern) + require.NoError(t, err) + sort.Strings(actualMatches) + assert.Equal(t, tc.expectedMatches, actualMatches) + }) + } +} + +func newTestRealSystem(fs vfs.FS) *RealSystem { + return NewRealSystem(fs, newTestPersistentState()) +} diff --git a/v2/chezmoi/realsystem_windows.go b/v2/chezmoi/realsystem_windows.go new file mode 100644 index 00000000000..68f67c3aa1c --- /dev/null +++ b/v2/chezmoi/realsystem_windows.go @@ -0,0 +1,8 @@ +package chezmoi + +import "os" + +// WriteFile implements System.WriteFile. +func (s *RealSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + return WriteFile(s.FS, filename, data, perm) +} diff --git a/v2/chezmoi/sourcestate.go b/v2/chezmoi/sourcestate.go new file mode 100644 index 00000000000..aeb93184a29 --- /dev/null +++ b/v2/chezmoi/sourcestate.go @@ -0,0 +1,542 @@ +package chezmoi + +import ( + "bufio" + "bytes" + "fmt" + "os" + "path" + "path/filepath" + "sort" + "strings" + "text/template" + + "github.com/coreos/go-semver/semver" + vfs "github.com/twpayne/go-vfs" + "go.uber.org/multierr" +) + +// A SourceState is a source state. +type SourceState struct { + Entries map[string]SourceStateEntry + s System + sourcePath string + umask os.FileMode + encryptionTool EncryptionTool + ignore *PatternSet + minVersion *semver.Version + remove *PatternSet + templateData interface{} + templateFuncs template.FuncMap + templateOptions []string + templates map[string]*template.Template +} + +// A SourceStateOption sets an option on a source state. +type SourceStateOption func(*SourceState) + +// WithEncryptionTool set the encryption tool. +func WithEncryptionTool(encryptionTool EncryptionTool) SourceStateOption { + return func(ss *SourceState) { + ss.encryptionTool = encryptionTool + } +} + +// WithSourcePath sets the source path. +func WithSourcePath(sourcePath string) SourceStateOption { + return func(ss *SourceState) { + ss.sourcePath = sourcePath + } +} + +// WithSystem sets the system. +func WithSystem(s System) SourceStateOption { + return func(ss *SourceState) { + ss.s = s + } +} + +// WithTemplateData sets the template data. +func WithTemplateData(templateData interface{}) SourceStateOption { + return func(ss *SourceState) { + ss.templateData = templateData + } +} + +// WithTemplateFuncs sets the template functions. +func WithTemplateFuncs(templateFuncs template.FuncMap) SourceStateOption { + return func(ss *SourceState) { + ss.templateFuncs = templateFuncs + } +} + +// WithTemplateOptions sets the template options. +func WithTemplateOptions(templateOptions []string) SourceStateOption { + return func(ss *SourceState) { + ss.templateOptions = templateOptions + } +} + +// WithUmask sets the umask. +func WithUmask(umask os.FileMode) SourceStateOption { + return func(ss *SourceState) { + ss.umask = umask + } +} + +// NewSourceState creates a new source state with the given options. +func NewSourceState(options ...SourceStateOption) *SourceState { + ss := &SourceState{ + Entries: make(map[string]SourceStateEntry), + umask: DefaultUmask, + encryptionTool: &nullEncryptionTool{}, + ignore: NewPatternSet(), + remove: NewPatternSet(), + templateOptions: DefaultTemplateOptions, + } + for _, option := range options { + option(ss) + } + return ss +} + +// Add adds sourceStateEntry to ss. +func (ss *SourceState) Add() error { + return nil // FIXME +} + +// ApplyAll updates targetDir in fs to match ss. +func (ss *SourceState) ApplyAll(s System, umask os.FileMode, targetDir string) error { + for _, targetName := range ss.sortedTargetNames() { + if err := ss.ApplyOne(s, umask, targetDir, targetName); err != nil { + return err + } + } + return nil +} + +// ApplyOne updates targetName in targetDir on fs to match ss using s. +func (ss *SourceState) ApplyOne(s System, umask os.FileMode, targetDir, targetName string) error { + targetPath := path.Join(targetDir, targetName) + destStateEntry, err := NewDestStateEntry(s, targetPath) + if err != nil { + return err + } + targetStateEntry, err := ss.Entries[targetName].TargetStateEntry() + if err != nil { + return err + } + if err := targetStateEntry.Apply(s, destStateEntry); err != nil { + return err + } + if targetStateDir, ok := targetStateEntry.(*TargetStateDir); ok { + if targetStateDir.exact { + infos, err := s.ReadDir(targetPath) + if err != nil { + return err + } + baseNames := make([]string, 0, len(infos)) + for _, info := range infos { + if baseName := info.Name(); baseName != "." && baseName != ".." { + baseNames = append(baseNames, baseName) + } + } + sort.Strings(baseNames) + for _, baseName := range baseNames { + if _, ok := ss.Entries[path.Join(targetName, baseName)]; !ok { + if err := s.RemoveAll(path.Join(targetPath, baseName)); err != nil { + return err + } + } + } + } + } + // FIXME chezmoiremove + return nil +} + +// ExecuteTemplateData returns the result of executing template data. +func (ss *SourceState) ExecuteTemplateData(name string, data []byte) ([]byte, error) { + tmpl, err := template.New(name).Option(ss.templateOptions...).Funcs(ss.templateFuncs).Parse(string(data)) + if err != nil { + return nil, err + } + for name, t := range ss.templates { + tmpl, err = tmpl.AddParseTree(name, t.Tree) + if err != nil { + return nil, err + } + } + output := &bytes.Buffer{} + if err = tmpl.ExecuteTemplate(output, name, ss.templateData); err != nil { + return nil, err + } + return output.Bytes(), nil +} + +// Read reads a source state from sourcePath in fs. +func (ss *SourceState) Read() error { + // Read all source entries. + allSourceEntries := make(map[string][]SourceStateEntry) + sourceDirPrefix := filepath.ToSlash(ss.sourcePath) + pathSeparatorStr + if err := vfs.Walk(ss.s, ss.sourcePath, func(sourcePath string, info os.FileInfo, err error) error { + sourcePath = filepath.ToSlash(sourcePath) + if err != nil { + return err + } + if sourcePath == ss.sourcePath { + return nil + } + relPath := strings.TrimPrefix(sourcePath, sourceDirPrefix) + sourceDirName, sourceName := path.Split(relPath) + targetDirName := getTargetDirName(sourceDirName) + switch { + case info.Name() == ignoreName: + return ss.addPatterns(ss.ignore, sourcePath, sourceDirName) + case info.Name() == removeName: + return ss.addPatterns(ss.remove, sourcePath, targetDirName) + case info.Name() == templatesDirName: + if err := ss.addTemplatesDir(sourcePath); err != nil { + return err + } + return filepath.SkipDir + case info.Name() == versionName: + return ss.addVersionFile(sourcePath) + case strings.HasPrefix(info.Name(), ignorePrefix): + if info.IsDir() { + return filepath.SkipDir + } + return nil + case info.IsDir(): + dirAttributes := ParseDirAttributes(sourceName) + targetName := path.Join(targetDirName, dirAttributes.Name) + if ss.ignore.Match(targetName) { + return nil + } + sourceEntry := ss.newSourceStateDir(sourcePath, dirAttributes) + allSourceEntries[targetName] = append(allSourceEntries[targetName], sourceEntry) + return nil + case info.Mode().IsRegular(): + fileAttributes := ParseFileAttributes(sourceName) + targetName := path.Join(targetDirName, fileAttributes.Name) + if ss.ignore.Match(targetName) { + return nil + } + sourceEntry := ss.newSourceStateFile(sourcePath, fileAttributes) + allSourceEntries[targetName] = append(allSourceEntries[targetName], sourceEntry) + return nil + default: + return &unsupportedFileTypeError{ + path: sourcePath, + mode: info.Mode(), + } + } + }); err != nil { + return err + } + + // Checking for duplicate source entries with the same target name. Iterate + // over the target names in order so that any error is deterministic. + var err error + targetNames := make([]string, 0, len(allSourceEntries)) + for targetName := range allSourceEntries { + targetNames = append(targetNames, targetName) + } + sort.Strings(targetNames) + for _, targetName := range targetNames { + sourceEntries := allSourceEntries[targetName] + if len(sourceEntries) == 1 { + continue + } + sourcePaths := make([]string, 0, len(sourceEntries)) + for _, sourceEntry := range sourceEntries { + sourcePaths = append(sourcePaths, sourceEntry.Path()) + } + err = multierr.Append(err, &duplicateTargetError{ + targetName: targetName, + sourcePaths: sourcePaths, + }) + } + if err != nil { + return err + } + + // Populate ss.sourceEntries with the unique source entry for each target. + for targetName, sourceEntries := range allSourceEntries { + ss.Entries[targetName] = sourceEntries[0] + } + return nil +} + +// Remove removes everything in targetDir that matches s's remove pattern set. +func (ss *SourceState) Remove(s System, targetDir string) error { + // Build a set of targets to remove. + targetDirPrefix := targetDir + pathSeparatorStr + targetPathsToRemove := NewStringSet() + for include := range ss.remove.includes { + matches, err := s.Glob(path.Join(targetDir, include)) + if err != nil { + return err + } + for _, match := range matches { + // Don't remove targets that are excluded from remove. + if !ss.remove.Match(strings.TrimPrefix(match, targetDirPrefix)) { + continue + } + targetPathsToRemove.Add(match) + } + } + + // Remove targets in order. Parent directories are removed before their + // children, which is okay because RemoveAll does not treat os.ErrNotExist + // as an error. + sortedTargetPathsToRemove := targetPathsToRemove.Elements() + sort.Strings(sortedTargetPathsToRemove) + for _, targetPath := range sortedTargetPathsToRemove { + if err := s.RemoveAll(targetPath); err != nil { + return err + } + } + return nil +} + +// Evaluate evaluates every target state entry in s. +func (ss *SourceState) Evaluate() error { + for _, targetName := range ss.sortedTargetNames() { + sourceStateEntry := ss.Entries[targetName] + if err := sourceStateEntry.Evaluate(); err != nil { + return err + } + targetStateEntry, err := sourceStateEntry.TargetStateEntry() + if err != nil { + return err + } + if err := targetStateEntry.Evaluate(); err != nil { + return err + } + } + return nil +} + +func (ss *SourceState) addPatterns(ps *PatternSet, path, relPath string) error { + data, err := ss.executeTemplate(path) + if err != nil { + return err + } + dir := filepath.Dir(relPath) + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + text := scanner.Text() + if index := strings.IndexRune(text, '#'); index != -1 { + text = text[:index] + } + text = strings.TrimSpace(text) + if text == "" { + continue + } + include := true + if strings.HasPrefix(text, "!") { + include = false + text = strings.TrimPrefix(text, "!") + } + pattern := filepath.Join(dir, text) + if err := ps.Add(pattern, include); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + return nil +} + +func (ss *SourceState) addTemplatesDir(templateDir string) error { + templateDirPrefix := filepath.ToSlash(templateDir) + pathSeparatorStr + return vfs.Walk(ss.s, templateDir, func(templatePath string, info os.FileInfo, err error) error { + templatePath = filepath.ToSlash(templatePath) + if err != nil { + return err + } + switch { + case info.Mode().IsRegular(): + contents, err := ss.s.ReadFile(templatePath) + if err != nil { + return err + } + name := strings.TrimPrefix(templatePath, templateDirPrefix) + tmpl, err := template.New(name).Parse(string(contents)) + if err != nil { + return err + } + if ss.templates == nil { + ss.templates = make(map[string]*template.Template) + } + ss.templates[name] = tmpl + return nil + case info.IsDir(): + return nil + default: + return &unsupportedFileTypeError{ + path: templatePath, + mode: info.Mode(), + } + } + }) +} + +// addVersionFile reads a .chezmoiversion file from source path and updates ss's +// minimum version if it contains a more recent version than the current minimum +// version. +func (ss *SourceState) addVersionFile(sourcePath string) error { + data, err := ss.s.ReadFile(sourcePath) + if err != nil { + return err + } + version, err := semver.NewVersion(strings.TrimSpace(string(data))) + if err != nil { + return err + } + if ss.minVersion == nil || ss.minVersion.LessThan(*version) { + ss.minVersion = version + } + return nil +} + +func (ss *SourceState) executeTemplate(path string) ([]byte, error) { + data, err := ss.s.ReadFile(path) + if err != nil { + return nil, err + } + return ss.ExecuteTemplateData(path, data) +} + +func (ss *SourceState) newSourceStateDir(sourcePath string, dirAttributes DirAttributes) *SourceStateDir { + perm := os.FileMode(0o777) + if dirAttributes.Private { + perm &^= 0o77 + } + perm &^= ss.umask + + targetStateDir := &TargetStateDir{ + perm: perm, + exact: dirAttributes.Exact, + } + + return &SourceStateDir{ + path: sourcePath, + attributes: dirAttributes, + targetStateEntry: targetStateDir, + } +} + +func (ss *SourceState) newSourceStateFile(sourcePath string, fileAttributes FileAttributes) *SourceStateFile { + lazyContents := &lazyContents{ + contentsFunc: func() ([]byte, error) { + contents, err := ss.s.ReadFile(sourcePath) + if err != nil { + return nil, err + } + if !fileAttributes.Encrypted { + return contents, nil + } + // FIXME pass targetName as filenameHint + return ss.encryptionTool.Decrypt(sourcePath, contents) + }, + } + + var targetStateEntryFunc func() (TargetStateEntry, error) + switch fileAttributes.Type { + case SourceFileTypeFile: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttributes.Template { + contents, err = ss.ExecuteTemplateData(sourcePath, contents) + if err != nil { + return nil, err + } + } + if !fileAttributes.Empty && isEmpty(contents) { + return &TargetStateAbsent{}, nil + } + perm := os.FileMode(0o666) + if fileAttributes.Executable { + perm |= 0o111 + } + if fileAttributes.Private { + perm &^= 0o77 + } + perm &^= ss.umask + return &TargetStateFile{ + lazyContents: newLazyContents(contents), + perm: perm, + }, nil + } + case SourceFileTypeScript: + targetStateEntryFunc = func() (TargetStateEntry, error) { + contents, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttributes.Template { + contents, err = ss.ExecuteTemplateData(sourcePath, contents) + if err != nil { + return nil, err + } + } + return &TargetStateScript{ + lazyContents: newLazyContents(contents), + name: fileAttributes.Name, + once: fileAttributes.Once, + }, nil + } + case SourceFileTypeSymlink: + targetStateEntryFunc = func() (TargetStateEntry, error) { + linknameBytes, err := lazyContents.Contents() + if err != nil { + return nil, err + } + if fileAttributes.Template { + linknameBytes, err = ss.ExecuteTemplateData(sourcePath, linknameBytes) + if err != nil { + return nil, err + } + } + return &TargetStateSymlink{ + lazyLinkname: newLazyLinkname(string(linknameBytes)), + }, nil + } + default: + panic(fmt.Sprintf("unsupported type: %s", string(fileAttributes.Type))) + } + + return &SourceStateFile{ + lazyContents: lazyContents, + path: sourcePath, + attributes: fileAttributes, + targetStateEntryFunc: targetStateEntryFunc, + } +} + +// sortedTargetNames returns all of ss's target names in order. +func (ss *SourceState) sortedTargetNames() []string { + targetNames := make([]string, 0, len(ss.Entries)) + for targetName := range ss.Entries { + targetNames = append(targetNames, targetName) + } + sort.Strings(targetNames) + return targetNames +} + +// getTargetDirName returns the target directory name of sourceDirName. +func getTargetDirName(sourceDirName string) string { + sourceNames := strings.Split(sourceDirName, pathSeparatorStr) + targetNames := make([]string, 0, len(sourceNames)) + for _, sourceName := range sourceNames { + dirAttributes := ParseDirAttributes(sourceName) + targetNames = append(targetNames, dirAttributes.Name) + } + return strings.Join(targetNames, pathSeparatorStr) +} diff --git a/v2/chezmoi/sourcestate_test.go b/v2/chezmoi/sourcestate_test.go new file mode 100644 index 00000000000..a0e5a5311b0 --- /dev/null +++ b/v2/chezmoi/sourcestate_test.go @@ -0,0 +1,707 @@ +package chezmoi + +import ( + "os" + "testing" + "text/template" + + "github.com/coreos/go-semver/semver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +func TestSourceStateApplyAll(t *testing.T) { + // FIXME script tests + // FIXME script template tests + for _, tc := range []struct { + name string + root interface{} + sourceStateOptions []SourceStateOption + tests []interface{} + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": &vfst.Dir{Perm: 0o755}, + }, + }, + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "foo": &vfst.Dir{Perm: 0o755}, + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestIsDir, + vfst.TestModePerm(0o755), + ), + }, + }, + { + name: "dir_exact", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "", + }, + ".local/share/chezmoi": map[string]interface{}{ + "exact_foo": &vfst.Dir{Perm: 0o755}, + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestIsDir, + vfst.TestModePerm(0o755), + ), + vfst.TestPath("/home/user/foo/bar", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o644), + vfst.TestContentsString("bar"), + ), + }, + }, + { + name: "file_remove_empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "foo": "", + ".local/share/chezmoi": map[string]interface{}{ + "foo": "", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "file_create_empty", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "empty_foo": "", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o644), + vfst.TestContentsString(""), + ), + }, + }, + { + name: "file_template", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "foo.tmpl": "email = {{ .email }}", + }, + }, + }, + sourceStateOptions: []SourceStateOption{ + WithTemplateData(map[string]interface{}{ + "email": "john.smith@company.com", + }), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + vfst.TestModePerm(0o644), + vfst.TestContentsString("email = john.smith@company.com"), + ), + }, + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "symlink_foo": "bar", + }, + }, + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget("bar"), + ), + }, + }, + { + name: "symlink_template", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + ".local/share/chezmoi": map[string]interface{}{ + "symlink_foo.tmpl": "bar_{{ .os }}", + }, + }, + }, + sourceStateOptions: []SourceStateOption{ + WithTemplateData(map[string]interface{}{ + "os": "linux", + }), + }, + tests: []interface{}{ + vfst.TestPath("/home/user/foo", + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget("bar_linux"), + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + + s := newTestRealSystem(fs) + sourceStateOptions := []SourceStateOption{ + WithSystem(s), + WithSourcePath("/home/user/.local/share/chezmoi"), + } + sourceStateOptions = append(sourceStateOptions, tc.sourceStateOptions...) + ss := NewSourceState(sourceStateOptions...) + require.NoError(t, ss.Read()) + require.NoError(t, ss.Evaluate()) + require.NoError(t, ss.ApplyAll(s, vfst.DefaultUmask, "/home/user")) + + vfst.RunTests(t, fs, "", tc.tests...) + }) + } +} + +func TestSourceStateRead(t *testing.T) { + for _, tc := range []struct { + name string + root interface{} + sourceStateOptions []SourceStateOption + expectedError string + expectedSourceState *SourceState + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: 0o755}, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + ), + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": &vfst.Dir{Perm: 0o755}, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateDir{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: DirAttributes{ + Name: "foo", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + }), + ), + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: FileAttributes{ + Name: "foo", + Type: SourceFileTypeFile, + }, + lazyContents: newLazyContents([]byte("bar")), + targetStateEntry: &TargetStateFile{ + perm: 0o644, + lazyContents: newLazyContents([]byte("bar")), + }, + }, + }), + ), + }, + { + name: "duplicate_target", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + "foo.tmpl": "bar", + }, + }, + expectedError: "foo: duplicate target (/home/user/.local/share/chezmoi/foo, /home/user/.local/share/chezmoi/foo.tmpl)", + }, + { + name: "duplicate_target", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": "bar", + "exact_foo": &vfst.Dir{Perm: 0o755}, + }, + }, + expectedError: "foo: duplicate target (/home/user/.local/share/chezmoi/exact_foo, /home/user/.local/share/chezmoi/foo)", + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": &vfst.Symlink{Target: "bar"}, + }, + }, + expectedError: "/home/user/.local/share/chezmoi/foo: unsupported file type symlink", + }, + { + name: "script", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "run_foo": "bar", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/run_foo", + attributes: FileAttributes{ + Name: "foo", + Type: SourceFileTypeScript, + }, + lazyContents: newLazyContents([]byte("bar")), + targetStateEntry: &TargetStateScript{ + name: "foo", + lazyContents: newLazyContents([]byte("bar")), + }, + }, + }), + ), + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "symlink_foo": "bar", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/symlink_foo", + attributes: FileAttributes{ + Name: "foo", + Type: SourceFileTypeSymlink, + }, + lazyContents: newLazyContents([]byte("bar")), + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: newLazyLinkname("bar"), + }, + }, + }), + ), + }, + { + name: "file_in_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + "foo": map[string]interface{}{ + "bar": "baz", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateDir{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: DirAttributes{ + Name: "foo", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + "foo/bar": &SourceStateFile{ + path: "/home/user/.local/share/chezmoi/foo/bar", + attributes: FileAttributes{ + Name: "bar", + Type: SourceFileTypeFile, + }, + lazyContents: &lazyContents{ + contents: []byte("baz"), + }, + targetStateEntry: &TargetStateFile{ + perm: 0o644, + lazyContents: &lazyContents{ + contents: []byte("baz"), + }, + }, + }, + }), + ), + }, + { + name: "chezmoiignore", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withIgnore( + NewPatternSet( + withAdd(t, "README.md", true), + ), + ), + ), + }, + { + name: "chezmoiignore_ignore_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + "README.md": "", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withIgnore( + NewPatternSet( + withAdd(t, "README.md", true), + ), + ), + ), + }, + { + name: "chezmoiremove", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "!*.txt\n", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withRemove( + NewPatternSet( + withAdd(t, "*.txt", false), + ), + ), + ), + }, + { + name: "chezmoitemplates", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoitemplates": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withTemplates( + map[string]*template.Template{ + "foo": template.Must(template.New("foo").Parse("bar")), + }, + ), + ), + }, + { + name: "chezmoiversion", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiversion": "1.2.3\n", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withMinVersion( + &semver.Version{ + Major: 1, + Minor: 2, + Patch: 3, + }, + ), + ), + }, + { + name: "chezmoiversion_multiple", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiversion": "1.2.3\n", + "foo": map[string]interface{}{ + ".chezmoiversion": "2.3.4\n", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + withEntries(map[string]SourceStateEntry{ + "foo": &SourceStateDir{ + path: "/home/user/.local/share/chezmoi/foo", + attributes: DirAttributes{ + Name: "foo", + }, + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + }), + withMinVersion( + &semver.Version{ + Major: 2, + Minor: 3, + Patch: 4, + }, + ), + ), + }, + { + name: "ignore_dir", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".ignore": map[string]interface{}{ + "foo": "bar", + }, + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + ), + }, + { + name: "ignore_file", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".ignore": "", + }, + }, + expectedSourceState: NewSourceState( + WithSourcePath("/home/user/.local/share/chezmoi"), + ), + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + + sourceStateOptions := []SourceStateOption{ + WithSystem(newTestRealSystem(fs)), + WithSourcePath("/home/user/.local/share/chezmoi"), + } + sourceStateOptions = append(sourceStateOptions, tc.sourceStateOptions...) + ss := NewSourceState(sourceStateOptions...) + err = ss.Read() + if tc.expectedError != "" { + assert.Error(t, err) + assert.Equal(t, tc.expectedError, err.Error()) + return + } + require.NoError(t, err) + require.NoError(t, ss.Evaluate()) + require.NoError(t, tc.expectedSourceState.Evaluate()) + ss.s = nil + assert.Equal(t, tc.expectedSourceState, ss) + }) + } +} + +func TestSourceStateRemove(t *testing.T) { + // FIXME test doublestar globs + for _, tc := range []struct { + name string + root interface{} + tests []vfst.Test + }{ + { + name: "empty", + root: map[string]interface{}{ + "/home/user/.local/share/chezmoi": &vfst.Dir{Perm: 0o755}, + }, + }, + { + name: "dir", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o755}, + "file": "", + "symlink": &vfst.Symlink{Target: "file"}, + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "dir\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/dir", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/file", + vfst.TestModeIsRegular, + ), + vfst.TestPath("/home/user/symlink", + vfst.TestModeType(os.ModeSymlink), + ), + }, + }, + { + name: "file", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o755}, + "file": "", + "symlink": &vfst.Symlink{Target: "file"}, + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "file\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/dir", + vfst.TestIsDir, + ), + vfst.TestPath("/home/user/file", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/symlink", + vfst.TestModeType(os.ModeSymlink), + ), + }, + }, + { + name: "symlink", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "dir": &vfst.Dir{Perm: 0o755}, + "file": "", + "symlink": &vfst.Symlink{Target: "file"}, + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "symlink\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/dir", + vfst.TestIsDir, + ), + vfst.TestPath("/home/user/file", + vfst.TestModeIsRegular, + ), + vfst.TestPath("/home/user/symlink", + vfst.TestDoesNotExist, + ), + }, + }, + { + name: "exclude_pattern", + root: map[string]interface{}{ + "/home/user": map[string]interface{}{ + "foo": "", + "bar": "", + "baz": "", + ".local/share/chezmoi": map[string]interface{}{ + ".chezmoiremove": "b*\n!*z\n", + }, + }, + }, + tests: []vfst.Test{ + vfst.TestPath("/home/user/foo", + vfst.TestModeIsRegular, + ), + vfst.TestPath("/home/user/bar", + vfst.TestDoesNotExist, + ), + vfst.TestPath("/home/user/baz", + vfst.TestModeIsRegular, + ), + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(tc.root) + require.NoError(t, err) + defer cleanup() + + ss := NewSourceState( + WithSystem(newTestRealSystem(fs)), + WithSourcePath("/home/user/.local/share/chezmoi"), + ) + require.NoError(t, ss.Read()) + require.NoError(t, ss.Evaluate()) + + require.NoError(t, ss.Remove(newTestRealSystem(fs), "/home/user")) + + vfst.RunTests(t, fs, "", tc.tests) + }) + } +} + +func withEntries(sourceEntries map[string]SourceStateEntry) SourceStateOption { + return func(ss *SourceState) { + ss.Entries = sourceEntries + } +} + +func withIgnore(ignore *PatternSet) SourceStateOption { + return func(ss *SourceState) { + ss.ignore = ignore + } +} + +func withMinVersion(minVersion *semver.Version) SourceStateOption { + return func(ss *SourceState) { + ss.minVersion = minVersion + } +} + +func withRemove(remove *PatternSet) SourceStateOption { + return func(ss *SourceState) { + ss.remove = remove + } +} + +func withTemplates(templates map[string]*template.Template) SourceStateOption { + return func(ss *SourceState) { + ss.templates = templates + } +} diff --git a/v2/chezmoi/sourcestateentry.go b/v2/chezmoi/sourcestateentry.go new file mode 100644 index 00000000000..89f81386b97 --- /dev/null +++ b/v2/chezmoi/sourcestateentry.go @@ -0,0 +1,79 @@ +package chezmoi + +import ( + "os" +) + +// A SourceStateEntry represents the state of an entry in the source state. +type SourceStateEntry interface { + Evaluate() error + Path() string + TargetStateEntry() (TargetStateEntry, error) + Write(s System, umask os.FileMode) error +} + +// A SourceStateDir represents the state of a directory in the source state. +type SourceStateDir struct { + path string + attributes DirAttributes + targetStateEntry TargetStateEntry +} + +// A SourceStateFile represents the state of a file in the source state. +type SourceStateFile struct { + *lazyContents + path string + attributes FileAttributes + targetStateEntryFunc func() (TargetStateEntry, error) + targetStateEntry TargetStateEntry + targetStateEntryErr error +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateDir) Evaluate() error { + return nil +} + +// Path returns s's path. +func (s *SourceStateDir) Path() string { + return s.path +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateDir) TargetStateEntry() (TargetStateEntry, error) { + return s.targetStateEntry, nil +} + +// Write writes s to sourceStateDir. +func (s *SourceStateDir) Write(sourceStateDir System, umask os.FileMode) error { + return sourceStateDir.Mkdir(s.path, 0o777&^umask) +} + +// Evaluate evaluates s and returns any error. +func (s *SourceStateFile) Evaluate() error { + _, err := s.ContentsSHA256() + return err +} + +// Path returns s's path. +func (s *SourceStateFile) Path() string { + return s.path +} + +// TargetStateEntry returns s's target state entry. +func (s *SourceStateFile) TargetStateEntry() (TargetStateEntry, error) { + if s.targetStateEntryFunc != nil { + s.targetStateEntry, s.targetStateEntryErr = s.targetStateEntryFunc() + s.targetStateEntryFunc = nil + } + return s.targetStateEntry, s.targetStateEntryErr +} + +// Write writes s to sourceStateDir. +func (s *SourceStateFile) Write(sourceStateDir System, umask os.FileMode) error { + contents, err := s.Contents() + if err != nil { + return err + } + return sourceStateDir.WriteFile(s.path, contents, 0o666&^umask) +} diff --git a/v2/chezmoi/stringset.go b/v2/chezmoi/stringset.go new file mode 100644 index 00000000000..491b433f5ba --- /dev/null +++ b/v2/chezmoi/stringset.go @@ -0,0 +1,33 @@ +package chezmoi + +// A StringSet is a set of strings. +type StringSet map[string]struct{} + +// NewStringSet returns a new StringSet containing elements. +func NewStringSet(elements ...string) StringSet { + s := make(StringSet) + s.Add(elements...) + return s +} + +// Add adds elements to s. +func (s StringSet) Add(elements ...string) { + for _, element := range elements { + s[element] = struct{}{} + } +} + +// Contains returns true if element is in s. +func (s StringSet) Contains(element string) bool { + _, ok := s[element] + return ok +} + +// Elements returns all the elements of s. +func (s StringSet) Elements() []string { + elements := make([]string, 0, len(s)) + for element := range s { + elements = append(elements, element) + } + return elements +} diff --git a/v2/chezmoi/system.go b/v2/chezmoi/system.go new file mode 100644 index 00000000000..4a887165b78 --- /dev/null +++ b/v2/chezmoi/system.go @@ -0,0 +1,32 @@ +package chezmoi + +import ( + "os" + "os/exec" +) + +// A SystemReader reads from a filesystem and executes idempotent commands. +type SystemReader interface { + PersistentStateReader + Glob(pattern string) ([]string, error) + IdempotentCmdOutput(cmd *exec.Cmd) ([]byte, error) + Lstat(filename string) (os.FileInfo, error) + ReadDir(dirname string) ([]os.FileInfo, error) + ReadFile(filename string) ([]byte, error) + Readlink(name string) (string, error) + Stat(name string) (os.FileInfo, error) +} + +// A System reads from and writes to a filesystem, executes idempotent commands, +// runs scripts, and persists state. +type System interface { + PersistentStateWriter + SystemReader + Chmod(name string, mode os.FileMode) error + Mkdir(name string, perm os.FileMode) error + RemoveAll(name string) error + Rename(oldpath, newpath string) error + RunScript(name string, data []byte) error + WriteFile(filename string, data []byte, perm os.FileMode) error + WriteSymlink(oldname, newname string) error +} diff --git a/v2/chezmoi/targetstateentry.go b/v2/chezmoi/targetstateentry.go new file mode 100644 index 00000000000..b30bc675923 --- /dev/null +++ b/v2/chezmoi/targetstateentry.go @@ -0,0 +1,268 @@ +package chezmoi + +// FIXME I don't think we need to use lazyContents here, except the SHA256 stuff is useful + +import ( + "bytes" + "encoding/hex" + "encoding/json" + "os" + "time" +) + +// A TargetStateEntry represents the state of an entry in the target state. +type TargetStateEntry interface { + Apply(s System, destStateEntry DestStateEntry) error + Equal(destStateEntry DestStateEntry) (bool, error) + Evaluate() error +} + +// A TargetStateAbsent represents the absence of an entry in the target state. +type TargetStateAbsent struct{} + +// A TargetStateDir represents the state of a directory in the target state. +type TargetStateDir struct { + perm os.FileMode + exact bool +} + +// A TargetStateFile represents the state of a file in the target state. +type TargetStateFile struct { + *lazyContents + perm os.FileMode +} + +// A TargetStateScript represents the state of a script. +type TargetStateScript struct { + *lazyContents + name string + once bool +} + +// A TargetStateSymlink represents the state of a symlink in the target state. +type TargetStateSymlink struct { + *lazyLinkname +} + +// A scriptOnceState records the state of a script that should only be run once. +type scriptOnceState struct { + Name string `json:"name"` + ExecutedAt time.Time `json:"executedAt"` // FIXME should be runAt? +} + +// Apply updates destStateEntry to match t. +func (t *TargetStateAbsent) Apply(s System, destStateEntry DestStateEntry) error { + if _, ok := destStateEntry.(*DestStateAbsent); ok { + return nil + } + return s.RemoveAll(destStateEntry.Path()) +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateAbsent) Equal(destStateEntry DestStateEntry) (bool, error) { + _, ok := destStateEntry.(*DestStateAbsent) + return ok, nil +} + +// Evaluate evaluates t. +func (t *TargetStateAbsent) Evaluate() error { + return nil +} + +// Apply updates destStateEntry to match t. It does not recurse. +func (t *TargetStateDir) Apply(s System, destStateEntry DestStateEntry) error { + if destStateDir, ok := destStateEntry.(*DestStateDir); ok { + if destStateDir.perm == t.perm { + return nil + } + return s.Chmod(destStateDir.Path(), t.perm) + } + if err := destStateEntry.Remove(s); err != nil { + return err + } + return s.Mkdir(destStateEntry.Path(), t.perm) +} + +// Equal returns true if destStateEntry matches t. It does not recurse. +func (t *TargetStateDir) Equal(destStateEntry DestStateEntry) (bool, error) { + destStateDir, ok := destStateEntry.(*DestStateDir) + if !ok { + return false, nil + } + return destStateDir.perm == t.perm, nil +} + +// Evaluate evaluates t. +func (t *TargetStateDir) Evaluate() error { + return nil +} + +// Apply updates destStateEntry to match t. +func (t *TargetStateFile) Apply(s System, destStateEntry DestStateEntry) error { + if destStateFile, ok := destStateEntry.(*DestStateFile); ok { + // Compare file contents using only their SHA256 sums. This is so that + // we can compare last-written states without storing the full contents + // of each file written. + destContentsSHA256, err := destStateFile.ContentsSHA256() + if err != nil { + return err + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return err + } + if bytes.Equal(destContentsSHA256, contentsSHA256) { + if destStateFile.perm == t.perm { + return nil + } + return s.Chmod(destStateFile.Path(), t.perm) + } + } else if err := destStateEntry.Remove(s); err != nil { + return err + } + contents, err := t.Contents() + if err != nil { + return err + } + return s.WriteFile(destStateEntry.Path(), contents, t.perm) +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateFile) Equal(destStateEntry DestStateEntry) (bool, error) { + destStateFile, ok := destStateEntry.(*DestStateFile) + if !ok { + return false, nil + } + if destStateFile.perm != t.perm { + return false, nil + } + destContentsSHA256, err := destStateFile.ContentsSHA256() + if err != nil { + return false, err + } + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return false, err + } + return bytes.Equal(destContentsSHA256, contentsSHA256), nil +} + +// Evaluate evaluates t. +func (t *TargetStateFile) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// Apply runs t. +func (t *TargetStateScript) Apply(s System, destStateEntry DestStateEntry) error { + var ( + bucket = scriptOnceStateBucket + key []byte + executedAt time.Time + ) + if t.once { + contentsSHA256, err := t.ContentsSHA256() + if err != nil { + return err + } + // FIXME the following assumes that the script name is part of the script state + // FIXME maybe it shouldn't be + key = []byte(t.name + ":" + hex.EncodeToString(contentsSHA256)) + scriptOnceState, err := s.Get(bucket, key) + if err != nil { + return err + } + if scriptOnceState != nil { + return nil + } + executedAt = time.Now() + } + contents, err := t.Contents() + if err != nil { + return err + } + if isEmpty(contents) { + return nil + } + if err := s.RunScript(t.name, contents); err != nil { + return err + } + if t.once { + value, err := json.Marshal(&scriptOnceState{ + Name: t.name, + ExecutedAt: executedAt, + }) + if err != nil { + return err + } + if err := s.Set(bucket, key, value); err != nil { + return err + } + } + return nil +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateScript) Equal(destStateEntry DestStateEntry) (bool, error) { + // Scripts are independent of the destination state. + // FIXME maybe the destination state should store the sha256 sums of executed scripts + return true, nil +} + +// Evaluate evaluates t. +func (t *TargetStateScript) Evaluate() error { + _, err := t.ContentsSHA256() + return err +} + +// Apply updates destStateEntry to match t. +func (t *TargetStateSymlink) Apply(s System, destStateEntry DestStateEntry) error { + if destStateSymlink, ok := destStateEntry.(*DestStateSymlink); ok { + destLinkname, err := destStateSymlink.Linkname() + if err != nil { + return err + } + linkname, err := t.Linkname() + if err != nil { + return err + } + if destLinkname == linkname { + return nil + } + } + linkname, err := t.Linkname() + if err != nil { + return err + } + if err := destStateEntry.Remove(s); err != nil { + return err + } + return s.WriteSymlink(linkname, destStateEntry.Path()) +} + +// Equal returns true if destStateEntry matches t. +func (t *TargetStateSymlink) Equal(destStateEntry DestStateEntry) (bool, error) { + destStateSymlink, ok := destStateEntry.(*DestStateSymlink) + if !ok { + return false, nil + } + destLinkname, err := destStateSymlink.Linkname() + if err != nil { + return false, err + } + linkname, err := t.Linkname() + if err != nil { + return false, nil + } + return destLinkname == linkname, nil +} + +// Evaluate evaluates t. +func (t *TargetStateSymlink) Evaluate() error { + _, err := t.Linkname() + return err +} + +func isEmpty(data []byte) bool { + return len(bytes.TrimSpace(data)) == 0 +} diff --git a/v2/chezmoi/targetstatentry_test.go b/v2/chezmoi/targetstatentry_test.go new file mode 100644 index 00000000000..d7920a9dc0e --- /dev/null +++ b/v2/chezmoi/targetstatentry_test.go @@ -0,0 +1,174 @@ +package chezmoi + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +func TestTargetStateEntryApplyAndEqual(t *testing.T) { + for _, tc1 := range []struct { + name string + targetStateEntry TargetStateEntry + }{ + { + name: "absent", + targetStateEntry: &TargetStateAbsent{}, + }, + { + name: "dir", + targetStateEntry: &TargetStateDir{ + perm: 0o755, + }, + }, + { + name: "file", + targetStateEntry: &TargetStateFile{ + perm: 0o644, + lazyContents: &lazyContents{ + contents: []byte("bar"), + }, + }, + }, + { + name: "file_empty", + targetStateEntry: &TargetStateFile{ + perm: 0o644, + }, + }, + { + name: "file_empty_ok", + targetStateEntry: &TargetStateFile{ + perm: 0o644, + }, + }, + { + name: "symlink", + targetStateEntry: &TargetStateSymlink{ + lazyLinkname: &lazyLinkname{ + linkname: "bar", + }, + }, + }, + } { + t.Run(tc1.name, func(t *testing.T) { + for _, tc2 := range []struct { + name string + root interface{} + }{ + { + name: "not_present", + root: map[string]interface{}{ + "/home/user": &vfst.Dir{Perm: 0o755}, + }, + }, + { + name: "existing_dir", + root: map[string]interface{}{ + "/home/user/foo": &vfst.Dir{Perm: 0o755}, + }, + }, + { + name: "existing_dir_chmod", + root: map[string]interface{}{ + "/home/user/foo": &vfst.Dir{Perm: 0o644}, + }, + }, + { + name: "existing_file_empty", + root: map[string]interface{}{ + "/home/user/foo": "", + }, + }, + { + name: "existing_file_contents", + root: map[string]interface{}{ + "/home/user/foo": "baz", + }, + }, + { + name: "existing_file_chmod", + root: map[string]interface{}{ + "/home/user/foo": &vfst.File{ + Perm: 0o755, + }, + }, + }, + { + name: "existing_symlink", + root: map[string]interface{}{ + "/home/user/bar": "", + "/home/user/foo": &vfst.Symlink{Target: "bar"}, + }, + }, + { + name: "existing_symlink_broken", + root: map[string]interface{}{ + "/home/user/foo": &vfst.Symlink{Target: "bar"}, + }, + }, + } { + t.Run(tc2.name, func(t *testing.T) { + testFS, cleanup, err := vfst.NewTestFS(tc2.root) + require.NoError(t, err) + defer cleanup() + fs := NewRealSystem(testFS, newTestPersistentState()) + + // Read the initial destination state entry from fs. + destStateEntry, err := NewDestStateEntry(fs, "/home/user/foo") + require.NoError(t, err) + + // Apply the target state entry. + require.NoError(t, tc1.targetStateEntry.Apply(NewRealSystem(fs, newTestPersistentState()), destStateEntry)) + + // Verify that the destination state entry matches the + // desired state. + vfst.RunTests(t, fs, "", vfst.TestPath("/home/user/foo", targetStateTest(t, tc1.targetStateEntry)...)) + + // Read the updated destination state entry from fs and + // verify that it is equal to the target state entry. + newDestStateEntry, err := NewDestStateEntry(fs, "/home/user/foo") + require.NoError(t, err) + equal, err := tc1.targetStateEntry.Equal(newDestStateEntry) + require.NoError(t, err) + require.True(t, equal) + }) + } + }) + } +} + +func targetStateTest(t *testing.T, ts TargetStateEntry) []vfst.PathTest { + switch ts := ts.(type) { + case *TargetStateAbsent: + return []vfst.PathTest{ + vfst.TestDoesNotExist, + } + case *TargetStateDir: + return []vfst.PathTest{ + vfst.TestIsDir, + vfst.TestModePerm(ts.perm), + } + case *TargetStateFile: + expectedContents, err := ts.Contents() + require.NoError(t, err) + return []vfst.PathTest{ + vfst.TestModeIsRegular, + vfst.TestModePerm(ts.perm), + vfst.TestContents(expectedContents), + } + case *TargetStateScript: + return nil // FIXME how to verify scripts? + case *TargetStateSymlink: + expectedLinkname, err := ts.Linkname() + require.NoError(t, err) + return []vfst.PathTest{ + vfst.TestModeType(os.ModeSymlink), + vfst.TestSymlinkTarget(expectedLinkname), + } + default: + return nil + } +} diff --git a/v2/chezmoi/tarsystem.go b/v2/chezmoi/tarsystem.go new file mode 100644 index 00000000000..eec4ff95f6e --- /dev/null +++ b/v2/chezmoi/tarsystem.go @@ -0,0 +1,114 @@ +package chezmoi + +import ( + "archive/tar" + "io" + "os" + "os/user" + "strconv" + "time" +) + +// A TARSystem is a System that writes to a TAR archive. +type TARSystem struct { + nullSystem + w *tar.Writer + headerTemplate tar.Header +} + +// NewTARSystem returns a new TARSystem that writes a TAR file to w. +func NewTARSystem(w io.Writer, headerTemplate tar.Header) *TARSystem { + return &TARSystem{ + w: tar.NewWriter(w), + headerTemplate: headerTemplate, + } +} + +// Chmod implements System.Chmod. +func (s *TARSystem) Chmod(name string, mode os.FileMode) error { + return os.ErrPermission +} + +// Close closes m. +func (s *TARSystem) Close() error { + return s.w.Close() +} + +// Mkdir implements System.Mkdir. +func (s *TARSystem) Mkdir(name string, perm os.FileMode) error { + header := s.headerTemplate + header.Typeflag = tar.TypeDir + header.Name = name + "/" + header.Mode = int64(perm) + return s.w.WriteHeader(&header) +} + +// RemoveAll implements System.RemoveAll. +func (s *TARSystem) RemoveAll(name string) error { + return os.ErrPermission +} + +// Rename implements System.Rename. +func (s *TARSystem) Rename(oldpath, newpath string) error { + return os.ErrPermission +} + +// RunScript implements System.RunScript. +func (s *TARSystem) RunScript(name string, data []byte) error { + return s.WriteFile(name, data, 0o700) +} + +// WriteFile implements System.WriteFile. +func (s *TARSystem) WriteFile(filename string, data []byte, perm os.FileMode) error { + header := s.headerTemplate + header.Typeflag = tar.TypeReg + header.Name = filename + header.Size = int64(len(data)) + header.Mode = int64(perm) + if err := s.w.WriteHeader(&header); err != nil { + return err + } + _, err := s.w.Write(data) + return err +} + +// WriteSymlink implements System.WriteSymlink. +func (s *TARSystem) WriteSymlink(oldname, newname string) error { + header := s.headerTemplate + header.Typeflag = tar.TypeSymlink + header.Name = newname + header.Linkname = oldname + return s.w.WriteHeader(&header) +} + +// TARHeaderTemplate returns a tar.Header template populated with the current +// user and time. +func TARHeaderTemplate() tar.Header { + // Attempt to lookup the current user. Ignore errors because the default + // zero values are reasonable. + var ( + uid int + gid int + Uname string + Gname string + ) + if currentUser, err := user.Current(); err == nil { + uid, _ = strconv.Atoi(currentUser.Uid) + gid, _ = strconv.Atoi(currentUser.Gid) + Uname = currentUser.Username + if group, err := user.LookupGroupId(currentUser.Gid); err == nil { + Gname = group.Name + } + } + + now := time.Now() + return tar.Header{ + Uid: uid, + Gid: gid, + Uname: Uname, + Gname: Gname, + ModTime: now, + AccessTime: now, + ChangeTime: now, + } +} diff --git a/v2/chezmoi/tarsystem_test.go b/v2/chezmoi/tarsystem_test.go new file mode 100644 index 00000000000..ab019ef49f0 --- /dev/null +++ b/v2/chezmoi/tarsystem_test.go @@ -0,0 +1,96 @@ +package chezmoi + +import ( + "archive/tar" + "bytes" + "io" + "io/ioutil" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/twpayne/go-vfs/vfst" +) + +var _ System = &TARSystem{} + +func TestSourceStateArchive(t *testing.T) { + fs, cleanup, err := vfst.NewTestFS(map[string]interface{}{ + "/home/user/.local/share/chezmoi": map[string]interface{}{ + ".chezmoiignore": "README.md\n", + ".chezmoiremove": "*.txt\n", + ".chezmoiversion": "1.2.3\n", + ".chezmoitemplates": map[string]interface{}{ + "foo": "bar", + }, + "README.md": "", + "dir": map[string]interface{}{ + "foo": "bar", + }, + "run_script": "#!/bin/sh\n", + "symlink_symlink": "bar", + }, + }) + require.NoError(t, err) + defer cleanup() + + s := NewSourceState( + WithSystem(NewRealSystem(fs, newTestPersistentState())), + WithSourcePath("/home/user/.local/share/chezmoi"), + ) + require.NoError(t, s.Read()) + require.NoError(t, s.Evaluate()) + + b := &bytes.Buffer{} + tarFS := NewTARSystem(b, tar.Header{}) + require.NoError(t, s.ApplyAll(tarFS, vfst.DefaultUmask, "")) + + r := tar.NewReader(b) + for _, tc := range []struct { + expectedTypeflag byte + expectedName string + expectedMode int64 + expectedLinkname string + expectedContents []byte + }{ + { + expectedTypeflag: tar.TypeDir, + expectedName: "dir/", + expectedMode: 0o755, + }, + { + expectedTypeflag: tar.TypeReg, + expectedName: "dir/foo", + expectedContents: []byte("bar"), + expectedMode: 0o644, + }, + { + expectedTypeflag: tar.TypeReg, + expectedName: "script", + expectedContents: []byte("#!/bin/sh\n"), + expectedMode: 0o700, + }, + { + expectedTypeflag: tar.TypeSymlink, + expectedName: "symlink", + expectedLinkname: "bar", + }, + } { + t.Run(tc.expectedName, func(t *testing.T) { + header, err := r.Next() + require.NoError(t, err) + assert.Equal(t, tc.expectedTypeflag, header.Typeflag) + assert.Equal(t, tc.expectedName, header.Name) + assert.Equal(t, tc.expectedMode, header.Mode) + assert.Equal(t, tc.expectedLinkname, header.Linkname) + assert.Equal(t, int64(len(tc.expectedContents)), header.Size) + if tc.expectedContents != nil { + actualContents, err := ioutil.ReadAll(r) + require.NoError(t, err) + assert.Equal(t, tc.expectedContents, actualContents) + } + }) + } + _, err = r.Next() + assert.Equal(t, io.EOF, err) +}