package artifact import ( "errors" "fmt" "os" "path/filepath" "testing" "bugabinga.net/luci/internal/ciconfig" ) func TestCollectStagesImmutableFiles(t *testing.T) { root, workspace := t.TempDir(), t.TempDir() if err := os.MkdirAll(filepath.Join(workspace, "dist"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "dist", "result.txt"), []byte("ok"), 0o644); err != nil { t.Fatal(err) } store := Store{DataDir: root} if err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "build", Paths: []string{"dist"}}); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "dist", "result.txt"), []byte("changed"), 0o644); err != nil { t.Fatal(err) } file, err := store.Open("123", "001/build/dist/result.txt") if err != nil { t.Fatal(err) } defer file.Close() data, err := os.ReadFile(file.Name()) if err != nil { t.Fatal(err) } if string(data) != "ok" { t.Fatalf("stored = %q", data) } if _, err := store.Open("123", "../build"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("traversal error = %v", err) } } func TestCollectRequiredMissingAndOversizedFilesStayInvisible(t *testing.T) { root, workspace := t.TempDir(), t.TempDir() store := Store{DataDir: root} if err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "missing", Paths: []string{"none"}, Required: true}); err == nil { t.Fatal("missing required artifact accepted") } if err := os.WriteFile(filepath.Join(workspace, "large"), make([]byte, maxFile+1), 0o644); err != nil { t.Fatal(err) } if err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "large", Paths: []string{"large"}}); err == nil { t.Fatal("large artifact accepted") } for _, name := range []string{"missing", "large"} { if _, err := os.Stat(filepath.Join(root, "artifacts", "123", "001", name)); !errors.Is(err, os.ErrNotExist) { t.Fatalf("partial %q visible: %v", name, err) } } } func TestCollectBoundsFilesAcrossSets(t *testing.T) { root, workspace := t.TempDir(), t.TempDir() for i := 0; i < maxFiles; i++ { name := filepath.Join(workspace, fmt.Sprintf("file-%03d", i)) if err := os.WriteFile(name, []byte("x"), 0o644); err != nil { t.Fatal(err) } } store := Store{DataDir: root} paths := make([]string, maxFiles) for i := range paths { paths[i] = fmt.Sprintf("file-%03d", i) } if err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "first", Paths: paths}); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(workspace, "extra"), []byte("x"), 0o644); err != nil { t.Fatal(err) } if err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "second", Paths: []string{"extra"}}); err == nil { t.Fatal("child file budget exceeded") } if _, err := os.Stat(filepath.Join(root, "artifacts", "123", "001", "second")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("over-budget set visible: %v", err) } } func TestListAndOpenHideIncompleteStage(t *testing.T) { root := t.TempDir() stage := filepath.Join(root, "artifacts", "123", "001", ".stage-copy") if err := os.MkdirAll(stage, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(stage, "private.txt"), []byte("private"), 0o644); err != nil { t.Fatal(err) } store := Store{DataDir: root} files, err := store.List("123", "") if err != nil { t.Fatal(err) } if len(files) != 0 { t.Fatalf("staged files listed: %#v", files) } if _, err := store.Open("123", "001/.stage-copy/private.txt"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("staged file opened: %v", err) } } func TestCollectRejectsSourceAncestorSymlink(t *testing.T) { root, workspace, outside := t.TempDir(), t.TempDir(), t.TempDir() if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("private"), 0o600); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(workspace, "dist")); err != nil { t.Fatal(err) } store := Store{DataDir: root} if err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "set", Paths: []string{"dist/secret"}, Required: true}); err == nil { t.Fatal("ancestor symlink accepted") } if _, err := store.Open("123", "001/set/dist/secret"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("leaked artifact opened: %v", err) } } func TestOpenRejectsStorageAncestorSymlink(t *testing.T) { root, outside := t.TempDir(), t.TempDir() if err := os.WriteFile(filepath.Join(outside, "secret"), []byte("private"), 0o600); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(root, "artifacts", "123"), 0o755); err != nil { t.Fatal(err) } if err := os.Symlink(outside, filepath.Join(root, "artifacts", "123", "001")); err != nil { t.Fatal(err) } if _, err := (Store{DataDir: root}).Open("123", "001/secret"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("ancestor symlink opened: %v", err) } } func TestCollectRejectsSymlinkWithoutPartialVisibility(t *testing.T) { root, workspace := t.TempDir(), t.TempDir() if err := os.Symlink("/etc/passwd", filepath.Join(workspace, "leak")); err != nil { t.Fatal(err) } store := Store{DataDir: root} err := store.Collect(workspace, "123", "001", ciconfig.Artifact{Name: "set", Paths: []string{"leak"}, Required: true}) if err == nil { t.Fatal("symlink accepted") } if _, err := os.Stat(filepath.Join(root, "artifacts", "123", "001", "set")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("partial set visible: %v", err) } }