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
10 changes: 10 additions & 0 deletions internal/packager/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func (p *Packager) processArchive(spec config.PackageSpec) error {

// If a subpath is specified, use it directly; otherwise unwrap single top-level dirs
// (common in archives like "firebase-unity-sdk-12.0.0/...")
archiveRoot := extractDir
srcDir := extractDir
if spec.Path != "" {
srcDir = filepath.Join(extractDir, spec.Path)
Expand All @@ -31,6 +32,7 @@ func (p *Packager) processArchive(spec config.PackageSpec) error {
}
} else {
srcDir = unwrapSingleDir(extractDir)
archiveRoot = srcDir
}

destDir := filepath.Join(p.packagesDir, spec.Name)
Expand All @@ -43,6 +45,10 @@ func (p *Packager) processArchive(spec config.PackageSpec) error {
return fmt.Errorf("copying archive package %q: %w", spec.Name, err)
}

if err := CopyLegalFiles(srcDir, destDir); err != nil {
return fmt.Errorf("copying legal files for %q: %w", spec.Name, err)
}

if err := unity.WriteCscRspForAsmdefs(destDir, spec.SuppressWarnings); err != nil {
return fmt.Errorf("writing csc.rsp for %q: %w", spec.Name, err)
}
Expand Down Expand Up @@ -75,6 +81,10 @@ func (p *Packager) processArchive(spec config.PackageSpec) error {
if err := unity.WriteCscRsp(runtimeDir, spec.SuppressWarnings); err != nil {
return fmt.Errorf("writing csc.rsp for %q: %w", spec.Name, err)
}

if err := CopyLegalFilesSearchingUp(srcDir, archiveRoot, destDir); err != nil {
return fmt.Errorf("copying legal files for %q: %w", spec.Name, err)
}
}

if err := GenerateMetaFiles(destDir, spec.Name); err != nil {
Expand Down
15 changes: 13 additions & 2 deletions internal/packager/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ func (p *Packager) processGitUnity(spec config.PackageSpec) error {
return fmt.Errorf("copying git-unity package %q: %w", spec.Name, err)
}

// Ensure license/readme files survive exclude filters
if err := CopyLegalFiles(srcDir, destDir); err != nil {
return fmt.Errorf("copying legal files for %q: %w", spec.Name, err)
}

if err := unity.WriteCscRspForAsmdefs(destDir, spec.SuppressWarnings); err != nil {
return fmt.Errorf("writing csc.rsp for %q: %w", spec.Name, err)
}
Expand All @@ -44,14 +49,15 @@ func (p *Packager) processGitUnity(spec config.PackageSpec) error {
}

func (p *Packager) processGitRaw(spec config.PackageSpec) error {
srcDir, err := p.cloneOrCache(spec.URL, spec.Ref)
cloneDir, err := p.cloneOrCache(spec.URL, spec.Ref)
if err != nil {
return err
}

// If a subpath is specified, use it
srcDir := cloneDir
if spec.Path != "" {
srcDir = filepath.Join(srcDir, spec.Path)
srcDir = filepath.Join(cloneDir, spec.Path)
}

destDir := filepath.Join(p.packagesDir, spec.Name)
Expand Down Expand Up @@ -88,6 +94,11 @@ func (p *Packager) processGitRaw(spec config.PackageSpec) error {
return fmt.Errorf("writing csc.rsp for %q: %w", spec.Name, err)
}

// Copy license/readme to package root, searching upward to repo root
if err := CopyLegalFilesSearchingUp(srcDir, cloneDir, destDir); err != nil {
return fmt.Errorf("copying legal files for %q: %w", spec.Name, err)
}

// Generate meta files for everything
if err := GenerateMetaFiles(destDir, spec.Name); err != nil {
return fmt.Errorf("generating meta files for %q: %w", spec.Name, err)
Expand Down
172 changes: 172 additions & 0 deletions internal/packager/legalfiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package packager

import (
"archive/zip"
"io"
"os"
"path/filepath"
"strings"
)

// legalFilePatterns are base names (case-insensitive) of files that should always
// be included in the package root, regardless of exclude filters.
var legalFilePatterns = []string{
"license",
"licence",
"license.md",
"licence.md",
"license.txt",
"licence.txt",
"license.rst",
"licence.rst",
"readme",
"readme.md",
"readme.txt",
"readme.rst",
"notice",
"notice.md",
"notice.txt",
"third-party-notices",
"third-party-notices.md",
"third-party-notices.txt",
"thirdpartynotices",
"thirdpartynotices.txt",
}
Comment on lines +13 to +34
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

legalFilePatterns doesn’t fully match the filenames/extensions promised in the PR description (e.g., NOTICE.rst, ThirdPartyNotices.md, ThirdPartyNotices.rst, and third-party-notices.rst are missing). This will cause some legal files to be skipped. Expand the pattern list (or generate patterns programmatically) so all documented variants are recognized consistently.

Copilot uses AI. Check for mistakes.

// isLegalFile returns true if the filename (base name only) matches a known
// license/readme/notice pattern.
func isLegalFile(name string) bool {
lower := strings.ToLower(name)
for _, pattern := range legalFilePatterns {
if lower == pattern {
return true
}
}
return false
}

// CopyLegalFiles copies license, readme, and notice files from srcDir (top-level only)
// to destDir, regardless of any exclude patterns. Files already present in destDir are
// not overwritten.
func CopyLegalFiles(srcDir, destDir string) error {
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}

for _, entry := range entries {
if entry.IsDir() {
continue
}
if !isLegalFile(entry.Name()) {
continue
}

destPath := filepath.Join(destDir, entry.Name())
// Don't overwrite if already present (e.g., copied by CopyFiltered)
if _, err := os.Stat(destPath); err == nil {
continue
Comment on lines +67 to +68
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The overwrite check uses os.Stat(destPath) and treats any non-nil error as “file does not exist”. If Stat fails for reasons other than non-existence (permissions, transient IO errors), the code will proceed and likely fail later or mask the real error. Handle err != nil && !os.IsNotExist(err) by returning the error.

Suggested change
if _, err := os.Stat(destPath); err == nil {
continue
if _, err := os.Stat(destPath); err == nil {
// File already exists at destPath; skip copying.
continue
} else if !os.IsNotExist(err) {
// An unexpected error occurred while checking destPath; surface it.
return err

Copilot uses AI. Check for mistakes.
}

srcPath := filepath.Join(srcDir, entry.Name())
info, err := entry.Info()
if err != nil {
return err
}
if err := copyFile(srcPath, destPath, info.Mode()); err != nil {
return err
}
}

return nil
}

// CopyLegalFilesSearchingUp copies legal files from srcDir to destDir, searching
// upward through parent directories up to (and including) rootDir. This ensures
// license files at a repository root are found even when a subpath is used.
// Files found closer to srcDir take precedence over those found higher up.
func CopyLegalFilesSearchingUp(srcDir, rootDir, destDir string) error {
srcAbs, err := filepath.Abs(srcDir)
if err != nil {
return err
}
rootAbs, err := filepath.Abs(rootDir)
if err != nil {
return err
}

// Collect directories from srcDir up to rootDir
var dirs []string
current := srcAbs
for {
dirs = append(dirs, current)
if current == rootAbs {
break
}
parent := filepath.Dir(current)
if parent == current {
Comment on lines +98 to +107
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ExtractLegalFilesFromZip joins destDir with f.Name without a zip-slip/path traversal guard. The current root-level check only looks for /, so names like ..\\LICENSE could escape destDir on Windows. Add a robust validation (e.g., reject any path separators, clean and verify the final path stays within destDir, similar to extractZip), before creating the output file.

Copilot uses AI. Check for mistakes.
break
}
current = parent
}
Comment on lines +107 to +111
Copy link

Copilot AI Mar 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as CopyLegalFiles: the os.Stat overwrite check treats any error as “not present”. If Stat fails for reasons other than IsNotExist, return that error rather than continuing to create/truncate the file.

Copilot uses AI. Check for mistakes.

// Search from closest to furthest — CopyLegalFiles won't overwrite existing files
for _, dir := range dirs {
if err := CopyLegalFiles(dir, destDir); err != nil {
return err
}
}

return nil
}

// ExtractLegalFilesFromZip extracts license, readme, and notice files from a zip
// archive's root to destDir. Used for nupkg files where these are at the archive root.
func ExtractLegalFilesFromZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()

for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}

// Only look at root-level files in the archive
if strings.Contains(f.Name, "/") {
continue
}

if !isLegalFile(f.Name) {
continue
}

destPath := filepath.Join(destDir, f.Name)
// Don't overwrite
if _, err := os.Stat(destPath); err == nil {
continue
}

rc, err := f.Open()
if err != nil {
return err
}

outFile, err := os.Create(destPath)
if err != nil {
rc.Close()
return err
}

_, copyErr := io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if copyErr != nil {
return copyErr
}
}

return nil
}
Loading
Loading