feat: share env files as encrypted links
S → f on a repo, an env file or a key in one copies a lazysecrets://share link holding the env files, encrypted (age) with a secret's value: whoever can read that secret can open it. The key is share.key in the repo's .lazysecrets.yaml, else in the config, else picked when sharing; on Azure the link pins its version. Opening one says what it holds, decrypts when asked, and saves the files in the clone of the repo (or a folder chosen), replacing existing ones only when asked. lazysecrets share prints a link from the command line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
26 files changed
+1541
-98
No files matched your search
@@ -0,0 +1,271 @@
|
||||
// Package share seals env files into a lazysecrets://share link, so they
|
||||
// can be passed on in chat or a ticket. They're encrypted with the value of
|
||||
// a secret (in Key Vault, Databricks or a repo's SOPS file): whoever can
|
||||
// read that secret can open the link, nobody else.
|
||||
//
|
||||
// A link says in the clear which secret it's encrypted with, the repo the
|
||||
// files are from and how many there are, which is what's needed to open
|
||||
// it. The files' paths and contents are encrypted, with age
|
||||
// (https://age-encryption.org), the secret's value as its passphrase.
|
||||
//
|
||||
// The link's payload is unpadded base64url of:
|
||||
//
|
||||
// version (1) | uvarint length of the header | header (JSON) | age file
|
||||
//
|
||||
// The age file holds the files as deflated JSON.
|
||||
package share
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/flate"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
const (
|
||||
version = 1
|
||||
// MaxLink keeps a link short enough for chat and tickets (Teams takes
|
||||
// about 28 KB) and for passing it to a terminal window.
|
||||
MaxLink = 16 << 10
|
||||
// MinKey is the shortest secret value accepted as a key.
|
||||
MinKey = 16
|
||||
// maxFiles caps what opening a link unpacks.
|
||||
maxFiles = 1 << 20
|
||||
)
|
||||
|
||||
// workFactor is age's scrypt work factor for sealing (its default);
|
||||
// maxWorkFactor is the most opening accepts, so a link can't make it
|
||||
// work for minutes.
|
||||
var workFactor, maxWorkFactor = 18, 20
|
||||
|
||||
// File is a shared file.
|
||||
type File struct {
|
||||
Path string // relative to the repo's root, with slashes
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Header is what a link says in the clear.
|
||||
type Header struct {
|
||||
// Key is the secret whose value the files are encrypted with.
|
||||
Key ref.Ref
|
||||
// Repo is the repo the files are from: its remote without scheme or
|
||||
// .git (a repo link's host).
|
||||
Repo string
|
||||
// Files is how many files the link holds.
|
||||
Files int
|
||||
}
|
||||
|
||||
// Sealed is a share link read, its files still encrypted.
|
||||
type Sealed struct {
|
||||
Header
|
||||
box []byte
|
||||
}
|
||||
|
||||
type header struct {
|
||||
Key string `json:"key"`
|
||||
Repo string `json:"repo,omitempty"`
|
||||
Files int `json:"files"`
|
||||
}
|
||||
|
||||
type file struct {
|
||||
Path string `json:"path"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Seal encrypts files with key, the value of h.Key, into a share link.
|
||||
func Seal(h Header, key []byte, files []File) (ref.Ref, error) {
|
||||
if h.Key.IsShare() || h.Key.IsSource() || h.Key.IsContainer() {
|
||||
return ref.Ref{}, errors.New("the key must be a secret's link")
|
||||
}
|
||||
if len(key) < MinKey {
|
||||
return ref.Ref{}, fmt.Errorf("the key's value is %d characters: use a random one of at least %d", len(key), MinKey)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return ref.Ref{}, errors.New("no files to share")
|
||||
}
|
||||
plain := make([]file, len(files))
|
||||
for i, f := range files {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if !utf8.Valid(f.Data) {
|
||||
return ref.Ref{}, fmt.Errorf("%s isn't text", f.Path)
|
||||
}
|
||||
plain[i] = file{Path: f.Path, Data: string(f.Data)}
|
||||
}
|
||||
b, err := json.Marshal(plain)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
|
||||
rcpt, err := age.NewScryptRecipient(string(key))
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
rcpt.SetWorkFactor(workFactor)
|
||||
var box bytes.Buffer
|
||||
enc, err := age.Encrypt(&box, rcpt)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
z, _ := flate.NewWriter(enc, flate.BestCompression)
|
||||
if _, err := z.Write(b); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if err := z.Close(); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if err := enc.Close(); err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
|
||||
hb, err := json.Marshal(header{Key: h.Key.String(), Repo: h.Repo, Files: len(files)})
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
payload := binary.AppendUvarint([]byte{version}, uint64(len(hb)))
|
||||
payload = append(append(payload, hb...), box.Bytes()...)
|
||||
r := ref.Ref{Share: base64.RawURLEncoding.EncodeToString(payload)}
|
||||
if n := len(r.String()); n > MaxLink {
|
||||
return ref.Ref{}, fmt.Errorf("the link would be %d KB, more than the %d KB it can be: share fewer files", n>>10, MaxLink>>10)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Read reads a share link's header.
|
||||
func Read(r ref.Ref) (Sealed, error) {
|
||||
bad := errors.New("share link is cut short or garbled: copy all of it")
|
||||
b, err := base64.RawURLEncoding.DecodeString(r.Share)
|
||||
if err != nil || len(b) < 2 {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
if b[0] != version {
|
||||
return Sealed{}, errors.New("share link is from a newer lazysecrets: update it (lazysecrets update)")
|
||||
}
|
||||
n, size := binary.Uvarint(b[1:])
|
||||
if size <= 0 || n > uint64(len(b)) {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
rest := b[1+size:]
|
||||
if n > uint64(len(rest)) {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
var h header
|
||||
if err := json.Unmarshal(rest[:n], &h); err != nil {
|
||||
return Sealed{}, bad
|
||||
}
|
||||
key, err := ParseKey(h.Key)
|
||||
if err != nil {
|
||||
return Sealed{}, fmt.Errorf("share link's key: %w", err)
|
||||
}
|
||||
return Sealed{Header: Header{Key: key, Repo: h.Repo, Files: h.Files}, box: rest[n:]}, nil
|
||||
}
|
||||
|
||||
// Open decrypts the files with key, the value of s.Key.
|
||||
func (s Sealed) Open(key []byte) ([]File, error) {
|
||||
id, err := age.NewScryptIdentity(string(key))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id.SetMaxWorkFactor(maxWorkFactor)
|
||||
dec, err := age.Decrypt(bytes.NewReader(s.box), id)
|
||||
if err != nil {
|
||||
var wrong *age.NoIdentityMatchError
|
||||
if errors.As(err, &wrong) {
|
||||
return nil, errors.New("the key doesn't decrypt it: the link may have been made with another version of " + s.Key.Name)
|
||||
}
|
||||
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(flate.NewReader(dec), maxFiles+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("can't decrypt the link: %w", err)
|
||||
}
|
||||
if len(b) > maxFiles {
|
||||
return nil, errors.New("the link holds more than lazysecrets unpacks")
|
||||
}
|
||||
var plain []file
|
||||
if err := json.Unmarshal(b, &plain); err != nil {
|
||||
return nil, fmt.Errorf("can't read the link's files: %w", err)
|
||||
}
|
||||
out := make([]File, len(plain))
|
||||
for i, f := range plain {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[i] = File{Path: f.Path, Data: []byte(f.Data)}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckPath accepts a path relative to a repo's root, with slashes, of an
|
||||
// env file (.env, .env.local, prod.env) outside .git: what a link may
|
||||
// write.
|
||||
func CheckPath(p string) error {
|
||||
switch {
|
||||
case p == "" || path.IsAbs(p) || strings.ContainsAny(p, "\\\x00") || path.Clean(p) != p ||
|
||||
p == ".." || strings.HasPrefix(p, "../"):
|
||||
return fmt.Errorf("%q isn't a path inside a repo", p)
|
||||
case p == ".git" || strings.HasPrefix(p, ".git/") || strings.Contains(p, "/.git/"):
|
||||
return fmt.Errorf("%q is inside .git", p)
|
||||
case !repo.IsEnv(p):
|
||||
return fmt.Errorf("%q isn't an env file: only those can be shared", p)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DefaultKey is the key for a repo's files: share.key in the repo's
|
||||
// .lazysecrets.yaml, else general (share.key in the config). ok is false
|
||||
// when neither names one.
|
||||
func DefaultKey(root, general string) (key ref.Ref, ok bool, err error) {
|
||||
link, err := repo.ShareKey(root)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, err
|
||||
}
|
||||
where := root + "/.lazysecrets.yaml"
|
||||
if link == "" {
|
||||
link, where = general, "the config"
|
||||
}
|
||||
if link == "" {
|
||||
return ref.Ref{}, false, nil
|
||||
}
|
||||
key, err = ParseKey(link)
|
||||
if err != nil {
|
||||
return ref.Ref{}, false, fmt.Errorf("share.key in %s: %w", where, err)
|
||||
}
|
||||
return key, true, nil
|
||||
}
|
||||
|
||||
// ParseKey reads a key's link: a link to a secret.
|
||||
func ParseKey(link string) (ref.Ref, error) {
|
||||
key, err := ref.Parse(link)
|
||||
if err != nil {
|
||||
return ref.Ref{}, err
|
||||
}
|
||||
if key.IsShare() || key.IsSource() || key.IsContainer() {
|
||||
return ref.Ref{}, fmt.Errorf("%s isn't a secret's link", link)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Pin is key at version, the version its value was read at, for Azure,
|
||||
// whose old versions stay readable: a link keeps working when the key
|
||||
// is rotated, until that version is disabled.
|
||||
func Pin(key ref.Ref, version string) ref.Ref {
|
||||
if key.Kind == provider.KindAzure && key.Version == "" {
|
||||
key.Version = version
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
||||
)
|
||||
|
||||
func init() { workFactor = 10 } // fast; opening accepts any up to maxWorkFactor
|
||||
|
||||
var (
|
||||
key = ref.Ref{Kind: provider.KindAzure, Host: "kv-team.vault.azure.net", Type: provider.TypeSecret, Name: "env-share", Version: "91d3e0"}
|
||||
pass = []byte("0123456789abcdef0123456789abcdef")
|
||||
)
|
||||
|
||||
func TestSealOpen(t *testing.T) {
|
||||
files := []File{
|
||||
{Path: ".env", Data: []byte("DB_PASSWORD=hunter2\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("TOKEN=\"a b\" # quoted\n")},
|
||||
}
|
||||
link, err := Seal(Header{Key: key, Repo: "git.example.com/team/app"}, pass, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(link.String(), "hunter2") || strings.Contains(link.String(), "apps") {
|
||||
t.Errorf("link shows what it shares: %s", link)
|
||||
}
|
||||
// It goes through ref.Parse like any link.
|
||||
r, err := ref.Parse(link.String())
|
||||
if err != nil || !r.IsShare() {
|
||||
t.Fatalf("parse: %v, %+v", err, r)
|
||||
}
|
||||
s, err := Read(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Key != key || s.Repo != "git.example.com/team/app" || s.Files != 2 {
|
||||
t.Errorf("header %+v", s.Header)
|
||||
}
|
||||
got, err := s.Open(pass)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 2 || got[1].Path != files[1].Path || string(got[1].Data) != string(files[1].Data) {
|
||||
t.Errorf("opened %+v", got)
|
||||
}
|
||||
if _, err := s.Open([]byte("another key, long enough to use")); err == nil || !strings.Contains(err.Error(), "doesn't decrypt") {
|
||||
t.Errorf("wrong key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSealRefuses(t *testing.T) {
|
||||
ok := []File{{Path: ".env", Data: []byte("A=1")}}
|
||||
for name, c := range map[string]struct {
|
||||
h Header
|
||||
pass []byte
|
||||
files []File
|
||||
want string
|
||||
}{
|
||||
"short key": {Header{Key: key}, []byte("short"), ok, "at least 16"},
|
||||
"vault as key": {Header{Key: ref.Ref{Kind: provider.KindAzure, Host: "kv.vault.azure.net"}}, pass, ok, "secret's link"},
|
||||
"no files": {Header{Key: key}, pass, nil, "no files"},
|
||||
"not env": {Header{Key: key}, pass, []File{{Path: "main.go", Data: []byte("x")}}, "isn't an env file"},
|
||||
"escapes": {Header{Key: key}, pass, []File{{Path: "../.env", Data: []byte("x")}}, "inside a repo"},
|
||||
"binary": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte{0xff, 0xfe}}}, "isn't text"},
|
||||
"too big": {Header{Key: key}, pass, []File{{Path: ".env", Data: []byte(random(2 * MaxLink))}}, "more than the 16 KB"},
|
||||
} {
|
||||
if _, err := Seal(c.h, c.pass, c.files); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// random is text that doesn't compress.
|
||||
func random(n int) string {
|
||||
var b strings.Builder
|
||||
x := uint32(1)
|
||||
for b.Len() < n {
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
b.WriteByte("abcdefghijklmnopqrstuvwxyz0123456789"[x%36])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func TestReadGarbled(t *testing.T) {
|
||||
link, err := Seal(Header{Key: key}, pass, []File{{Path: ".env", Data: []byte("A=1")}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cut := ref.Ref{Share: link.Share[:len(link.Share)/2]}
|
||||
s, err := Read(cut)
|
||||
if err == nil {
|
||||
_, err = s.Open(pass)
|
||||
}
|
||||
if err == nil {
|
||||
t.Error("a link cut in half opened")
|
||||
}
|
||||
if _, err := Read(ref.Ref{Share: "AgAAAAAAAAAAAAAAAAAAAA"}); err == nil || !strings.Contains(err.Error(), "newer lazysecrets") {
|
||||
t.Errorf("version 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPath(t *testing.T) {
|
||||
for _, p := range []string{".env", "apps/api/.env.local", "prod.env"} {
|
||||
if err := CheckPath(p); err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"", "/etc/.env", "../.env", "a/../.env", ".git/.env", "a/.git/x.env", `a\.env`, ".env.example", "node_modules/x/.env", "Makefile"} {
|
||||
if err := CheckPath(p); err == nil {
|
||||
t.Errorf("%q accepted", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanWrite(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if out, err := exec.Command("git", "-C", dir, "init", "-q").CombinedOutput(); err != nil {
|
||||
t.Skip("git:", string(out))
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".gitignore"), []byte(".env\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, ".env"), []byte("OLD=1\n"), 0o644)
|
||||
targets, err := Plan(dir, []File{
|
||||
{Path: ".env", Data: []byte("NEW=1\n")},
|
||||
{Path: "apps/api/.env.local", Data: []byte("B=2\n")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !targets[0].Exists || targets[0].Exposed {
|
||||
t.Errorf(".env: %+v", targets[0])
|
||||
}
|
||||
if targets[1].Exists || !targets[1].Exposed {
|
||||
t.Errorf("apps/api/.env.local: %+v", targets[1])
|
||||
}
|
||||
for _, tg := range targets {
|
||||
if err := tg.Write(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if b, _ := os.ReadFile(filepath.Join(dir, ".env")); string(b) != "NEW=1\n" {
|
||||
t.Errorf(".env is %q", b)
|
||||
}
|
||||
info, err := os.Stat(filepath.Join(dir, "apps", "api", ".env.local"))
|
||||
if err != nil || info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("new file: %v, %v", info.Mode(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSymlinks(t *testing.T) {
|
||||
dir, outside := t.TempDir(), t.TempDir()
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "apps")); err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
if _, err := Plan(dir, []File{{Path: "apps/.env"}}); err == nil || !strings.Contains(err.Error(), "out of the folder") {
|
||||
t.Errorf("through a linked folder: %v", err)
|
||||
}
|
||||
os.Symlink(filepath.Join(outside, "x"), filepath.Join(dir, ".env"))
|
||||
if _, err := Plan(dir, []File{{Path: ".env"}}); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||
t.Errorf("onto a symlink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
general := "lazysecrets://azure/kv-team.vault.azure.net/secrets/env-share"
|
||||
k, ok, err := DefaultKey(dir, general)
|
||||
if err != nil || !ok || k.Name != "env-share" {
|
||||
t.Errorf("general: %+v %v %v", k, ok, err)
|
||||
}
|
||||
os.WriteFile(filepath.Join(dir, ".lazysecrets.yaml"), []byte("share:\n key: lazysecrets://azure/kv-app.vault.azure.net/secrets/app-share\n"), 0o644)
|
||||
if k, _, _ := DefaultKey(dir, general); k.Name != "app-share" {
|
||||
t.Errorf("the repo's should win: %+v", k)
|
||||
}
|
||||
if _, ok, _ := DefaultKey(t.TempDir(), ""); ok {
|
||||
t.Error("a key with none set")
|
||||
}
|
||||
unpinned := key
|
||||
unpinned.Version = ""
|
||||
if p := Pin(unpinned, "abc"); p.Version != "abc" {
|
||||
t.Errorf("pin: %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Target is where an opened file goes.
|
||||
type Target struct {
|
||||
File
|
||||
Abs string
|
||||
Exists bool
|
||||
// Exposed is set when dir is a git repo that doesn't ignore the file:
|
||||
// its values could end up in a commit.
|
||||
Exposed bool
|
||||
}
|
||||
|
||||
// Plan says where files go under dir, without writing anything. A path
|
||||
// that leads out of dir through a symlink, or is one, is an error.
|
||||
func Plan(dir string, files []File) ([]Target, error) {
|
||||
root, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Target, len(files))
|
||||
for i, f := range files {
|
||||
if err := CheckPath(f.Path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := Target{File: f, Abs: filepath.Join(root, filepath.FromSlash(f.Path))}
|
||||
if err := inside(root, filepath.Dir(t.Abs)); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", f.Path, err)
|
||||
}
|
||||
switch info, err := os.Lstat(t.Abs); {
|
||||
case err == nil && info.Mode()&fs.ModeSymlink != 0:
|
||||
return nil, fmt.Errorf("%s is a symlink: not writing through it", f.Path)
|
||||
case err == nil && !info.Mode().IsRegular():
|
||||
return nil, fmt.Errorf("%s isn't a file", f.Path)
|
||||
case err == nil:
|
||||
t.Exists = true
|
||||
case !errors.Is(err, fs.ErrNotExist):
|
||||
return nil, err
|
||||
}
|
||||
t.Exposed = exposed(root, f.Path)
|
||||
out[i] = t
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// inside checks that dir, or the part of it that exists, is under root
|
||||
// once symlinks are followed.
|
||||
func inside(root, dir string) error {
|
||||
for {
|
||||
real, err := filepath.EvalSymlinks(dir)
|
||||
if errors.Is(err, fs.ErrNotExist) && dir != root {
|
||||
dir = filepath.Dir(dir) // created when writing
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if real != root && !strings.HasPrefix(real, root+string(filepath.Separator)) {
|
||||
return errors.New("leads out of the folder through a symlink")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// exposed reports whether root is in a git repo that doesn't ignore p.
|
||||
func exposed(root, p string) bool {
|
||||
err := exec.Command("git", "-C", root, "check-ignore", "-q", "--", p).Run()
|
||||
var exit *exec.ExitError
|
||||
return errors.As(err, &exit) && exit.ExitCode() == 1 // 0: ignored; 128: no repo
|
||||
}
|
||||
|
||||
// Write writes the file, readable by the user only when it's new.
|
||||
func (t Target) Write() error {
|
||||
if err := os.MkdirAll(filepath.Dir(t.Abs), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(t.Abs, t.Data, 0o600)
|
||||
}
|
||||
Reference in new issue
Block a user