diff --git a/syncmedia.go b/syncmedia.go index b96198a8..4462c07c 100644 --- a/syncmedia.go +++ b/syncmedia.go @@ -48,6 +48,10 @@ const ( // mediaExtensions is the set of file extensions treated as "media" (i.e. // non-code) that will be synced to blob storage. Keep in lowercase. +// +// This is an allowlist: files whose extensions are not listed here are never +// uploaded. See codeExtensions for an explicit denylist of source-code +// extensions that must never leak into storage even if this allowlist grows. var mediaExtensions = map[string]bool{ ".png": true, ".jpg": true, @@ -71,6 +75,27 @@ var mediaExtensions = map[string]bool{ ".zip": true, } +// codeExtensions is an explicit denylist of source-code file extensions that +// must NEVER be uploaded to blob storage. Code lives in git; only rendered +// media belongs in the container. This check runs before the mediaExtensions +// allowlist so accidental additions to that allowlist can't slip code through. +// Keep in lowercase. +var codeExtensions = map[string]bool{ + ".md": true, + ".css": true, + ".html": true, + ".go": true, + ".js": true, + ".ts": true, + ".json": true, + ".yaml": true, + ".yml": true, + ".toml": true, + ".mod": true, + ".sum": true, + ".sh": true, +} + // syncMediaPrefixes are the repo-relative directory roots we sync from. var syncMediaPrefixes = []string{ "content/articles", @@ -258,7 +283,15 @@ func watchAndSync( } func isMediaPath(p string) bool { - return mediaExtensions[strings.ToLower(filepath.Ext(p))] + ext := strings.ToLower(filepath.Ext(p)) + if codeExtensions[ext] { + return false + } + return mediaExtensions[ext] +} + +func isCodePath(p string) bool { + return codeExtensions[strings.ToLower(filepath.Ext(p))] } // isLikelyDirEvent returns true if the event has no file extension (usually a @@ -293,6 +326,9 @@ func collectLocalMedia(log *logrus.Logger) (map[string]*localMediaFile, error) { return nil } ext := strings.ToLower(filepath.Ext(p)) + if codeExtensions[ext] { + return nil + } if !mediaExtensions[ext] { return nil } diff --git a/syncmedia_test.go b/syncmedia_test.go new file mode 100644 index 00000000..331b8c65 --- /dev/null +++ b/syncmedia_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsMediaPathAndIsCodePath(t *testing.T) { + mediaCases := []string{ + "content/articles/foo/bar.png", + "content/images/logo.JPG", + "content/pages/about/pic.jpeg", + "content/articles/x/movie.mp4", + "content/articles/x/doc.pdf", + } + for _, p := range mediaCases { + require.Truef(t, isMediaPath(p), "expected media: %s", p) + require.Falsef(t, isCodePath(p), "expected not code: %s", p) + } + + codeCases := []string{ + "content/articles/foo/foo.md", + "content/stylesheets/tailwind.css", + "web/html/_nav.tmpl.html", + "build.go", + "package.json", + "go.mod", + } + for _, p := range codeCases { + require.Truef(t, isCodePath(p), "expected code: %s", p) + require.Falsef(t, isMediaPath(p), "expected not media: %s", p) + } + + // Belt-and-suspenders: even if a code extension were mistakenly added to + // mediaExtensions, isMediaPath must still return false because + // codeExtensions is consulted first. + mediaExtensions[".md"] = true + t.Cleanup(func() { delete(mediaExtensions, ".md") }) + require.False(t, isMediaPath("content/articles/foo/foo.md"), + "code denylist must win over media allowlist") +}