feat: initial open-source release of Pipely
OTA update server designed for high-concurrency, low-bandwidth deployments. GORM-backed PostgreSQL, JWT auth, device management, artefact versioning, deployment rollout with gated rate-limited downloads, and a React admin panel.
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
package delta
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PatchManifest describes the delta operations needed to transform an old JAR into a new JAR.
|
||||
type PatchManifest struct {
|
||||
OldVersion string `json:"oldVersion"`
|
||||
NewVersion string `json:"newVersion"`
|
||||
Deleted []string `json:"deleted"`
|
||||
Added []string `json:"added"`
|
||||
Modified []PatchEntry `json:"modified"`
|
||||
Unchanged int `json:"unchanged"`
|
||||
}
|
||||
|
||||
// PatchEntry represents a single modified file within the delta.
|
||||
type PatchEntry struct {
|
||||
Path string `json:"path"`
|
||||
OldSHA256 string `json:"oldSha256"`
|
||||
NewSHA256 string `json:"newSha256"`
|
||||
DiffSize int64 `json:"diffSize"`
|
||||
DiffHash string `json:"diffHash"`
|
||||
}
|
||||
|
||||
// GenerateJarDelta produces a .patch file by comparing two JARs:
|
||||
// 1. Unzip both JARs into memory.
|
||||
// 2. Compare files; identify added, deleted, and modified entries.
|
||||
// 3. For modified .class files, generate a BSDiff-style binary diff.
|
||||
// 4. Package everything (diff chunks, new files, manifest.json) into a zip.
|
||||
func GenerateJarDelta(oldJarPath, newJarPath, patchPath string) error {
|
||||
oldFiles, err := readJarEntries(oldJarPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading old JAR: %w", err)
|
||||
}
|
||||
newFiles, err := readJarEntries(newJarPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading new JAR: %w", err)
|
||||
}
|
||||
|
||||
manifest := PatchManifest{
|
||||
OldVersion: filepath.Base(oldJarPath),
|
||||
NewVersion: filepath.Base(newJarPath),
|
||||
}
|
||||
|
||||
oldSet := make(map[string][]byte)
|
||||
for _, f := range oldFiles {
|
||||
oldSet[f.Name] = f.Data
|
||||
}
|
||||
newSet := make(map[string][]byte)
|
||||
for _, f := range newFiles {
|
||||
newSet[f.Name] = f.Data
|
||||
}
|
||||
|
||||
// Identify deleted, added, and modified files.
|
||||
for name := range oldSet {
|
||||
if _, ok := newSet[name]; !ok {
|
||||
manifest.Deleted = append(manifest.Deleted, name)
|
||||
}
|
||||
}
|
||||
for name := range newSet {
|
||||
if _, ok := oldSet[name]; !ok {
|
||||
manifest.Added = append(manifest.Added, name)
|
||||
}
|
||||
}
|
||||
for name, newData := range newSet {
|
||||
oldData, ok := oldSet[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !bytes.Equal(oldData, newData) {
|
||||
entry := PatchEntry{Path: name}
|
||||
entry.OldSHA256 = sha256Hex(oldData)
|
||||
entry.NewSHA256 = sha256Hex(newData)
|
||||
|
||||
if isBinaryFile(name) {
|
||||
diff := bsdiff(oldData, newData)
|
||||
entry.DiffSize = int64(len(diff))
|
||||
entry.DiffHash = sha256Hex(diff)
|
||||
}
|
||||
|
||||
manifest.Modified = append(manifest.Modified, entry)
|
||||
} else {
|
||||
manifest.Unchanged++
|
||||
}
|
||||
}
|
||||
|
||||
return writePatchFile(patchPath, &manifest, oldFiles, newFiles, oldSet, newSet)
|
||||
}
|
||||
|
||||
type jarEntry struct {
|
||||
Name string
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func readJarEntries(jarPath string) ([]jarEntry, error) {
|
||||
data, err := os.ReadFile(jarPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []jarEntry
|
||||
for _, f := range reader.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries = append(entries, jarEntry{Name: f.Name, Data: content})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func isBinaryFile(name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(name))
|
||||
switch ext {
|
||||
case ".class", ".jar", ".war", ".ear":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
h := sha256.Sum256(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func writePatchFile(patchPath string, manifest *PatchManifest, oldFiles, newFiles []jarEntry, oldSet, newSet map[string][]byte) error {
|
||||
out, err := os.Create(patchPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
// Write manifest.json
|
||||
manifestData, _ := json.MarshalIndent(manifest, "", " ")
|
||||
w, _ := zw.Create("manifest.json")
|
||||
w.Write(manifestData)
|
||||
|
||||
// Write added files
|
||||
for _, name := range manifest.Added {
|
||||
w, _ := zw.Create("added/" + name)
|
||||
w.Write(newSet[name])
|
||||
}
|
||||
|
||||
// Write modifications (binary diffs)
|
||||
for _, entry := range manifest.Modified {
|
||||
if entry.DiffSize > 0 {
|
||||
oldData := oldSet[entry.Path]
|
||||
newData := newSet[entry.Path]
|
||||
diff := bsdiff(oldData, newData)
|
||||
w, _ := zw.Create("diffs/" + entry.Path + ".bsdiff")
|
||||
w.Write(diff)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// bsdiff produces a simple binary diff between two byte slices.
|
||||
// For production use, integrate a full BSDiff library (e.g. github.com/icedream/bsdiff).
|
||||
func bsdiff(old, new []byte) []byte {
|
||||
// Poor man's binary diff — records differing byte runs.
|
||||
// A production implementation should use a proper BSDiff or VCDIFF library.
|
||||
var buf bytes.Buffer
|
||||
minLen := len(old)
|
||||
if len(new) < minLen {
|
||||
minLen = len(new)
|
||||
}
|
||||
|
||||
// Write header: old size, new size
|
||||
buf.WriteString(fmt.Sprintf("BSDIFF40:%d:%d\n", len(old), len(new)))
|
||||
|
||||
// Record differing chunks
|
||||
chunkStart := -1
|
||||
for i := 0; i < minLen; i++ {
|
||||
if old[i] != new[i] {
|
||||
if chunkStart == -1 {
|
||||
chunkStart = i
|
||||
}
|
||||
} else if chunkStart != -1 {
|
||||
chunk := new[chunkStart:i]
|
||||
buf.Write(encodeChunk(chunkStart, chunk))
|
||||
chunkStart = -1
|
||||
}
|
||||
}
|
||||
if chunkStart != -1 {
|
||||
chunk := new[chunkStart:minLen]
|
||||
buf.Write(encodeChunk(chunkStart, chunk))
|
||||
}
|
||||
|
||||
// Trail: if new is longer, append extra bytes
|
||||
if len(new) > len(old) {
|
||||
buf.Write(encodeChunk(len(old), new[len(old):]))
|
||||
}
|
||||
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func encodeChunk(offset int, data []byte) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(fmt.Sprintf("@%d+%d:", offset, len(data)))
|
||||
buf.Write(data)
|
||||
buf.WriteByte('\n')
|
||||
return buf.Bytes()
|
||||
}
|
||||
Reference in New Issue
Block a user