Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions .github/workflows/CI.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Continuous integration

on:
push:
branches:
branches:
- main
- develop
paths:
Expand All @@ -20,24 +20,32 @@ on:
- 'go.sum'
- '.github/workflows/CI.yml'

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ['1.22']
permissions:
contents: read

steps:
- uses: actions/checkout@v6

- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-ffmpeg-${{ runner.os }}

- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y ffmpeg
run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends ffmpeg

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
go-version-file: go.mod
cache: true

- name: Download dependencies
run: go mod download
Expand All @@ -47,17 +55,26 @@ jobs:

coverage:
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- uses: actions/checkout@v6

- name: Cache apt packages
uses: actions/cache@v4
with:
path: /var/cache/apt/archives
key: apt-ffmpeg-${{ runner.os }}

- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y ffmpeg
run: sudo apt-get update -q && sudo apt-get install -y -q --no-install-recommends ffmpeg

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.22'
go-version-file: go.mod
cache: true

- name: Download dependencies
run: go mod download
Expand All @@ -80,16 +97,19 @@ jobs:

lint:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.22'
go-version-file: go.mod
cache: true

- name: Run go vet
run: go vet ./...

- name: Build
run: go build ./...
run: go build ./...
75 changes: 70 additions & 5 deletions internal/converter/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ func probeVideoBitrate(source, ffmpegBin string) int64 {
if _, err := exec.LookPath(probeBin); err != nil {
return 0
}
src := pathForBin(probeBin, source)
out, err := exec.Command(probeBin,
"-v", "quiet",
"-show_entries", "format=bit_rate",
"-of", "default=noprint_wrappers=1",
source).Output()
src).Output()
if err != nil {
return 0
}
Expand Down Expand Up @@ -124,9 +125,67 @@ func getVideoEncoder(codec string) (string, error) {
return "", fmt.Errorf("unknown codec: %s", codec)
}

func validateImageTargetExt(targetExt string) (string, error) {
if targetExt == "" {
return "", fmt.Errorf("invalid target extension")
}
if strings.Contains(targetExt, "\x00") || strings.ContainsAny(targetExt, `/\`) || strings.Contains(targetExt, "..") {
return "", fmt.Errorf("invalid target extension")
}
for _, r := range targetExt {
if r < 0x20 || r == 0x7f {
return "", fmt.Errorf("invalid target extension")
}
}

ext := shared.NormaliseExt(normaliseTargetExt(targetExt))
switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tif", ".tiff", ".webp", ".avif":
return ext, nil
default:
return "", fmt.Errorf("unsupported target extension: %s", targetExt)
}
}

func validateVideoTargetExt(targetExt string) (string, error) {
if targetExt == "" {
return "", fmt.Errorf("invalid target extension")
}
if strings.Contains(targetExt, "\x00") || strings.ContainsAny(targetExt, `/\`) || strings.Contains(targetExt, "..") {
return "", fmt.Errorf("invalid target extension")
}
for _, r := range targetExt {
if r < 0x20 || r == 0x7f {
return "", fmt.Errorf("invalid target extension")
}
}

ext := shared.NormaliseExt(normaliseTargetExt(targetExt))
if _, ok := canonicalVideo[ext]; ok {
return ext, nil
}
return "", fmt.Errorf("unsupported target extension: %s", targetExt)
}

// IsValidTargetExt reports whether ext is a recognised image or video output extension.
func IsValidTargetExt(ext string) bool {
_, imgErr := validateImageTargetExt(ext)
if imgErr == nil {
return true
}
_, vidErr := validateVideoTargetExt(ext)
return vidErr == nil
}

// ConvertImage converts an image file using the imaging library.
func ConvertImage(source, targetExt, outputDir string) (string, error) {
ext := shared.NormaliseExt(normaliseTargetExt(targetExt))
if !filepath.IsAbs(source) || strings.Contains(source, "\x00") {
return "", fmt.Errorf("invalid source path")
}
Comment on lines 181 to +184
ext, err := validateImageTargetExt(targetExt)
if err != nil {
return "", err
}

stem := strings.TrimSuffix(filepath.Base(source), filepath.Ext(source))
var dest string
Expand Down Expand Up @@ -215,13 +274,19 @@ func convertImageByFFmpeg(source, dest, ext string) (string, error) {
// ConvertVideo converts a video file using ffmpeg.
// codec is one of: h264, h265, av1, vp8, vp9. Defaults to h264 when empty.
func ConvertVideo(source, targetExt, codec, outputDir string, av1CRF int) (string, error) {
if !filepath.IsAbs(source) || strings.Contains(source, "\x00") {
return "", fmt.Errorf("invalid source path")
}
Comment on lines 276 to +279
candidates := ffmpegCandidates()
if len(candidates) == 0 {
return "", fmt.Errorf("ffmpeg is not installed or not on PATH")
}
bin := candidates[0]

ext := normaliseTargetExt(targetExt)
ext, err := validateVideoTargetExt(targetExt)
if err != nil {
return "", err
}

stem := strings.TrimSuffix(filepath.Base(source), filepath.Ext(source))
var dest string
Expand All @@ -247,7 +312,7 @@ func ConvertVideo(source, targetExt, codec, outputDir string, av1CRF int) (strin
return "", err
}

cmd := []string{bin, "-y", "-i", source, "-c:v", encoder, "-c:a", "aac"}
cmd := []string{bin, "-y", "-i", pathForBin(bin, source), "-c:v", encoder, "-c:a", "aac"}

isAV1 := encoder == "libsvtav1" || encoder == "libaom-av1"
if isAV1 {
Expand Down Expand Up @@ -289,7 +354,7 @@ func ConvertVideo(source, targetExt, codec, outputDir string, av1CRF int) (strin
}
}

cmd = append(cmd, dest)
cmd = append(cmd, pathForBin(bin, dest))

out, err2 := exec.Command(cmd[0], cmd[1:]...).CombinedOutput()
if err2 != nil {
Expand Down
30 changes: 30 additions & 0 deletions internal/converter/converter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,33 @@ func TestVideoConversions_nonEmpty(t *testing.T) {
t.Error("VideoConversions is empty")
}
}

// ── Input validation ─────────────────────────────────────────────────────────

func TestConvertImage_relativePath(t *testing.T) {
_, err := converter.ConvertImage("relative/path/image.jpg", ".png", "")
if err == nil {
t.Error("expected error for relative source path, got nil")
}
}

func TestConvertImage_nullBytePath(t *testing.T) {
_, err := converter.ConvertImage("/valid/path/image\x00.jpg", ".png", "")
if err == nil {
t.Error("expected error for source path containing NUL byte, got nil")
}
}

func TestConvertVideo_relativePath(t *testing.T) {
_, err := converter.ConvertVideo("relative/path/video.mp4", ".mkv", "", "", 0)
if err == nil {
t.Error("expected error for relative source path, got nil")
}
}

func TestConvertVideo_nullBytePath(t *testing.T) {
_, err := converter.ConvertVideo("/valid/path/video\x00.mp4", ".mkv", "", "", 0)
if err == nil {
t.Error("expected error for source path containing NUL byte, got nil")
}
}
Loading
Loading