diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index d23f187..4db1676 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -2578,3 +2578,17 @@ Gates: `go test ./... -count=1` 26/26 packages ok (macOS); internal/deploy full suite PASS on Linux (colima, bash 5.2); `GOOS=linux go vet ./... && GOOS=linux go build ./...` clean; `make quickstart` green on colima. + +## L14 — remote build context credential exposure (2026-09-24) + +Resolved in the source-sync path: Git-ignored files no longer ride along with +remote builds, protected configuration/secret paths cannot be allowlisted, +ordinary generated artifacts have an explicit `.teployignore` include path, +and the resolved transfer list also drives provenance. Attempt directories +are mode 0700. Static releases exclude protected files too. `teploy doctor` +reports legacy exposure without mutating the server. See `docs/build-context.md`. + +Validation: full Go tests and vet; source-selection/rsync argument tests; +private-directory permission tests; real repository context checks for Ship, +Dash and Observe. This change does not remove historical uploaded copies or +rotate potentially exposed credentials; the doctor reports those separately. diff --git a/docs/build-context.md b/docs/build-context.md new file mode 100644 index 0000000..942049b --- /dev/null +++ b/docs/build-context.md @@ -0,0 +1,33 @@ +# Files sent to a remote build + +Container builds upload tracked files and untracked files that Git does not +ignore. This follows `.gitignore`, `.git/info/exclude`, and the user's Git +excludes. Outside a Git worktree, all files are considered before exclusions. + +Use `.teployignore` to exclude additional files. An explicit `!` pattern can +include a Git-ignored build artifact that the Dockerfile needs: + +``` +!/dist/ +!/web/dist/ +/web/dist/**/*.map +``` + +Exclusions take precedence over includes. Environment files, Git metadata, +node_modules, root Teploy configuration, destination overlays, and conventional +secret stores are protected and cannot be included by `!`. Keep credentials in +the deployment secret store instead of copying them into an image. Dockerfile +COPY/ADD checks report missing excluded inputs before upload and explain how to +include ordinary build artifacts. + +The upload's selected paths also determine its recorded context fingerprint. +Each build attempt lives under an owner-only directory on the server; files +inside retain their source modes so unprivileged application processes can read +the files Docker copies into the image. + +Static deployments also exclude protected files, but do not apply Git ignores: +static sources normally point at generated output directories. + +`teploy doctor` reports older build directories that are traversable by other +users and potentially secret-bearing files under build/static release paths. +The check lists paths only and does not delete files or change permissions. diff --git a/internal/build/build_test.go b/internal/build/build_test.go index e03e293..d914a8d 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -224,73 +224,67 @@ func TestPruneImages(t *testing.T) { } } -func TestLoadIgnore_Default(t *testing.T) { +func TestLoadRules_Default(t *testing.T) { dir := t.TempDir() - patterns, err := LoadIgnore(dir) + rules, err := LoadRules(dir) if err != nil { - t.Fatalf("LoadIgnore: %v", err) + t.Fatalf("LoadRules: %v", err) } - - if len(patterns) != len(DefaultIgnore) { - t.Fatalf("expected %d default patterns, got %d", len(DefaultIgnore), len(patterns)) + if strings.Join(rules.Protected, "\n") != strings.Join(DefaultIgnore, "\n") { + t.Fatalf("protected = %v, want the defaults %v", rules.Protected, DefaultIgnore) } - for i, p := range patterns { - if p != DefaultIgnore[i] { - t.Errorf("pattern %d: expected %s, got %s", i, DefaultIgnore[i], p) - } + if len(rules.Excludes) != 0 || len(rules.Includes) != 0 { + t.Fatalf("no .teployignore must add nothing: %+v", rules) } } -// TestLoadIgnore_CustomFileExtendsDefaults is the T51 regression: a custom +// TestLoadRules_CustomFileExtendsDefaults is the T51 regression: a custom // .teployignore must EXTEND the protected defaults (.env/.git), never // replace them — one custom pattern used to ship the .env file to the build -// host. -func TestLoadIgnore_CustomFileExtendsDefaults(t *testing.T) { +// host. `!` lines are the allowlist, not excludes. +func TestLoadRules_CustomFileExtendsDefaults(t *testing.T) { dir := t.TempDir() - content := "vendor\n# comment\n.cache\n\nbuild\n" + content := "vendor\n# comment\n.cache\n\nbuild\n!/dist/\n" os.WriteFile(filepath.Join(dir, ".teployignore"), []byte(content), 0644) - patterns, err := LoadIgnore(dir) + rules, err := LoadRules(dir) if err != nil { - t.Fatalf("LoadIgnore: %v", err) + t.Fatalf("LoadRules: %v", err) } - - joined := "\n" + strings.Join(patterns, "\n") + "\n" - for _, protected := range DefaultIgnore { - if !strings.Contains(joined, "\n"+protected+"\n") { - t.Errorf("custom ignore file dropped the protected default %q: %v", protected, patterns) - } + if strings.Join(rules.Protected, "\n") != strings.Join(DefaultIgnore, "\n") { + t.Errorf("custom ignore file changed the protected defaults: %v", rules.Protected) } - for _, custom := range []string{"vendor", ".cache", "build"} { - if !strings.Contains(joined, "\n"+custom+"\n") { - t.Errorf("custom pattern %q missing: %v", custom, patterns) - } + if got := strings.Join(rules.Excludes, ","); got != "vendor,.cache,build" { + t.Errorf("excludes = %q", got) + } + if got := strings.Join(rules.Includes, ","); got != "/dist/" { + t.Errorf("includes = %q", got) } } -func TestLoadIgnore_EmptyFile(t *testing.T) { +func TestLoadRules_EmptyFile(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, ".teployignore"), []byte("\n\n# only comments\n"), 0644) - patterns, err := LoadIgnore(dir) + rules, err := LoadRules(dir) if err != nil { - t.Fatalf("LoadIgnore: %v", err) + t.Fatalf("LoadRules: %v", err) } - if len(patterns) != len(DefaultIgnore) { - t.Fatalf("expected defaults for empty file, got %d patterns", len(patterns)) + if len(rules.Excludes) != 0 || len(rules.Includes) != 0 { + t.Fatalf("expected defaults only for an empty file, got %+v", rules) } } -// TestLoadIgnore_UnreadableFileIsAnError is the T51 regression: read +// TestLoadRules_UnreadableFileIsAnError is the T51 regression: read // failures used to fold into "no custom rules" and transfer silently. -func TestLoadIgnore_UnreadableFileIsAnError(t *testing.T) { +func TestLoadRules_UnreadableFileIsAnError(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, ".teployignore") os.WriteFile(path, []byte("vendor\n"), 0644) if err := os.Chmod(path, 0000); err != nil { t.Skip("cannot make the ignore file unreadable") } - if _, err := LoadIgnore(dir); err == nil { + if _, err := LoadRules(dir); err == nil { t.Error("an unreadable .teployignore must be an error, never a silent defaults-only transfer") } } diff --git a/internal/build/fingerprint.go b/internal/build/fingerprint.go index 50fc314..f420247 100644 --- a/internal/build/fingerprint.go +++ b/internal/build/fingerprint.go @@ -2,139 +2,11 @@ package build import ( "crypto/sha256" - "encoding/binary" - "encoding/hex" - "fmt" "io" - "io/fs" "os" - "path/filepath" "runtime" - "sort" ) -// ContextFingerprint returns the sha256 fingerprint of the build-context -// tree dir would transfer: every file's path and content, every directory's -// path, and every symlink's target, with the given exclude patterns -// (rsync-style: matched against each entry's base name and its -// slash-separated relative path) left out — the fingerprint describes the -// SOURCE the builder consumes, not the operator's local clutter. -// -// Encoding discipline mirrors the static deployer's v3 tree hash (audit -// F51/TCL-38): typed, length-prefixed records for EVERY entry — -// directories included — emitted in sorted path order, so no two distinct -// trees can collide through framing ambiguity. Permission bits are ignored -// (umask stability across machines). Unlike the static hash, symlinks are -// INCLUDED, hashed by target: rsync -a preserves links into the build -// context, so a link is build input whose identity is what it points at. -// This is a provenance identity, not a security boundary. -func ContextFingerprint(dir string, excludes []string) (string, error) { - if dir == "" { - dir = "." - } - type entry struct { - rel string - kind byte // 'f' file, 'd' directory, 'l' symlink - size int64 - digest [32]byte - target string - } - var entries []entry - pruned := func(rel string) bool { - if len(excludes) == 0 { - return false - } - base := filepath.Base(rel) - for _, pat := range excludes { - if pat == "" { - continue - } - if ok, _ := filepath.Match(pat, base); ok { - return true - } - if ok, _ := filepath.Match(pat, rel); ok { - return true - } - } - return false - } - - root := filepath.Clean(dir) - err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { - if walkErr != nil { - return walkErr - } - if p == root { - return nil - } - rel, err := filepath.Rel(root, p) - if err != nil { - return err - } - rel = filepath.ToSlash(rel) - if pruned(rel) { - if d.IsDir() { - return fs.SkipDir - } - return nil - } - info, err := d.Info() - if err != nil { - return err - } - switch { - case info.Mode()&fs.ModeSymlink != 0: - target, err := os.Readlink(p) - if err != nil { - return err - } - entries = append(entries, entry{rel: rel, kind: 'l', target: target}) - case info.IsDir(): - entries = append(entries, entry{rel: rel, kind: 'd'}) - case info.Mode().IsRegular(): - digest, size, err := hashFile(p) - if err != nil { - return err - } - entries = append(entries, entry{rel: rel, kind: 'f', size: size, digest: digest}) - default: - // Sockets, devices and FIFOs cannot be synced as build input; - // record their presence so the fingerprint still moves. - entries = append(entries, entry{rel: rel, kind: 's'}) - } - return nil - }) - if err != nil { - return "", fmt.Errorf("fingerprinting build context %s: %w", dir, err) - } - sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel }) - - h := sha256.New() - h.Write([]byte("teploy-context-v1\x00")) - var num [8]byte - binary.BigEndian.PutUint64(num[:], uint64(len(entries))) - h.Write(num[:]) - var len8 [8]byte - writeStr := func(s string) { - binary.BigEndian.PutUint64(len8[:], uint64(len(s))) - h.Write(len8[:]) - h.Write([]byte(s)) - } - for _, e := range entries { - h.Write([]byte{e.kind}) - writeStr(e.rel) - switch e.kind { - case 'f': - binary.BigEndian.PutUint64(len8[:], uint64(e.size)) - h.Write(len8[:]) - h.Write(e.digest[:]) - case 'l': - writeStr(e.target) - } - } - return hex.EncodeToString(h.Sum(nil)), nil -} - func hashFile(path string) ([32]byte, int64, error) { f, err := os.Open(path) if err != nil { diff --git a/internal/build/fingerprint_test.go b/internal/build/fingerprint_test.go index 526e4ae..b783590 100644 --- a/internal/build/fingerprint_test.go +++ b/internal/build/fingerprint_test.go @@ -7,6 +7,15 @@ import ( "testing" ) +// contextFingerprint resolves dir's upload selection and fingerprints it. +func contextFingerprint(dir string) (string, error) { + src, err := ResolveSource(dir) + if err != nil { + return "", err + } + return src.Fingerprint("") +} + func writeTree(t *testing.T, dir string, files map[string]string) { t.Helper() for rel, content := range files { @@ -37,11 +46,11 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) { writeTree(t, a, base) writeTree(t, b, base) - fa, err := ContextFingerprint(a, DefaultIgnore) + fa, err := contextFingerprint(a) if err != nil { t.Fatalf("ContextFingerprint: %v", err) } - fb, err := ContextFingerprint(b, DefaultIgnore) + fb, err := contextFingerprint(b) if err != nil { t.Fatalf("ContextFingerprint: %v", err) } @@ -50,7 +59,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) { } // Re-resolving the SAME tree (a second attempt) is stable. - fa2, err := ContextFingerprint(a, DefaultIgnore) + fa2, err := contextFingerprint(a) if err != nil || fa2 != fa { t.Fatalf("same tree re-fingerprinted differently: %q vs %q (%v)", fa, fa2, err) } @@ -60,7 +69,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) { if err := os.WriteFile(filepath.Join(b, "main.go"), []byte("package mian"), 0o644); err != nil { t.Fatal(err) } - if fb, err = ContextFingerprint(b, DefaultIgnore); err != nil || fb == fa { + if fb, err = contextFingerprint(b); err != nil || fb == fa { t.Fatalf("a content change must move the fingerprint: %q vs %q (%v)", fa, fb, err) } @@ -69,7 +78,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) { if err := os.Rename(filepath.Join(b, "docs"), filepath.Join(b, "docz")); err != nil { t.Fatal(err) } - if fb, err = ContextFingerprint(b, DefaultIgnore); err != nil || fb == fa { + if fb, err = contextFingerprint(b); err != nil || fb == fa { t.Fatalf("a rename must move the fingerprint (path is part of identity): %q vs %q (%v)", fa, fb, err) } @@ -78,7 +87,7 @@ func TestContextFingerprint_DeterministicAndSensitive(t *testing.T) { if err := os.MkdirAll(filepath.Join(b, "brand/new/dir"), 0o755); err != nil { t.Fatal(err) } - if fb, err = ContextFingerprint(b, DefaultIgnore); err != nil || fb == fa { + if fb, err = contextFingerprint(b); err != nil || fb == fa { t.Fatalf("a new empty directory must move the fingerprint (TCL-38 parity): %q vs %q (%v)", fa, fb, err) } } @@ -98,11 +107,11 @@ func TestContextFingerprint_HonorsExcludePatterns(t *testing.T) { ".env.local": "SECRET=2", }) - fa, err := ContextFingerprint(a, DefaultIgnore) + fa, err := contextFingerprint(a) if err != nil { t.Fatal(err) } - fb, err := ContextFingerprint(b, DefaultIgnore) + fb, err := contextFingerprint(b) if err != nil { t.Fatal(err) } @@ -127,11 +136,11 @@ func TestContextFingerprint_SymlinkTargetMovesIdentity(t *testing.T) { if err := os.Symlink("elsewhere", filepath.Join(b, "link")); err != nil { t.Fatal(err) } - fa, err := ContextFingerprint(a, DefaultIgnore) + fa, err := contextFingerprint(a) if err != nil { t.Fatal(err) } - fb, err := ContextFingerprint(b, DefaultIgnore) + fb, err := contextFingerprint(b) if err != nil { t.Fatal(err) } diff --git a/internal/build/ignore.go b/internal/build/ignore.go index 5c4b5a1..b9709dd 100644 --- a/internal/build/ignore.go +++ b/internal/build/ignore.go @@ -3,44 +3,249 @@ package build import ( "fmt" "os" + "path" "path/filepath" + "regexp" "strings" ) -// DefaultIgnore contains the ALWAYS-protected patterns excluded from every -// source sync. A custom .teployignore EXTENDS this list (audit T51): the -// old load replaced the defaults wholesale, so adding one harmless custom -// pattern silently shipped .env, .env.* and .git to the build host — where -// a broad Dockerfile COPY bakes them into the image. +// DefaultIgnore contains the ALWAYS-protected patterns: never uploaded to a +// build host, and a `!` allowlist line in .teployignore cannot re-include +// them. A custom .teployignore EXTENDS this list (audit T51): the old load +// replaced the defaults wholesale, so adding one harmless custom pattern +// silently shipped .env, .env.* and .git to the build host — where a broad +// Dockerfile COPY bakes them into the image. +// +// teploy's own config is protected too (L14): the remote build never reads +// it (the Dockerfile is the only build input, and autodeploy reads its own +// git checkout), and destination overlays (teploy..yml) are where +// operators keep per-infra credentials — live on 2026-09-24 an overlay +// carrying the dash admin password was found world-readable in build dirs. var DefaultIgnore = []string{ "node_modules", ".git", ".env", ".env.*", ".teployignore", + "/teploy.yml", + "/teploy.yaml", + "/teploy.toml", + "teploy.*.yml", + "teploy.*.yaml", + "teploy.*.toml", + ".secrets", + "secrets.yml", + "secrets.yaml", + "secrets.json", + "secrets.toml", + "secrets.env", + "*.secrets.env", } -// LoadIgnore reads .teployignore from the given directory and returns the -// protected defaults MERGED with the user's patterns (defaults first, so -// they cannot be shadowed by ordering). A missing ignore file yields the -// defaults; an UNREADABLE one is an error — the old load folded read -// failures into "no custom rules" and transferred with defaults silently. -func LoadIgnore(dir string) ([]string, error) { +// Rules is the parsed selection policy for one source directory: the +// protected defaults, the .teployignore excludes, and the .teployignore +// `!` allowlist (paths .gitignore hides that the build genuinely needs). +type Rules struct { + Protected []string + Excludes []string + Includes []string + + protected []rule + excludes []rule + includes []rule +} + +// LoadRules reads .teployignore from dir. A missing file yields the +// protected defaults alone; an UNREADABLE one is an error — the old load +// folded read failures into "no custom rules" and transferred with +// defaults silently. +// +// Line grammar (rsync-style patterns): blank lines and `#` comments are +// skipped; `!pattern` allowlists a gitignored path; anything else is an +// exclude. A leading `/` anchors to the source root, a trailing `/` +// matches directories only, `*` and `?` stay within one path segment and +// `**` crosses segments. A pattern without a `/` matches a name at any +// depth. +func LoadRules(dir string) (*Rules, error) { + r := &Rules{Protected: append([]string(nil), DefaultIgnore...)} data, err := os.ReadFile(filepath.Join(dir, ".teployignore")) - if err != nil { - if os.IsNotExist(err) { - return DefaultIgnore, nil - } + if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("reading .teployignore: %w", err) } - - patterns := append([]string(nil), DefaultIgnore...) for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } - patterns = append(patterns, line) + if inc, ok := strings.CutPrefix(line, "!"); ok { + if inc = strings.TrimSpace(inc); inc != "" { + r.Includes = append(r.Includes, inc) + } + continue + } + r.Excludes = append(r.Excludes, line) + } + for _, set := range []struct { + pats []string + dst *[]rule + }{{r.Protected, &r.protected}, {r.Excludes, &r.excludes}, {r.Includes, &r.includes}} { + for _, p := range set.pats { + compiled, err := compileRule(p) + if err != nil { + return nil, fmt.Errorf(".teployignore pattern %q: %w", p, err) + } + *set.dst = append(*set.dst, compiled) + } + } + return r, nil +} + +// IsProtected reports whether rel (slash-separated, relative to the source +// root) or any of its ancestor directories matches a protected pattern. +func (r *Rules) IsProtected(rel string, isDir bool) bool { + return matchesAny(r.protected, rel, isDir) +} + +// IsExcluded reports whether rel is protected or .teployignore-excluded. +func (r *Rules) IsExcluded(rel string, isDir bool) bool { + return matchesAny(r.protected, rel, isDir) || matchesAny(r.excludes, rel, isDir) +} + +// IsAllowlisted reports whether rel (or an ancestor) matches a `!` line. +func (r *Rules) IsAllowlisted(rel string, isDir bool) bool { + return matchesAny(r.includes, rel, isDir) +} + +// rule is one compiled rsync-style pattern. +type rule struct { + re *regexp.Regexp + anchored bool + dirOnly bool + hasSlash bool + // literal is the pattern itself when it is an anchored path with no + // glob metacharacters — lets an allowlist walk go straight to it. + literal string +} + +func compileRule(p string) (rule, error) { + var r rule + if strings.HasSuffix(p, "/") { + r.dirOnly = true + p = strings.TrimRight(p, "/") + } + if strings.HasPrefix(p, "/") { + r.anchored = true + p = strings.TrimLeft(p, "/") + } + if p == "" { + return r, fmt.Errorf("empty pattern") + } + r.hasSlash = strings.Contains(p, "/") || strings.Contains(p, "**") + if r.anchored && !strings.ContainsAny(p, "*?[\\") { + r.literal = p + } + re, err := regexp.Compile("^" + globToRegexp(p) + "$") + if err != nil { + return r, err + } + r.re = re + return r, nil +} + +// globToRegexp translates an rsync-style glob: `**/` is zero or more +// directories, `**` anything, `*` and `?` stay within a segment, `[...]` +// is a character class (`[!...]` negated), `\x` escapes x. +func globToRegexp(p string) string { + var sb strings.Builder + for i := 0; i < len(p); i++ { + c := p[i] + switch c { + case '*': + if i+1 < len(p) && p[i+1] == '*' { + i++ + for i+1 < len(p) && p[i+1] == '*' { + i++ + } + if i+1 < len(p) && p[i+1] == '/' { + sb.WriteString("(?:.*/)?") + i++ + } else { + sb.WriteString(".*") + } + } else { + sb.WriteString("[^/]*") + } + case '?': + sb.WriteString("[^/]") + case '[': + end := strings.IndexByte(p[i+1:], ']') + if end < 0 { + sb.WriteString(`\[`) + continue + } + class := p[i+1 : i+1+end] + if strings.HasPrefix(class, "!") { + class = "^" + class[1:] + } + sb.WriteString("[" + strings.ReplaceAll(class, `\`, `\\`) + "]") + i += end + 1 + case '\\': + if i+1 < len(p) { + i++ + sb.WriteString(regexp.QuoteMeta(string(p[i]))) + } else { + sb.WriteString(`\\`) + } + default: + sb.WriteString(regexp.QuoteMeta(string(c))) + } + } + return sb.String() +} + +// matchOne tests the pattern against one path s (an entry or ancestor). +func (r rule) matchOne(s string, isDir bool) bool { + if r.dirOnly && !isDir { + return false + } + if r.anchored { + return r.re.MatchString(s) + } + if !r.hasSlash { + return r.re.MatchString(path.Base(s)) + } + // Unanchored with a slash: matches any trailing run of whole segments. + for i := 0; ; { + if r.re.MatchString(s[i:]) { + return true + } + j := strings.IndexByte(s[i:], '/') + if j < 0 { + return false + } + i += j + 1 + } +} + +// matchesAny reports whether rel or any ancestor directory of rel matches +// one of rules — excluding a directory excludes everything beneath it. +func matchesAny(rules []rule, rel string, isDir bool) bool { + if len(rules) == 0 { + return false + } + for i := 0; i < len(rel); i++ { + if rel[i] == '/' { + for _, r := range rules { + if r.matchOne(rel[:i], true) { + return true + } + } + } + } + for _, r := range rules { + if r.matchOne(rel, isDir) { + return true + } } - return patterns, nil + return false } diff --git a/internal/build/source.go b/internal/build/source.go new file mode 100644 index 0000000..61bc75a --- /dev/null +++ b/internal/build/source.go @@ -0,0 +1,492 @@ +package build + +// Source-sync selection (L14). A server build uploads the build context +// over rsync. Until L14 that upload was "the whole directory minus +// DefaultIgnore and .teployignore", so gitignored local-only files rode +// along — live on 2026-09-24 that put teploy.home.yml (an admin password in +// plaintext) and other teploy.*.yml overlays into world-readable build +// directories on the host. The rule now: +// +// transferred = (git-visible ∪ allowlisted) − protected − excluded +// +// - git-visible: tracked files plus untracked files git does not ignore +// (`git ls-files --cached --others --exclude-standard`), so .gitignore, +// .git/info/exclude and the global excludes file all apply. Outside a +// git work tree every entry is visible — there is no .gitignore to +// consult — and Source.GitAware says so for the caller to report. +// - allowlisted: `!pattern` lines in .teployignore re-include what +// .gitignore hides — the explicit door for build outputs a Dockerfile +// COPYs (a locally built dist/). Never implicit. +// - protected: DefaultIgnore. Never transferred; `!` cannot re-include. +// - excluded: the other .teployignore lines. They beat the allowlist, so +// `!/web/dist/` plus `/web/dist/**/*.map` ships dist without maps. +// +// The resolved entry list is handed to rsync (--files-from) AND to the +// provenance fingerprint, so the recorded identity is exactly what the +// build host received. + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" +) + +// Source is the resolved set of entries a sync of Root transfers. +type Source struct { + Root string + // Entries are slash-separated paths relative to Root, sorted: files, + // symlinks and — outside git, where empty directories are part of the + // tree — directories. Inside git, directories are implied by the files. + Entries []string + // GitAware is true when .gitignore was applied (Root is in a git work + // tree). False means every non-protected, non-excluded entry is sent. + GitAware bool + Rules *Rules +} + +// ResolveSource computes what a source sync of dir transfers. +func ResolveSource(dir string) (*Source, error) { + if dir == "" { + dir = "." + } + rules, err := LoadRules(dir) + if err != nil { + return nil, err + } + root := filepath.Clean(dir) + visible, gitAware, err := listVisible(root, "", rules) + if err != nil { + return nil, err + } + set := make(map[string]bool, len(visible)) + for _, rel := range visible { + set[rel] = true + } + if gitAware && len(rules.includes) > 0 { + if err := addAllowlisted(root, rules, set); err != nil { + return nil, err + } + } + entries := make([]string, 0, len(set)) + for rel := range set { + entries = append(entries, rel) + } + sort.Strings(entries) + return &Source{Root: root, Entries: entries, GitAware: gitAware, Rules: rules}, nil +} + +// listVisible lists the admitted entries of root/prefix: through git when +// it is a work tree, by walking otherwise. prefix is the path of this +// directory relative to the source root ("" for the root itself). +func listVisible(root, prefix string, rules *Rules) ([]string, bool, error) { + dir := filepath.Join(root, filepath.FromSlash(prefix)) + inGit, err := isGitWorkTree(dir) + if err != nil { + return nil, false, err + } + if !inGit { + out, err := walkAdmitted(root, prefix, rules, nil) + return out, false, err + } + raw, err := exec.Command("git", "-C", dir, "ls-files", "-z", "--cached", "--others", "--exclude-standard").Output() + if err != nil { + var ee *exec.ExitError + if errors.As(err, &ee) && len(ee.Stderr) > 0 { + return nil, false, fmt.Errorf("listing files with git in %s: %s", dir, strings.TrimSpace(string(ee.Stderr))) + } + return nil, false, fmt.Errorf("listing files with git in %s: %w", dir, err) + } + var out []string + for _, name := range strings.Split(string(raw), "\x00") { + if name == "" { + continue + } + name = strings.TrimSuffix(name, "/") + rel := name + if prefix != "" { + rel = prefix + "/" + name + } + info, err := os.Lstat(filepath.Join(root, filepath.FromSlash(rel))) + if err != nil { + // Tracked but deleted from the work tree: nothing to send. + continue + } + if info.IsDir() { + // A submodule (gitlink) or a nested repository git reports as a + // single entry: list it by its own rules, as the old walk did. + if rules.IsExcluded(rel, true) { + continue + } + var sub []string + var subErr error + if _, statErr := os.Lstat(filepath.Join(root, filepath.FromSlash(rel), ".git")); statErr == nil { + sub, _, subErr = listVisible(root, rel, rules) + } else { + // No repository of its own (an uninitialized submodule): + // nothing git could list, so walk what is there. + sub, subErr = walkAdmitted(root, rel, rules, nil) + } + if err := subErr; err != nil { + return nil, false, err + } + out = append(out, sub...) + continue + } + if rules.IsExcluded(rel, false) { + continue + } + out = append(out, rel) + } + return out, true, nil +} + +// isGitWorkTree reports whether dir is inside a git work tree. Without git +// on PATH, a work tree is detected by a .git entry in dir or an ancestor +// and refused: .gitignore cannot be honored, and silently uploading what +// it hides is the leak L14 closed. +func isGitWorkTree(dir string) (bool, error) { + if _, err := exec.LookPath("git"); err != nil { + abs, absErr := filepath.Abs(dir) + if absErr != nil { + return false, absErr + } + for d := abs; ; d = filepath.Dir(d) { + if _, statErr := os.Lstat(filepath.Join(d, ".git")); statErr == nil { + return false, fmt.Errorf("%s is in a git work tree but git is not on PATH — install git so the source sync can honor .gitignore", dir) + } + if filepath.Dir(d) == d { + return false, nil + } + } + } + out, err := exec.Command("git", "-C", dir, "rev-parse", "--is-inside-work-tree").Output() + if err != nil { + return false, nil + } + return strings.TrimSpace(string(out)) == "true", nil +} + +// walkAdmitted walks root/prefix and returns every entry the rules admit +// (pruning protected/excluded directories). keep, when non-nil, further +// filters entries (the allowlist walk). +func walkAdmitted(root, prefix string, rules *Rules, keep func(rel string, isDir bool) bool) ([]string, error) { + start := filepath.Join(root, filepath.FromSlash(prefix)) + var out []string + err := filepath.WalkDir(start, func(p string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + if p == start && errors.Is(walkErr, fs.ErrNotExist) { + return fs.SkipDir + } + return walkErr + } + if p == root { + return nil + } + relOS, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel := filepath.ToSlash(relOS) + isDir := d.IsDir() + if rules.IsExcluded(rel, isDir) { + if isDir { + return fs.SkipDir + } + return nil + } + if keep != nil && !keep(rel, isDir) { + return nil + } + out = append(out, rel) + return nil + }) + return out, err +} + +// addAllowlisted adds the files and symlinks `!` lines re-include. Anchored +// literal paths are walked directly; any glob forces a full (pruned) walk. +func addAllowlisted(root string, rules *Rules, set map[string]bool) error { + keep := func(rel string, isDir bool) bool { return !isDir && rules.IsAllowlisted(rel, false) } + starts := []string{""} + allLiteral := true + var literals []string + for _, r := range rules.includes { + if r.literal == "" { + allLiteral = false + break + } + literals = append(literals, r.literal) + } + if allLiteral { + starts = literals + } + for _, s := range starts { + // walkAdmitted judges every entry's ancestors too, so a literal + // under a protected or excluded directory still yields nothing. + found, err := walkAdmitted(root, s, rules, keep) + if err != nil { + return fmt.Errorf("resolving .teployignore allowlist: %w", err) + } + for _, rel := range found { + set[rel] = true + } + } + return nil +} + +// Contains reports whether rel is transferred, or — for a directory — +// whether anything beneath it is. +func (s *Source) Contains(rel string) bool { + rel = strings.Trim(rel, "/") + if rel == "" || rel == "." { + return len(s.Entries) > 0 + } + i := sort.SearchStrings(s.Entries, rel) + if i < len(s.Entries) && s.Entries[i] == rel { + return true + } + // Entries are sorted, so the first path under rel+"/" (if any) sorts + // at or after rel; scan forward past siblings that merely share the + // prefix ("dist-old" sorts between "dist" and "dist/"). + for ; i < len(s.Entries); i++ { + e := s.Entries[i] + if strings.HasPrefix(e, rel+"/") { + return true + } + if !strings.HasPrefix(e, rel) { + return false + } + } + return false +} + +// FileList renders Entries as an rsync --from0 --files-from list. +func (s *Source) FileList() []byte { + var b bytes.Buffer + for _, e := range s.Entries { + b.WriteString(e) + b.WriteByte(0) + } + return b.Bytes() +} + +// Fingerprint returns the sha256 fingerprint of the entries under the +// build-context subdirectory sub ("" or "." for the root): every entry's +// path relative to sub, every file's content, every symlink's target and +// every directory (listed, or implied by an entry beneath it), in typed, +// length-prefixed records emitted in sorted order — so no two distinct +// trees collide through framing ambiguity (the TCL-38 discipline). +// Permission bits are ignored (umask stability across machines). The +// fingerprint describes what the build host RECEIVED — a provenance +// identity, not a security boundary. +func (s *Source) Fingerprint(sub string) (string, error) { + sub = strings.Trim(path.Clean("/"+filepath.ToSlash(sub)), "/") + type entry struct { + rel string + kind byte // 'f' file, 'd' directory, 'l' symlink, 's' special + size int64 + digest [32]byte + target string + } + byRel := map[string]entry{} + addDirs := func(rel string) { + for i := 0; i < len(rel); i++ { + if rel[i] == '/' { + d := rel[:i] + if _, ok := byRel[d]; !ok { + byRel[d] = entry{rel: d, kind: 'd'} + } + } + } + } + for _, full := range s.Entries { + rel := full + if sub != "" { + var ok bool + if rel, ok = strings.CutPrefix(full, sub+"/"); !ok { + continue + } + } + p := filepath.Join(s.Root, filepath.FromSlash(full)) + info, err := os.Lstat(p) + if err != nil { + return "", fmt.Errorf("fingerprinting build context: %w", err) + } + addDirs(rel) + switch { + case info.Mode()&fs.ModeSymlink != 0: + target, err := os.Readlink(p) + if err != nil { + return "", err + } + byRel[rel] = entry{rel: rel, kind: 'l', target: target} + case info.IsDir(): + byRel[rel] = entry{rel: rel, kind: 'd'} + case info.Mode().IsRegular(): + digest, size, err := hashFile(p) + if err != nil { + return "", err + } + byRel[rel] = entry{rel: rel, kind: 'f', size: size, digest: digest} + default: + // Sockets, devices and FIFOs cannot be synced as build input; + // record their presence so the fingerprint still moves. + byRel[rel] = entry{rel: rel, kind: 's'} + } + } + entries := make([]entry, 0, len(byRel)) + for _, e := range byRel { + entries = append(entries, e) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].rel < entries[j].rel }) + + h := sha256.New() + // v2: the entry set is the L14 selection (gitignore-aware), not v1's + // walk-minus-excludes — a new version so the two are never compared. + h.Write([]byte("teploy-context-v2\x00")) + var num [8]byte + binary.BigEndian.PutUint64(num[:], uint64(len(entries))) + h.Write(num[:]) + var len8 [8]byte + writeStr := func(s string) { + binary.BigEndian.PutUint64(len8[:], uint64(len(s))) + h.Write(len8[:]) + h.Write([]byte(s)) + } + for _, e := range entries { + h.Write([]byte{e.kind}) + writeStr(e.rel) + switch e.kind { + case 'f': + binary.BigEndian.PutUint64(len8[:], uint64(e.size)) + h.Write(len8[:]) + h.Write(e.digest[:]) + case 'l': + writeStr(e.target) + } + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// CheckDockerfile fails fast when the Dockerfile — or a local path it +// COPYs/ADDs — exists on this machine but would not reach the build host: +// gitignored without a `!` allowlist line, excluded, or protected. The +// remote build would fail on it anyway ("COPY failed: not found"); this +// names the cause and the fix before anything is uploaded. Sources with +// globs, variables, URLs, heredocs or --from stages are not judged. +func (s *Source) CheckDockerfile(contextSub, dockerfile string) error { + sub := strings.Trim(path.Clean("/"+filepath.ToSlash(contextSub)), "/") + if dockerfile == "" { + dockerfile = "Dockerfile" + } + dfRel := strings.Trim(path.Clean("/"+path.Join(sub, filepath.ToSlash(dockerfile))), "/") + data, err := os.ReadFile(filepath.Join(s.Root, filepath.FromSlash(dfRel))) + if err != nil { + return nil // DetectAt already judged presence; nothing to parse + } + if !s.Contains(dfRel) { + return s.notUploaded(dfRel, "the Dockerfile") + } + for _, src := range copySources(data) { + rel := strings.Trim(path.Clean("/"+path.Join(sub, src)), "/") + if rel == "" || rel == sub { + continue + } + if _, err := os.Lstat(filepath.Join(s.Root, filepath.FromSlash(rel))); err != nil { + continue // absent locally: the build's own error is the right one + } + if !s.Contains(rel) { + return s.notUploaded(rel, "the Dockerfile's COPY/ADD source") + } + } + return nil +} + +func (s *Source) notUploaded(rel, what string) error { + info, _ := os.Lstat(filepath.Join(s.Root, filepath.FromSlash(rel))) + isDir := info != nil && info.IsDir() + if s.Rules.IsProtected(rel, isDir) { + return fmt.Errorf("%s %s is protected (env files, teploy config, secrets stores) and is never uploaded to the build host — the image must not depend on it", what, rel) + } + if s.Rules.IsExcluded(rel, isDir) { + return fmt.Errorf("%s %s is excluded by .teployignore, so the remote build cannot see it — remove the exclude", what, rel) + } + suffix := "" + if isDir { + suffix = "/" + } + return fmt.Errorf("%s %s is gitignored, so it is not uploaded to the build host — if the image genuinely needs it (a locally built artifact), allowlist it with a line `!/%s%s` in .teployignore", what, rel, rel, suffix) +} + +// copySources extracts the local source paths of COPY/ADD instructions. +func copySources(dockerfile []byte) []string { + var instructions []string + var cur strings.Builder + for _, line := range strings.Split(string(dockerfile), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + continue + } + if strings.HasSuffix(trimmed, `\`) { + cur.WriteString(strings.TrimSuffix(trimmed, `\`) + " ") + continue + } + cur.WriteString(trimmed) + if s := strings.TrimSpace(cur.String()); s != "" { + instructions = append(instructions, s) + } + cur.Reset() + } + var out []string + for _, ins := range instructions { + fields := strings.Fields(ins) + if len(fields) < 3 { + continue + } + switch strings.ToUpper(fields[0]) { + case "COPY", "ADD": + default: + continue + } + args := fields[1:] + staged := false + for len(args) > 0 && strings.HasPrefix(args[0], "--") { + if strings.HasPrefix(args[0], "--from") { + staged = true + } + args = args[1:] + } + if staged || len(args) == 0 { + continue + } + if rest := strings.Join(args, " "); strings.HasPrefix(rest, "[") { + var arr []string + if json.Unmarshal([]byte(rest), &arr) != nil { + continue + } + args = arr + } + if len(args) < 2 { + continue + } + for _, src := range args[:len(args)-1] { + if strings.ContainsAny(src, "*?[$") || strings.Contains(src, "://") || + strings.HasPrefix(src, "<<") || strings.HasPrefix(src, "git@") { + continue + } + out = append(out, src) + } + } + return out +} diff --git a/internal/build/source_test.go b/internal/build/source_test.go new file mode 100644 index 0000000..e48223b --- /dev/null +++ b/internal/build/source_test.go @@ -0,0 +1,356 @@ +package build + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// gitRepo initializes a hermetic git repository at dir (no global or +// system config, so a developer's core.excludesFile cannot change what the +// test sees) and commits the given tracked files. +func gitRepo(t *testing.T, dir string, tracked map[string]string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + run := func(args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + run("init", "-q") + writeTree(t, dir, tracked) + run("add", "-A") + run("-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "-m", "init") +} + +func resolve(t *testing.T, dir string) *Source { + t.Helper() + src, err := ResolveSource(dir) + if err != nil { + t.Fatalf("ResolveSource: %v", err) + } + return src +} + +func assertEntries(t *testing.T, src *Source, want, notWant []string) { + t.Helper() + have := map[string]bool{} + for _, e := range src.Entries { + have[e] = true + } + for _, w := range want { + if !have[w] { + t.Errorf("%s must be uploaded; entries = %v", w, src.Entries) + } + } + for _, n := range notWant { + if have[n] { + t.Errorf("%s must NOT be uploaded; entries = %v", n, src.Entries) + } + } +} + +// TestResolveSource_GitignoredOverlayNeverUploaded is the L14 regression: +// the live leak was a gitignored teploy.home.yml (admin password in +// plaintext) riding the source sync into a world-readable build dir. +// Gitignored files stay home; teploy config, overlays, env files and +// secrets stores stay home even when git would show them. +func TestResolveSource_GitignoredOverlayNeverUploaded(t *testing.T) { + dir := t.TempDir() + gitRepo(t, dir, map[string]string{ + ".gitignore": "teploy.*.yml\n!teploy.yml\n/dist/\n*.log\n", + "teploy.yml": "app: dash\n", + "Dockerfile": "FROM alpine\nCOPY . .\n", + "main.go": "package main", + "cmd/app/run.go": "package app", + "secrets.go": "package main // source named like a secret is still source", + ".env.example": "KEY=", + "sub/teploy.yml": "app: nested", + "web/src/app.tsx": "export {}", + }) + writeTree(t, dir, map[string]string{ + "teploy.home.yml": "env:\n TEPLOY_DASH_PASSWORD: hunter2\n", // gitignored overlay + "teploy.staging.yml": "env: {}\n", // gitignored overlay + "teploy.prod.yaml": "env: {}\n", // NOT gitignored: protected anyway + ".env": "SECRET=1", + ".env.production": "SECRET=2", + "secrets.env": "TOKEN=3", + "app.secrets.env": "TOKEN=4", + "dist/bundle.js": "built", + "debug.log": "noise", + "notes.md": "untracked, not ignored: uploaded", + "node_modules/x/i.js": "dep", + }) + + src := resolve(t, dir) + if !src.GitAware { + t.Fatal("a git work tree must resolve git-aware") + } + assertEntries(t, src, + []string{"Dockerfile", "main.go", "cmd/app/run.go", "secrets.go", ".gitignore", "notes.md", "sub/teploy.yml", "web/src/app.tsx"}, + []string{ + "teploy.home.yml", "teploy.staging.yml", "teploy.prod.yaml", "teploy.yml", + ".env", ".env.production", ".env.example", "secrets.env", "app.secrets.env", + "dist/bundle.js", "debug.log", "node_modules/x/i.js", + }) +} + +// TestResolveSource_AllowlistReincludesBuildArtifact pins the explicit +// door for a locally built artifact the Dockerfile COPYs (Ship's dist/ and +// web/dist/): `!` lines re-include gitignored paths, .teployignore +// excludes still beat them, and protected files can never be re-included. +func TestResolveSource_AllowlistReincludesBuildArtifact(t *testing.T) { + dir := t.TempDir() + gitRepo(t, dir, map[string]string{ + ".gitignore": "dist/\nweb/dist/\nteploy.*.yml\n.env\n", + ".teployignore": "!/dist/\n!web/dist/\n/web/dist/**/*.map\n!teploy.home.yml\n!.env\n/src\n", + "Dockerfile": "FROM node\nCOPY dist/ dist/\nCOPY web/dist/ web/dist/\n", + "src/index.ts": "export {}", + }) + writeTree(t, dir, map[string]string{ + "dist/index.js": "built", + "dist/nested/chunk.js": "built", + "web/dist/app.js": "built", + "web/dist/app.js.map": "map", + "web/dist/assets/x.js.map": "map", + "teploy.home.yml": "password: hunter2", + ".env": "SECRET=1", + "other/dist/leak.txt": "matches the unanchored !web/dist? no — only dist/", + }) + + src := resolve(t, dir) + assertEntries(t, src, + []string{"Dockerfile", "dist/index.js", "dist/nested/chunk.js", "web/dist/app.js"}, + []string{ + "web/dist/app.js.map", "web/dist/assets/x.js.map", // exclude beats allowlist + "teploy.home.yml", ".env", ".teployignore", // protected beats allowlist + "src/index.ts", // .teployignore exclude of a tracked path + }) + if err := src.CheckDockerfile("", ""); err != nil { + t.Fatalf("allowlisted COPY sources must pass the preflight: %v", err) + } +} + +// TestResolveSource_OutsideGit: no work tree, no .gitignore to consult — +// everything but protected/excluded goes, empty directories included, and +// GitAware reports it so the caller can say so. +func TestResolveSource_OutsideGit(t *testing.T) { + dir := t.TempDir() + writeTree(t, dir, map[string]string{ + "Dockerfile": "FROM alpine", + "app.py": "print(1)", + "teploy.yml": "app: x", + "teploy.home.yml": "password: hunter2", + ".env.local": "SECRET=1", + ".git/config": "not a real repo", + }) + if err := os.MkdirAll(filepath.Join(dir, "empty/dir"), 0o755); err != nil { + t.Fatal(err) + } + // A bare .git directory without git's own layout is not a work tree. + src := resolve(t, dir) + if src.GitAware { + t.Skip("temp dir resolved as a git work tree (unexpected environment)") + } + assertEntries(t, src, + []string{"Dockerfile", "app.py", "empty", "empty/dir"}, + []string{"teploy.yml", "teploy.home.yml", ".env.local", ".git", ".git/config"}) +} + +// TestResolveSource_NestedRepository: a nested repository (or an +// initialized submodule) is listed by its OWN .gitignore, as rsync used to +// send it whole. +func TestResolveSource_NestedRepository(t *testing.T) { + dir := t.TempDir() + gitRepo(t, dir, map[string]string{".gitignore": "*.log\n", "main.go": "package main"}) + nested := filepath.Join(dir, "vendor/lib") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatal(err) + } + gitRepo(t, nested, map[string]string{".gitignore": "out/\n", "lib.go": "package lib"}) + writeTree(t, nested, map[string]string{"out/gen.bin": "built", "teploy.home.yml": "x"}) + + src := resolve(t, dir) + assertEntries(t, src, + []string{"main.go", "vendor/lib/lib.go", "vendor/lib/.gitignore"}, + []string{"vendor/lib/out/gen.bin", "vendor/lib/teploy.home.yml", "vendor/lib/.git"}) +} + +func TestResolveSource_DeletedTrackedFileSkipped(t *testing.T) { + dir := t.TempDir() + gitRepo(t, dir, map[string]string{"a.go": "package a", "b.go": "package a"}) + if err := os.Remove(filepath.Join(dir, "b.go")); err != nil { + t.Fatal(err) + } + assertEntries(t, resolve(t, dir), []string{"a.go"}, []string{"b.go"}) +} + +// The Dockerfile preflight names the cause and the fix before anything is +// uploaded, instead of a remote "COPY failed: not found". +func TestCheckDockerfile(t *testing.T) { + dir := t.TempDir() + gitRepo(t, dir, map[string]string{ + ".gitignore": "dist/\n", + "Dockerfile": strings.Join([]string{ + "FROM node AS build", + "# COPY ignored-in-a-comment/ x/", + "COPY --from=build /out /out", + "COPY --chown=1000:1000 \\", + " package.json \\", + " dist/ /app/", + "ADD https://example.com/x.tgz /tmp/", + "COPY *.json /app/", + `COPY ["package.json", "/app/"]`, + }, "\n"), + "package.json": "{}", + }) + writeTree(t, dir, map[string]string{"dist/index.js": "built"}) + + err := resolve(t, dir).CheckDockerfile("", "") + if err == nil || !strings.Contains(err.Error(), "dist is gitignored") || !strings.Contains(err.Error(), "`!/dist/`") { + t.Fatalf("a gitignored COPY source must fail with the allowlist fix, got %v", err) + } + + // Protected COPY sources are refused with the protected message: an + // allowlist line cannot help, and the image must not need them. + dir2 := t.TempDir() + gitRepo(t, dir2, map[string]string{"Dockerfile": "FROM alpine\nCOPY .env /app/.env\n", ".gitignore": ".env\n"}) + writeTree(t, dir2, map[string]string{".env": "SECRET=1"}) + if err := resolve(t, dir2).CheckDockerfile("", ""); err == nil || !strings.Contains(err.Error(), "protected") { + t.Fatalf("a protected COPY source must be refused as protected, got %v", err) + } + + // Context subdirectory: sources resolve under it. + dir3 := t.TempDir() + gitRepo(t, dir3, map[string]string{".gitignore": "app/build/\n", "app/Dockerfile": "FROM x\nCOPY build/ /b/\nCOPY missing/ /m/\n"}) + writeTree(t, dir3, map[string]string{"app/build/a": "built"}) + if err := resolve(t, dir3).CheckDockerfile("app", ""); err == nil || !strings.Contains(err.Error(), "app/build") { + t.Fatalf("context-relative COPY source must be judged under the context, got %v", err) + } +} + +// Contains must not confuse a sibling sharing a prefix ("dist-old") with +// content under the directory ("dist/"). +func TestSourceContains(t *testing.T) { + s := &Source{Entries: []string{"dist-old/a", "dist.txt", "web/app.js"}} + if s.Contains("dist") { + t.Fatal("dist has no entries beneath it") + } + s.Entries = []string{"dist-old/a", "dist.txt", "dist/x", "web/app.js"} + if !s.Contains("dist") || !s.Contains("dist/") || !s.Contains("web") || !s.Contains("dist.txt") { + t.Fatal("Contains missed an uploaded entry") + } +} + +func TestRuleMatching(t *testing.T) { + cases := []struct { + pattern string + path string + isDir bool + want bool + }{ + {"node_modules", "web/node_modules/x/y.js", false, true}, + {".env.*", "config/.env.production", false, true}, + {".env.*", "config/env.production", false, false}, + {"/teploy.yml", "teploy.yml", false, true}, + {"/teploy.yml", "sub/teploy.yml", false, false}, + {"teploy.*.yml", "teploy.home.yml", false, true}, + {"teploy.*.yml", "deep/teploy.home.yml", false, true}, + {"teploy.*.yml", "teploy.yml", false, false}, + {"/src", "src/index.ts", false, true}, + {"/src", "web/src/index.ts", false, false}, + {"/web/dist/**/*.map", "web/dist/a.js.map", false, true}, + {"/web/dist/**/*.map", "web/dist/assets/a.js.map", false, true}, + {"/web/dist/**/*.map", "web/dist/a.js", false, false}, + {"dist/", "dist", true, true}, + {"dist/", "dist", false, false}, + {"dist/", "dist/a.js", false, true}, + {"foo/bar", "x/foo/bar", false, true}, + {"foo/bar", "x/foo/barn", false, false}, + {"*.secrets.env", "ship.secrets.env", false, true}, + {"secrets.yml", "secrets.yml.go", false, false}, + {"file[0-9].txt", "file7.txt", false, true}, + {"file[!0-9].txt", "file7.txt", false, false}, + {"a?c", "abc", false, true}, + {"a?c", "a/c", false, false}, + } + for _, c := range cases { + r, err := compileRule(c.pattern) + if err != nil { + t.Fatalf("compile %q: %v", c.pattern, err) + } + if got := matchesAny([]rule{r}, c.path, c.isDir); got != c.want { + t.Errorf("pattern %q vs %q (dir=%v) = %v, want %v", c.pattern, c.path, c.isDir, got, c.want) + } + } +} + +// A gitignored file must not move the fingerprint (it is not build input +// any more); an allowlisted one must. +func TestFingerprint_TracksTheSelection(t *testing.T) { + dir := t.TempDir() + gitRepo(t, dir, map[string]string{".gitignore": "cache/\ndist/\n", ".teployignore": "!/dist/\n", "main.go": "package main"}) + writeTree(t, dir, map[string]string{"cache/a": "1", "dist/a": "1"}) + before, err := contextFingerprint(dir) + if err != nil { + t.Fatal(err) + } + writeTree(t, dir, map[string]string{"cache/a": "2", "teploy.home.yml": "changed"}) + if after, _ := contextFingerprint(dir); after != before { + t.Fatal("a gitignored or protected change moved the fingerprint") + } + writeTree(t, dir, map[string]string{"dist/a": "2"}) + if after, _ := contextFingerprint(dir); after == before { + t.Fatal("an allowlisted artifact change must move the fingerprint") + } +} + +// Sync hands rsync exactly the resolved list over --files-from (NUL +// separated) and never the directory wholesale. A fake rsync on PATH +// records the argv and the list it was fed. +func TestSync_SendsOnlyTheResolvedList(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fake") + } + bin := t.TempDir() + record := filepath.Join(bin, "record") + fake := "#!/bin/sh\nprintf '%s\\n' \"$@\" > '" + record + ".args'\ncat > '" + record + ".list'\n" + if err := os.WriteFile(filepath.Join(bin, "rsync"), []byte(fake), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + src := &Source{Root: "/work/app", Entries: []string{"Dockerfile", "dist/a b.js", "main.go"}} + err := Sync(context.Background(), SyncConfig{ + Source: src, RemoteDir: "/deployments/app/meta/att/v1.0123456789abcdef/build", + Host: "192.0.2.1", User: "deploy", LinkDest: "/deployments/app/meta/att/v0.fedcba9876543210/build", + }, os.Stdout, os.Stderr) + if err != nil { + t.Fatalf("Sync: %v", err) + } + args, _ := os.ReadFile(record + ".args") + list, _ := os.ReadFile(record + ".list") + argv := strings.Split(strings.TrimSpace(string(args)), "\n") + joined := strings.Join(argv, " ") + for _, want := range []string{"--from0", "--files-from=-", "--link-dest=/deployments/app/meta/att/v0.fedcba9876543210/build", "/work/app/"} { + if !strings.Contains(joined, want) { + t.Errorf("rsync argv missing %q: %v", want, argv) + } + } + if strings.Contains(joined, "--delete") || strings.Contains(joined, "--exclude") { + t.Errorf("the list is the whole selection — no --delete/--exclude: %v", argv) + } + if got, want := string(list), "Dockerfile\x00dist/a b.js\x00main.go\x00"; got != want { + t.Errorf("files-from list = %q, want %q", got, want) + } +} diff --git a/internal/build/sync.go b/internal/build/sync.go index 57bea00..c752465 100644 --- a/internal/build/sync.go +++ b/internal/build/sync.go @@ -1,6 +1,7 @@ package build import ( + "bytes" "context" "fmt" "io" @@ -12,13 +13,14 @@ import ( // SyncConfig holds parameters for rsyncing source to the server. type SyncConfig struct { - LocalDir string // local source directory - RemoteDir string // remote destination directory - Host string // SSH host (may carry :port; IPv6 literals bracketed automatically) - User string // SSH user - KeyPath string // SSH key path (optional) - Excludes []string // patterns to exclude - AcceptNewHost bool // mirror the control connection's --accept-new policy (see Sync) + // Source is the resolved selection (ResolveSource): rsync transfers + // exactly its entries, from its root — never a directory wholesale. + Source *Source + RemoteDir string // remote destination directory (fresh per attempt) + Host string // SSH host (may carry :port; IPv6 literals bracketed automatically) + User string // SSH user + KeyPath string // SSH key path (optional) + AcceptNewHost bool // mirror the control connection's --accept-new policy (see Sync) // LinkDest is an optional remote basis directory for --link-dest: the // F08 attempt-scoped build contexts are fresh per attempt, so without // a basis every deploy would re-transfer the whole tree. Pointing at @@ -36,21 +38,25 @@ type SyncConfig struct { // policy, which by sync time has verified (and, under --accept-new, // recorded) the host key. func Sync(ctx context.Context, cfg SyncConfig, stdout, stderr io.Writer) error { + if cfg.Source == nil { + return fmt.Errorf("rsync: no resolved source to transfer") + } // rsync re-parses the -e value through a shell — quote each argument // so an identity path containing spaces survives (TCL-52). sshCmd := ssh.ExternalSSHCommand(cfg.Host, cfg.KeyPath, cfg.AcceptNewHost) - // Ensure local dir has trailing slash so rsync copies contents, not the dir itself. - localDir := strings.TrimRight(cfg.LocalDir, "/") + "/" + // Trailing slash: the entry paths are relative to the root's contents. + localDir := strings.TrimRight(cfg.Source.Root, "/") + "/" + // The list is the whole selection (L14): --files-from sends exactly + // the resolved entries and nothing else, so .gitignore'd and protected + // files cannot ride along. No --delete: every attempt's build dir is + // fresh (F08), and rsync's --files-from mode does not recurse into the + // listed directories anyway. args := []string{ - "-az", "--delete", + "-az", "--from0", "--files-from=-", "-e", sshCmd, } - - for _, pattern := range cfg.Excludes { - args = append(args, "--exclude", pattern) - } if cfg.LinkDest != "" { args = append(args, "--link-dest="+cfg.LinkDest) } @@ -59,6 +65,7 @@ func Sync(ctx context.Context, cfg SyncConfig, stdout, stderr io.Writer) error { args = append(args, localDir, remote) cmd := exec.CommandContext(ctx, "rsync", args...) + cmd.Stdin = bytes.NewReader(cfg.Source.FileList()) cmd.Stdout = stdout cmd.Stderr = stderr diff --git a/internal/cli/autodeploy_serve.go b/internal/cli/autodeploy_serve.go index e85fd89..f045026 100644 --- a/internal/cli/autodeploy_serve.go +++ b/internal/cli/autodeploy_serve.go @@ -600,7 +600,10 @@ func triggerAutoDeploy(ctx context.Context, executor ssh.Executor, app, branch, defer state.ReleaseLockFenced(executor, lk, app) lk.StartRenewal(executor) - if _, err := executor.Run(ctx, "mkdir -p "+ssh.ShellQuote(buildDir)); err != nil { + // Owner-only (L14): the checkout is build input other host users have + // no business reading. Only the directory — its files keep their + // checkout modes, which Docker COPY carries into the image. + if _, err := executor.Run(ctx, "mkdir -p "+ssh.ShellQuote(buildDir)+" && chmod 700 "+ssh.ShellQuote(buildDir)); err != nil { return fmt.Errorf("creating build directory: %w", err) } if err := fetchCheckout(ctx, executor, buildDir, branch, commit, out); err != nil { diff --git a/internal/cli/build.go b/internal/cli/build.go index 47a2f00..cce1363 100644 --- a/internal/cli/build.go +++ b/internal/cli/build.go @@ -177,25 +177,9 @@ func runBuild(flags *Flags, version, destination string) error { // incremental. The directory is scratch — the next deploy of this app // prunes it once its hash leaves the protection window. att := releasemeta.MustAttempt(appCfg.App, version) - remoteDir := att.BuildDir() - if _, err := executor.Run(ctx, "mkdir -p "+remoteDir); err != nil { - return fmt.Errorf("creating build directory: %w", err) - } - fmt.Fprintln(out, "Syncing source to server...") - excludes, err := build.LoadIgnore(".") + remoteDir, err := syncAttemptBuildContext(ctx, executor, appCfg, att, buildMode, host, user, key, out, os.Stderr) if err != nil { - return fmt.Errorf("loading ignore rules: %w", err) - } - if err := build.Sync(ctx, build.SyncConfig{ - LocalDir: ".", - RemoteDir: remoteDir, - Host: host, - User: user, - KeyPath: key, - Excludes: excludes, - LinkDest: releasemeta.PreviousAttemptBuildDir(ctx, executor, appCfg.App, att.ID), - }, out, os.Stderr); err != nil { - return fmt.Errorf("syncing source: %w", err) + return err } fmt.Fprintln(out, "Building image on server...") @@ -233,3 +217,41 @@ func reportBuild(flags *Flags, image, version string, built bool) error { } return nil } + +// syncAttemptBuildContext uploads this directory's build context into the +// attempt's private build dir (F08 scoping, L14 selection + modes) and +// returns that dir. The selection honors .gitignore, never sends protected +// files (env files, teploy config and overlays, secrets stores), and fails +// before uploading when the Dockerfile needs something the selection +// leaves out — see internal/build/source.go for the rule. +func syncAttemptBuildContext(ctx context.Context, executor ssh.Executor, appCfg *config.AppConfig, att releasemeta.Attempt, mode build.Mode, host, user, key string, stdout, stderr io.Writer) (string, error) { + src, err := build.ResolveSource(".") + if err != nil { + return "", fmt.Errorf("resolving the build context: %w", err) + } + if mode == build.ModeDockerfile { + if err := src.CheckDockerfile(appCfg.Context, appCfg.Dockerfile); err != nil { + return "", err + } + } + remoteDir := att.BuildDir() + if _, err := executor.Run(ctx, att.MkdirCmd("build")); err != nil { + return "", fmt.Errorf("creating build directory: %w", err) + } + if src.GitAware { + fmt.Fprintf(stdout, "Syncing source to server (%d files; .gitignore honored)...\n", len(src.Entries)) + } else { + fmt.Fprintf(stdout, "Syncing source to server (%d entries; not a git work tree, so only .teployignore and the protected defaults apply)...\n", len(src.Entries)) + } + if err := build.Sync(ctx, build.SyncConfig{ + Source: src, + RemoteDir: remoteDir, + Host: host, + User: user, + KeyPath: key, + LinkDest: releasemeta.PreviousAttemptBuildDir(ctx, executor, appCfg.App, att.ID), + }, stdout, stderr); err != nil { + return "", fmt.Errorf("syncing source: %w", err) + } + return remoteDir, nil +} diff --git a/internal/cli/build_context_test.go b/internal/cli/build_context_test.go new file mode 100644 index 0000000..abe1f30 --- /dev/null +++ b/internal/cli/build_context_test.go @@ -0,0 +1,126 @@ +package cli + +import ( + "bytes" + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/build" + "github.com/useteploy/teploy/internal/config" + "github.com/useteploy/teploy/internal/releasemeta" + "github.com/useteploy/teploy/internal/ssh" +) + +// buildContextRepo makes a git work tree shaped like the Teploy repos that +// leaked on 2026-09-24: a gitignored teploy.home.yml overlay holding a +// password, a gitignored locally built dist/ the Dockerfile COPYs, and a +// fake rsync on PATH that records the file list it is handed. +func buildContextRepo(t *testing.T, teployignore string) (listFile string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not on PATH") + } + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + dir := t.TempDir() + files := map[string]string{ + ".gitignore": "teploy.*.yml\n!teploy.yml\ndist/\n", + "teploy.yml": "app: dash\n", + "Dockerfile": "FROM node\nCOPY dist/ /app/dist/\nCOPY main.js /app/\n", + "main.js": "console.log(1)", + "teploy.home.yml": "env:\n TEPLOY_DASH_PASSWORD: hunter2\n", + "dist/bundle.js": "built", + } + if teployignore != "" { + files[".teployignore"] = teployignore + } + for rel, content := range files { + p := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + for _, args := range [][]string{{"init", "-q"}, {"add", "-A"}, {"-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "-m", "init"}} { + if out, err := exec.Command("git", append([]string{"-C", dir}, args...)...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + } + t.Chdir(dir) + + bin := t.TempDir() + listFile = filepath.Join(bin, "list") + fake := "#!/bin/sh\ncat > '" + listFile + "'\n" + if err := os.WriteFile(filepath.Join(bin, "rsync"), []byte(fake), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + return listFile +} + +// TestSyncAttemptBuildContext_L14 drives the shared deploy/build upload +// path: the gitignored overlay never reaches rsync, the allowlisted dist/ +// does, and the attempt dir is created owner-only before anything lands. +func TestSyncAttemptBuildContext_L14(t *testing.T) { + listFile := buildContextRepo(t, "!/dist/\n") + mock := ssh.NewMockExecutor("192.0.2.1", + ssh.MockCommand{Match: "mkdir -p /deployments/dash/meta/att/", Output: ""}, + ssh.MockCommand{Match: "ls -1t", Output: ""}, + ) + att := releasemeta.MustAttempt("dash", "v1") + appCfg := &config.AppConfig{App: "dash"} + + var out bytes.Buffer + remoteDir, err := syncAttemptBuildContext(context.Background(), mock, appCfg, att, build.ModeDockerfile, "192.0.2.1", "root", "", &out, &out) + if err != nil { + t.Fatalf("syncAttemptBuildContext: %v\n%s", err, out.String()) + } + if remoteDir != att.BuildDir() { + t.Fatalf("remote dir = %s, want %s", remoteDir, att.BuildDir()) + } + if len(mock.Calls) == 0 || mock.Calls[0] != att.MkdirCmd("build") { + t.Fatalf("first remote call must be the private attempt mkdir, got %v", mock.Calls) + } + raw, err := os.ReadFile(listFile) + if err != nil { + t.Fatal(err) + } + sent := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00") + joined := "|" + strings.Join(sent, "|") + "|" + for _, want := range []string{"Dockerfile", "main.js", "dist/bundle.js", ".gitignore"} { + if !strings.Contains(joined, "|"+want+"|") { + t.Errorf("%s must be uploaded; sent %v", want, sent) + } + } + for _, never := range []string{"teploy.home.yml", "teploy.yml", ".teployignore"} { + if strings.Contains(joined, "|"+never+"|") { + t.Errorf("%s must never be uploaded; sent %v", never, sent) + } + } + if !strings.Contains(out.String(), ".gitignore honored") { + t.Errorf("the sync line must say .gitignore is honored: %s", out.String()) + } +} + +// Without the allowlist line the Dockerfile's gitignored COPY source is +// refused before a single remote command runs, naming the fix. +func TestSyncAttemptBuildContext_PreflightNamesTheAllowlistFix(t *testing.T) { + buildContextRepo(t, "") + mock := ssh.NewMockExecutor("192.0.2.1") + att := releasemeta.MustAttempt("dash", "v1") + + var out bytes.Buffer + _, err := syncAttemptBuildContext(context.Background(), mock, &config.AppConfig{App: "dash"}, att, build.ModeDockerfile, "192.0.2.1", "root", "", &out, &out) + if err == nil || !strings.Contains(err.Error(), "`!/dist/`") { + t.Fatalf("want the allowlist fix in the error, got %v", err) + } + if len(mock.Calls) != 0 { + t.Fatalf("preflight must fail before any remote effect, got %v", mock.Calls) + } +} diff --git a/internal/cli/deploy.go b/internal/cli/deploy.go index f4cb0ef..fbe8b18 100644 --- a/internal/cli/deploy.go +++ b/internal/cli/deploy.go @@ -459,26 +459,9 @@ func deployAppConfig(flags *Flags, appCfg *config.AppConfig, serverName, image, // previous attempt's build dir as rsync's --link-dest basis so // the fresh directory still transfers incrementally and // hardlink-shares unchanged files. - remoteDir := att.BuildDir() - if _, err := executor.Run(ctx, "mkdir -p "+remoteDir); err != nil { - return fmt.Errorf("creating build directory: %w", err) - } - - fmt.Println("Syncing source to server...") - excludes, err := build.LoadIgnore(".") + remoteDir, err := syncAttemptBuildContext(ctx, executor, appCfg, att, buildMode, host, user, key, os.Stdout, os.Stderr) if err != nil { - return fmt.Errorf("loading ignore rules: %w", err) - } - if err := build.Sync(ctx, build.SyncConfig{ - LocalDir: ".", - RemoteDir: remoteDir, - Host: host, - User: user, - KeyPath: key, - Excludes: excludes, - LinkDest: releasemeta.PreviousAttemptBuildDir(ctx, executor, appCfg.App, att.ID), - }, os.Stdout, os.Stderr); err != nil { - return fmt.Errorf("syncing source: %w", err) + return err } fmt.Println("Building image on server...") diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 9c18b95..b27aded 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -110,8 +110,9 @@ func newDoctorCmd(flags *Flags, version string) *cobra.Command { "app's server (or --server), remote Docker, remote disk headroom, registry\n" + "reachability for the configured image (auth failures distinguished from\n" + "unreachable), the Caddy admin API (caddy ingress only), teploy version\n" + - "compatibility with the server's teploy binary if present, and outstanding\n" + - "release-record repair debt.\n\n" + + "compatibility with the server's teploy binary if present, outstanding\n" + + "release-record repair debt, and secret-bearing files or world-traversable\n" + + "build directories older CLIs left under /deployments (listed, never deleted).\n\n" + "Exit codes: 0 when no check fails, 1 when any check fails, 2 never (that\n" + "code stays drift --exit-code's CI signal).", Args: cobra.NoArgs, @@ -196,7 +197,7 @@ func doctorRun(ctx context.Context, deps doctorDeps, flags *Flags, serverName st if executor == nil { const sshDown = "restore SSH connectivity (see the ssh check above), then re-run teploy doctor" - for _, name := range []string{"docker", "disk", "registry", "caddy", "compatibility", "repair-debt"} { + for _, name := range []string{"docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure"} { skipFail(name, "SSH unreachable", sshDown) } } else { @@ -212,6 +213,7 @@ func doctorRun(ctx context.Context, deps doctorDeps, flags *Flags, serverName st } report.Checks = append(report.Checks, doctorCompatCheck(ctx, deps, executor)) report.Checks = append(report.Checks, doctorRepairDebtCheck(ctx, executor, appCfg)) + report.Checks = append(report.Checks, doctorSecretExposureCheck(ctx, executor)) } report.summarize() @@ -616,3 +618,97 @@ func doctorRepairDebtCheck(ctx context.Context, exec ssh.Executor, appCfg *confi Remediation: "re-run teploy deploy (the reconciler repairs the record before its own work); teploy status shows the same debt", } } + +// doctorSecretFilesProbe lists secret-bearing files where teploy uploads +// operator source: build contexts (every path with a /build/ segment — +// attempt contexts, the legacy shared dir, copies of either) and static +// releases, which Caddy serves to the public. The names are the protected +// upload set (build.DefaultIgnore) minus conventional templates; base +// teploy.yml is left out because autodeploy checkouts legitimately hold +// it. Read-only: find -print (the probe caps the output with head). +const doctorSecretFilesFind = "find /deployments \\( -name .git -o -name node_modules \\) -prune -o -type f " + + "\\( -path '*/build/*' -o -path '/deployments/*/releases/*' \\) " + + "\\( -name 'teploy.*.yml' -o -name 'teploy.*.yaml' -o -name 'teploy.*.toml' -o -name .env -o -name '.env.*' " + + "-o -name .secrets -o -name secrets.yml -o -name secrets.yaml -o -name secrets.json -o -name secrets.toml " + + "-o -name secrets.env -o -name '*.secrets.env' \\) " + + "! -name 'teploy.example.*' ! -name .env.example ! -name .env.sample ! -name .env.template " + + "-print" + +const doctorSecretFilesProbe = doctorSecretFilesFind + " 2>/dev/null | head -n 201" + +// doctorOpenBuildDirsProbe lists build-context directories other host +// users can traverse (o+x): the legacy shared build dir and the attempt +// root. Read-only. +const doctorOpenBuildDirsProbe = "find /deployments -mindepth 2 -maxdepth 3 -type d " + + "\\( -path '/deployments/*/build' -o -path '/deployments/*/meta/att' \\) -perm -001 -print 2>/dev/null | head -n 201" + +// doctorSecretExposureCheck finds what teploy CLIs before L14 left behind: +// they uploaded the whole source tree, gitignored files included, so +// teploy..yml overlays and env files (live: an admin password) sat +// in build directories other host users could read. A secret-bearing file +// is a fail — it stays on disk until someone deletes it, and whatever it +// held must be rotated; an open directory alone is a warn. Nothing is +// deleted or chmodded here: the remediation names the commands. +func doctorSecretExposureCheck(ctx context.Context, exec ssh.Executor) doctorCheck { + files, err := exec.Run(ctx, doctorSecretFilesProbe) + if err != nil { + return doctorCheck{ + Name: "secret-exposure", Result: doctorWarn, Detail: "could not scan /deployments: " + err.Error(), + Remediation: "check that the SSH user can read /deployments, then re-run teploy doctor", + } + } + dirs, err := exec.Run(ctx, doctorOpenBuildDirsProbe) + if err != nil { + return doctorCheck{ + Name: "secret-exposure", Result: doctorWarn, Detail: "could not scan /deployments: " + err.Error(), + Remediation: "check that the SSH user can read /deployments, then re-run teploy doctor", + } + } + secretPaths, openDirs := doctorLines(files), doctorLines(dirs) + if len(secretPaths) > 0 { + return doctorCheck{ + Name: "secret-exposure", Result: doctorFail, + Detail: fmt.Sprintf("%s secret-bearing file(s) in uploaded build contexts or static releases (teploy before L14 uploaded gitignored files): %s", + doctorCount(secretPaths), doctorSample(secretPaths)), + Remediation: "on the server, list every file with `" + doctorSecretFilesFind + "`, review, delete each with rm, " + + "and rotate every credential they held — current teploy never uploads them. Tighten open build dirs with chmod 700 on the directory only " + + "(never chmod -R a build context: Docker COPY carries its file modes into images)", + } + } + if len(openDirs) > 0 { + return doctorCheck{ + Name: "secret-exposure", Result: doctorWarn, + Detail: fmt.Sprintf("no secret-bearing files found, but %s build director(ies) are traversable by other host users: %s", + doctorCount(openDirs), doctorSample(openDirs)), + Remediation: "chmod 700 each listed directory (the directory only — never chmod -R a build context: Docker COPY carries its file modes into images); deploys from current teploy create them 0700", + } + } + return doctorCheck{Name: "secret-exposure", Result: doctorOK, Detail: "no secret-bearing files in build contexts or static releases; build directories are private"} +} + +func doctorLines(out string) []string { + var lines []string + for _, l := range strings.Split(out, "\n") { + if l = strings.TrimSpace(l); l != "" { + lines = append(lines, l) + } + } + return lines +} + +// doctorCount renders a probe's hit count; the probes stop at 201 lines, +// so 201 means "more than 200". +func doctorCount(lines []string) string { + if len(lines) > 200 { + return "200+" + } + return strconv.Itoa(len(lines)) +} + +func doctorSample(lines []string) string { + const shown = 5 + if len(lines) <= shown { + return strings.Join(lines, ", ") + } + return strings.Join(lines[:shown], ", ") + fmt.Sprintf(" (+%d more)", len(lines)-shown) +} diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index 59d190b..957ee7e 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -18,7 +19,7 @@ import ( // against (adding checks is additive at the end; renaming/removing is a // machine-interface bump). var doctorCheckOrder = []string{ - "git", "config", "ssh", "docker", "disk", "registry", "caddy", "compatibility", "repair-debt", + "git", "config", "ssh", "docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure", } // doctorHappyMock registers every remote read a fully healthy target @@ -30,6 +31,7 @@ func doctorHappyMock() *ssh.MockExecutor { ssh.MockCommand{Match: "docker manifest inspect", Output: `{"schemaVersion":2}`}, ssh.MockCommand{Match: "docker exec caddy", Output: `{}`}, ssh.MockCommand{Match: "'/deployments/.bin/teploy' version", Output: "teploy v0.1.37"}, + ssh.MockCommand{Match: "find /deployments ", Output: ""}, ) } @@ -78,8 +80,8 @@ func TestDoctorAllChecksPass(t *testing.T) { if ex == nil { t.Fatal("expected an executor back") } - if report.Summary != (doctorSummary{OK: 9, Warn: 0, Fail: 0}) { - t.Fatalf("summary = %+v, want 9 ok", report.Summary) + if report.Summary != (doctorSummary{OK: 10, Warn: 0, Fail: 0}) { + t.Fatalf("summary = %+v, want 10 ok", report.Summary) } if len(report.Checks) != len(doctorCheckOrder) { t.Fatalf("got %d checks, want %d", len(report.Checks), len(doctorCheckOrder)) @@ -126,8 +128,8 @@ func TestDoctorJSONStableShape(t *testing.T) { t.Fatalf("machine_interface = %v, want %d", doc["machine_interface"], MachineInterface) } checks, ok := doc["checks"].([]any) - if !ok || len(checks) != 9 { - t.Fatalf("checks = %#v, want 9 entries", doc["checks"]) + if !ok || len(checks) != 10 { + t.Fatalf("checks = %#v, want 10 entries", doc["checks"]) } for i, raw := range checks { check, ok := raw.(map[string]any) @@ -178,6 +180,7 @@ func TestDoctorHumanTable(t *testing.T) { ssh.MockCommand{Match: "docker manifest inspect", Output: `{}`}, ssh.MockCommand{Match: "docker exec caddy", Output: `{}`}, ssh.MockCommand{Match: "'/deployments/.bin/teploy' version", Output: "teploy v0.1.37"}, + ssh.MockCommand{Match: "find /deployments ", Output: ""}, ) report, _ := doctorRun(context.Background(), doctorTestDeps(mock), &Flags{}, "", doctorTestApp(), nil) var out bytes.Buffer @@ -191,7 +194,7 @@ func TestDoctorHumanTable(t *testing.T) { if !strings.Contains(rendered, "fix:") { t.Fatalf("table missing the remediation line:\n%s", rendered) } - if !strings.Contains(rendered, "Summary: 8 ok, 0 warn, 1 fail") { + if !strings.Contains(rendered, "Summary: 9 ok, 0 warn, 1 fail") { t.Fatalf("table summary line wrong:\n%s", rendered) } assertDoctorReadOnlyCalls(t, mock.Calls) @@ -312,7 +315,7 @@ func TestDoctorSSHUnreachable(t *testing.T) { } } // Every remote check is skipped-with-fail, naming SSH as the reason. - for _, name := range []string{"docker", "disk", "registry", "caddy", "compatibility", "repair-debt"} { + for _, name := range []string{"docker", "disk", "registry", "caddy", "compatibility", "repair-debt", "secret-exposure"} { check := doctorFindCheck(t, report, name) if check.Result != "fail" || !strings.Contains(check.Detail, "SSH unreachable") { t.Fatalf("%s check = %+v, want fail/skipped (SSH unreachable)", name, check) @@ -664,7 +667,7 @@ func TestDoctorEndToEndAllOK(t *testing.T) { if err := runDoctor(doctorTestDeps(mock), &Flags{}, "", &out); err != nil { t.Fatalf("runDoctor: %v", err) } - if !strings.Contains(out.String(), "Summary: 9 ok, 0 warn, 0 fail") { + if !strings.Contains(out.String(), "Summary: 10 ok, 0 warn, 0 fail") { t.Fatalf("human report wrong:\n%s", out.String()) } assertDoctorReadOnlyCalls(t, mock.Calls) @@ -681,6 +684,7 @@ func assertDoctorReadOnlyCalls(t *testing.T, calls []string) { "df ", "'/deployments/.bin/teploy' version", "if [ ! -e '/deployments/", + "find /deployments ", } for _, call := range calls { allowed := false @@ -695,3 +699,74 @@ func assertDoctorReadOnlyCalls(t *testing.T, calls []string) { } } } + +// The L14 cleanup surface: secret-bearing files older CLIs uploaded into +// build contexts are a fail listing the paths; world-traversable build +// dirs alone are a warn; a clean host is ok. Every command is read-only +// (find piped to head) and nothing is deleted. +func TestDoctorSecretExposureCheck(t *testing.T) { + ctx := context.Background() + t.Run("clean host", func(t *testing.T) { + mock := ssh.NewMockExecutor("h", ssh.MockCommand{Match: "find /deployments ", Output: ""}) + if check := doctorSecretExposureCheck(ctx, mock); check.Result != "ok" { + t.Fatalf("check = %+v, want ok", check) + } + assertDoctorReadOnlyCalls(t, mock.Calls) + }) + t.Run("leaked overlay fails with the paths", func(t *testing.T) { + mock := ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "find /deployments \\( -name .git", Output: "/deployments/dash/meta/att/v1.0123456789abcdef/build/teploy.home.yml\n/deployments/dash/build/.env\n"}, + ssh.MockCommand{Match: "find /deployments -mindepth 2", Output: "/deployments/dash/build\n"}, + ) + check := doctorSecretExposureCheck(ctx, mock) + if check.Result != "fail" { + t.Fatalf("check = %+v, want fail", check) + } + for _, frag := range []string{"2 secret-bearing", "teploy.home.yml", "/deployments/dash/build/.env"} { + if !strings.Contains(check.Detail, frag) { + t.Fatalf("detail must carry %q: %s", frag, check.Detail) + } + } + for _, frag := range []string{"rotate", "never chmod -R", "find /deployments"} { + if !strings.Contains(check.Remediation, frag) { + t.Fatalf("remediation must carry %q: %s", frag, check.Remediation) + } + } + assertDoctorReadOnlyCalls(t, mock.Calls) + for _, call := range mock.Calls { + if strings.Contains(call, "-delete") || strings.Contains(call, "chmod") || strings.Contains(call, "xargs") || strings.Contains(call, " rm ") { + t.Fatalf("doctor must never purge or chmod: %q", call) + } + } + }) + t.Run("open build dir alone warns", func(t *testing.T) { + mock := ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "find /deployments \\( -name .git", Output: ""}, + ssh.MockCommand{Match: "find /deployments -mindepth 2", Output: "/deployments/ship/meta/att\n"}, + ) + check := doctorSecretExposureCheck(ctx, mock) + if check.Result != "warn" || !strings.Contains(check.Detail, "/deployments/ship/meta/att") { + t.Fatalf("check = %+v, want warn naming the dir", check) + } + }) + t.Run("scan failure warns", func(t *testing.T) { + mock := ssh.NewMockExecutor("h", ssh.MockCommand{Match: "find /deployments ", Err: errors.New("ssh: connection lost")}) + if check := doctorSecretExposureCheck(ctx, mock); check.Result != "warn" { + t.Fatalf("check = %+v, want warn", check) + } + }) + t.Run("count caps at 200+", func(t *testing.T) { + var many strings.Builder + for i := 0; i < 201; i++ { + fmt.Fprintf(&many, "/deployments/a/build/x%d/.env\n", i) + } + mock := ssh.NewMockExecutor("h", + ssh.MockCommand{Match: "find /deployments \\( -name .git", Output: many.String()}, + ssh.MockCommand{Match: "find /deployments -mindepth 2", Output: ""}, + ) + check := doctorSecretExposureCheck(ctx, mock) + if !strings.Contains(check.Detail, "200+ secret-bearing") || !strings.Contains(check.Detail, "(+196 more)") { + t.Fatalf("detail = %s", check.Detail) + } + }) +} diff --git a/internal/cli/envfile.go b/internal/cli/envfile.go index 1ce9953..2d922c2 100644 --- a/internal/cli/envfile.go +++ b/internal/cli/envfile.go @@ -133,7 +133,7 @@ func buildContainerEnvFiles(ctx context.Context, executor ssh.Executor, app stri return nil, fmt.Errorf("buildContainerEnvFiles requires a deploy attempt (F08) — the env file is attempt-scoped") } path := att.EnvFile() - if _, err := executor.Run(ctx, "mkdir -p "+ssh.ShellQuote(att.Dir())); err != nil { + if _, err := executor.Run(ctx, att.MkdirCmd()); err != nil { return nil, fmt.Errorf("creating attempt directory: %w", err) } if err := executor.Upload(ctx, strings.NewReader(sb.String()), path, "0600"); err != nil { diff --git a/internal/cli/provenance.go b/internal/cli/provenance.go index 085f016..113d206 100644 --- a/internal/cli/provenance.go +++ b/internal/cli/provenance.go @@ -72,9 +72,11 @@ func resolveDeployProvenance(ctx context.Context, exec ssh.Executor, out io.Writ if appCfg.Context != "" && appCfg.Context != "." { contextDir = filepath.Join(sourceRoot, appCfg.Context) } - if excludes, err := build.LoadIgnore(sourceRoot); err != nil { - fmt.Fprintf(out, "Warning: could not load the ignore rules for the context fingerprint: %v\n", err) - } else if fp, err := build.ContextFingerprint(contextDir, excludes); err != nil { + // The fingerprint covers exactly the selection a server build uploads + // (build.ResolveSource), restricted to the configured context. + if src, err := build.ResolveSource(sourceRoot); err != nil { + fmt.Fprintf(out, "Warning: could not resolve the build context for the fingerprint: %v\n", err) + } else if fp, err := src.Fingerprint(appCfg.Context); err != nil { fmt.Fprintf(out, "Warning: could not fingerprint the build context: %v\n", err) } else { prov.ContextFingerprint = fp diff --git a/internal/cli/singledeploy.go b/internal/cli/singledeploy.go index 4582b93..e464799 100644 --- a/internal/cli/singledeploy.go +++ b/internal/cli/singledeploy.go @@ -101,26 +101,9 @@ func (s *singleServerDeployer) deployApp(ctx context.Context, appCfg *config.App // Attempt-scoped build context (F08): a fresh directory per // (release, attempt), with the previous attempt's build dir as // rsync's --link-dest basis so transfer stays incremental. - remoteDir := att.BuildDir() - if _, err := s.exec.Run(ctx, "mkdir -p "+remoteDir); err != nil { - return fmt.Errorf("creating build directory: %w", err) - } - - fmt.Fprintln(s.out, "Syncing source to server...") - excludes, err := build.LoadIgnore(".") + remoteDir, err := syncAttemptBuildContext(ctx, s.exec, appCfg, att, buildMode, s.exec.Host(), s.exec.User(), s.keyPath, s.out, s.out) if err != nil { - return fmt.Errorf("loading ignore rules: %w", err) - } - if err := build.Sync(ctx, build.SyncConfig{ - LocalDir: ".", - RemoteDir: remoteDir, - Host: s.exec.Host(), - User: s.exec.User(), - KeyPath: s.keyPath, - Excludes: excludes, - LinkDest: releasemeta.PreviousAttemptBuildDir(ctx, s.exec, appCfg.App, att.ID), - }, s.out, s.out); err != nil { - return fmt.Errorf("syncing source: %w", err) + return err } fmt.Fprintln(s.out, "Building image on server...") diff --git a/internal/deploy/deploy.go b/internal/deploy/deploy.go index 554177c..98c5eef 100644 --- a/internal/deploy/deploy.go +++ b/internal/deploy/deploy.go @@ -463,7 +463,7 @@ func (d *Deployer) DeployFenced(ctx context.Context, cfg Config, lk *state.Lock) if prev := releasemeta.PreviousAttemptAssetsDir(ctx, d.exec, cfg.App, att.ID); prev != "" { seed = prev } - seedCmd := "mkdir -p " + ssh.ShellQuote(assetDir) + seedCmd := att.MkdirCmd("assets") if seed != "" { seedCmd += " && cp -a " + ssh.ShellQuote(seed+"/.") + " " + ssh.ShellQuote(assetDir+"/") } diff --git a/internal/deploy/deploy_test.go b/internal/deploy/deploy_test.go index 9c5013e..fb2499a 100644 --- a/internal/deploy/deploy_test.go +++ b/internal/deploy/deploy_test.go @@ -1193,7 +1193,7 @@ func TestDeploy_AssetBridging(t *testing.T) { // 4. Find port. ssh.MockCommand{Match: "ss -tln", Output: ssOutput}, // 5. Asset bridging: attempt-scoped tree (A15) + create/cp extraction. - ssh.MockCommand{Match: "mkdir -p '/deployments/myapp/meta/att/abc123.", Output: ""}, + ssh.MockCommand{Match: "mkdir -p /deployments/myapp/meta/att/abc123.", Output: ""}, ssh.MockCommand{Match: "ls -1t /deployments/myapp/meta/att", Output: ""}, ssh.MockCommand{Match: "docker rm -f 'teploy-assets-", Output: ""}, ssh.MockCommand{Match: "docker create --name 'teploy-assets-", Output: "extractcontainer"}, @@ -1290,7 +1290,7 @@ func TestDeploy_AssetBridgingCustomKeepDays(t *testing.T) { ssh.MockCommand{Match: "if [ ! -e '/deployments/myapp/state' ]", Output: "absent"}, ssh.MockCommand{Match: "ss -tln", Output: ssOutput}, ssh.MockCommand{Match: "mkdir -p '/deployments/myapp/assets'", Output: ""}, - ssh.MockCommand{Match: "mkdir -p '/deployments/myapp/meta/att/abc123.", Output: ""}, + ssh.MockCommand{Match: "mkdir -p /deployments/myapp/meta/att/abc123.", Output: ""}, ssh.MockCommand{Match: "ls -1t /deployments/myapp/meta/att", Output: ""}, ssh.MockCommand{Match: "docker rm -f 'teploy-assets-", Output: ""}, ssh.MockCommand{Match: "docker create --name 'teploy-assets-", Output: "extractcontainer"}, diff --git a/internal/deploy/journal.go b/internal/deploy/journal.go index f72ccf2..2f6652f 100644 --- a/internal/deploy/journal.go +++ b/internal/deploy/journal.go @@ -108,7 +108,7 @@ func (d *Deployer) persistPredecessorSnapshot(ctx context.Context, att releaseme // The path segments are grammar-validated (app via releasemeta's // ValidateName, hash via validHash, random hex id), so — like // releasemeta.Write's own meta mkdir — the path needs no quoting. - if _, err := d.exec.Run(ctx, "mkdir -p "+att.Dir()); err != nil { + if _, err := d.exec.Run(ctx, att.MkdirCmd()); err != nil { return fmt.Errorf("creating the attempt directory: %w", err) } return ssh.UploadAtomic(ctx, d.exec, bytes.NewReader(data), predecessorSnapshotPath(att), "0600") @@ -242,7 +242,7 @@ func (d *Deployer) persistReadinessReceipt(ctx context.Context, att releasemeta. if err != nil { return fmt.Errorf("marshaling the readiness receipt: %w", err) } - if _, err := d.exec.Run(ctx, "mkdir -p "+att.Dir()); err != nil { + if _, err := d.exec.Run(ctx, att.MkdirCmd()); err != nil { return fmt.Errorf("creating the attempt directory: %w", err) } return ssh.UploadAtomic(ctx, d.exec, bytes.NewReader(data), readinessReceiptPath(att), "0600") diff --git a/internal/deploy/static.go b/internal/deploy/static.go index 2c3c66f..9302a38 100644 --- a/internal/deploy/static.go +++ b/internal/deploy/static.go @@ -32,6 +32,7 @@ import ( "strings" "time" + "github.com/useteploy/teploy/internal/build" "github.com/useteploy/teploy/internal/caddy" "github.com/useteploy/teploy/internal/releasemeta" "github.com/useteploy/teploy/internal/ssh" @@ -415,9 +416,17 @@ func (d *StaticDeployer) rsyncTo(ctx context.Context, srcDir, remoteDest string) // must be quoted element-wise so an identity path with spaces // survives (TCL-52). sshCmd := ssh.ExternalSSHCommand(d.exec.Host(), d.SSHKeyPath, hostKeyPolicy == "accept-new") - cmd := exec.CommandContext(ctx, "rsync", - append([]string{"-az", "--delete", "-e", sshCmd}, src, target)..., - ) + // The protected set (env files, teploy config and overlays, secrets + // stores — build.DefaultIgnore) never reaches a static release either + // (L14): a release dir is served to the public, so `source: .` would + // otherwise publish an overlay's credentials over HTTP. .gitignore is + // deliberately NOT applied here — a static source is normally a build + // output, which is gitignored by nature. + args := []string{"-az", "--delete", "-e", sshCmd} + for _, pattern := range build.DefaultIgnore { + args = append(args, "--exclude", pattern) + } + cmd := exec.CommandContext(ctx, "rsync", append(args, src, target)...) cmd.Stdout = d.out cmd.Stderr = d.out return cmd.Run() diff --git a/internal/deploy/static_test.go b/internal/deploy/static_test.go index e1efdec..7350be6 100644 --- a/internal/deploy/static_test.go +++ b/internal/deploy/static_test.go @@ -321,3 +321,32 @@ func TestHashDir_Stable(t *testing.T) { t.Errorf("hash didn't change after content change") } } + +// A static release is served to the public, so the protected set (env +// files, teploy config and overlays, secrets stores) must never be in the +// upload even when `source:` points at the project root (L14). A fake +// rsync on PATH records the argv the deployer hands it. +func TestStaticRsync_ExcludesProtectedFiles(t *testing.T) { + bin := t.TempDir() + record := filepath.Join(bin, "argv") + fake := "#!/bin/sh\nprintf '%s\\n' \"$@\" > '" + record + "'\n" + if err := os.WriteFile(filepath.Join(bin, "rsync"), []byte(fake), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + + d := NewStaticDeployer(ssh.NewMockExecutor("192.0.2.1"), &bytes.Buffer{}) + if err := d.rsyncTo(context.Background(), staticTestSource(t), "/deployments/site/releases/abc.tmp"); err != nil { + t.Fatalf("rsyncTo: %v", err) + } + raw, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + argv := "\n" + string(raw) + for _, pattern := range []string{".env", ".env.*", "teploy.*.yml", "/teploy.yml", "secrets.env"} { + if !strings.Contains(argv, "\n--exclude\n"+pattern+"\n") { + t.Errorf("static rsync must exclude %q; argv:%s", pattern, argv) + } + } +} diff --git a/internal/releasemeta/attempt.go b/internal/releasemeta/attempt.go index dc8a9c9..00ca9d9 100644 --- a/internal/releasemeta/attempt.go +++ b/internal/releasemeta/attempt.go @@ -112,6 +112,27 @@ func (a Attempt) Dir() string { // BuildDir is where the attempt's rsync'd build context lives. func (a Attempt) BuildDir() string { return a.Dir() + "/build" } +// MkdirCmd is the shell command every writer of the attempt's artifacts +// runs first (L14): create the attempt directory (plus any subdirectories +// named) and make it owner-only. The attempt dir holds the build context, +// resolved env file and receipts; at 0700 nothing inside is reachable by +// other host users whatever the modes beneath it — which matters because +// rsync -a keeps the operator's local modes (usually 0644) on the build +// context, and those modes must stay as they are: Docker COPY carries them +// into the image, where a non-root process has to read them. The attempt +// root is tightened too (hides attempt names, closes dirs older CLIs left +// at 0755), best-effort only there: a root owned by another account is not +// ours to chmod, and the attempt dir alone already seals the contents. +// Paths are grammar-validated (NewAttempt), so they need no quoting. +func (a Attempt) MkdirCmd(subdirs ...string) string { + dirs := []string{a.Dir()} + for _, s := range subdirs { + dirs = append(dirs, a.Dir()+"/"+s) + } + return "mkdir -p " + strings.Join(dirs, " ") + " && chmod 700 " + a.Dir() + + " && { chmod 700 " + attemptRoot(a.App) + " 2>/dev/null || true; }" +} + // EnvFile is the attempt's resolved container env file (docker --env-file). func (a Attempt) EnvFile() string { return a.Dir() + "/env" } diff --git a/internal/releasemeta/attempt_mkdir_test.go b/internal/releasemeta/attempt_mkdir_test.go new file mode 100644 index 0000000..0998c2b --- /dev/null +++ b/internal/releasemeta/attempt_mkdir_test.go @@ -0,0 +1,74 @@ +package releasemeta + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestAttemptMkdirCmd_PrivateModes runs the real command in a real shell +// (with /deployments rebased onto a temp dir) and checks the modes on disk: +// the attempt dir and the attempt root end up 0700 — including a root an +// older CLI left at 0755 — so nothing a sync writes beneath them (the build +// context keeps the operator's 0644 modes on purpose) is reachable by +// other host users (L14). +func TestAttemptMkdirCmd_PrivateModes(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell + modes") + } + base := t.TempDir() + att := Attempt{App: "dash", Hash: "v1", ID: "0123456789abcdef"} + + // Pre-existing world-traversable root, as older CLIs created it. + legacyRoot := filepath.Join(base, "dash/meta/att") + if err := os.MkdirAll(legacyRoot, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Chmod(legacyRoot, 0o755); err != nil { + t.Fatal(err) + } + + cmd := strings.ReplaceAll(att.MkdirCmd("build"), deploymentsDir, base) + if out, err := exec.Command("sh", "-c", cmd).CombinedOutput(); err != nil { + t.Fatalf("%s: %v\n%s", cmd, err, out) + } + + for _, dir := range []string{ + filepath.Join(base, "dash/meta/att"), + filepath.Join(base, "dash/meta/att/v1.0123456789abcdef"), + } { + info, err := os.Stat(dir) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o700 { + t.Errorf("%s mode = %o, want 700", dir, perm) + } + } + if info, err := os.Stat(filepath.Join(base, "dash/meta/att/v1.0123456789abcdef/build")); err != nil || !info.IsDir() { + t.Fatalf("build subdir not created: %v", err) + } + + // Idempotent: a second writer of the same attempt (env file, receipts) + // runs the same command without error. + if out, err := exec.Command("sh", "-c", cmd).CombinedOutput(); err != nil { + t.Fatalf("rerun: %v\n%s", err, out) + } +} + +// The command string is what every attempt-artifact writer runs; pin its +// shape so a refactor cannot quietly drop the chmod. +func TestAttemptMkdirCmd_Shape(t *testing.T) { + att := Attempt{App: "ship", Hash: "abc123", ID: "0123456789abcdef"} + got := att.MkdirCmd() + want := "mkdir -p /deployments/ship/meta/att/abc123.0123456789abcdef && chmod 700 /deployments/ship/meta/att/abc123.0123456789abcdef && { chmod 700 /deployments/ship/meta/att 2>/dev/null || true; }" + if got != want { + t.Fatalf("MkdirCmd() =\n%s\nwant\n%s", got, want) + } + if !strings.HasPrefix(att.MkdirCmd("build"), "mkdir -p /deployments/ship/meta/att/abc123.0123456789abcdef /deployments/ship/meta/att/abc123.0123456789abcdef/build && ") { + t.Fatalf("MkdirCmd(build) = %s", att.MkdirCmd("build")) + } +} diff --git a/internal/releasemeta/provenance.go b/internal/releasemeta/provenance.go index 712123d..73a0299 100644 --- a/internal/releasemeta/provenance.go +++ b/internal/releasemeta/provenance.go @@ -114,7 +114,7 @@ func WriteAttemptProvenance(ctx context.Context, exec ssh.Executor, att Attempt, if err != nil { return fmt.Errorf("marshaling provenance: %w", err) } - if _, err := exec.Run(ctx, "mkdir -p "+att.Dir()); err != nil { + if _, err := exec.Run(ctx, att.MkdirCmd()); err != nil { return fmt.Errorf("creating the attempt directory: %w", err) } return ssh.UploadAtomic(ctx, exec, bytes.NewReader(data), AttemptProvenancePath(att), "0600")