Compare commits

..

2 Commits

Author SHA1 Message Date
Jakob Borg c7f1d854bd wip
Signed-off-by: Jakob Borg <jakob@kastelo.net>
2026-05-19 14:30:42 +02:00
Jakob Borg f877dfed4e wip
Signed-off-by: Jakob Borg <jakob@kastelo.net>
2026-05-19 13:09:29 +02:00
39 changed files with 190 additions and 1389 deletions
-3
View File
@@ -6,6 +6,3 @@ vendor/** -text=auto
# Diffs on these files are meaningless
*.svg -diff
# Patch files intentionally contain diff context whitespace
patches/*.patch -whitespace
-135
View File
@@ -1,135 +0,0 @@
name: custom release
permissions:
contents: write
releases: write
on:
push:
branches:
- felix/release-automation
paths:
- ".gitea/workflows/custom-release.yml"
- "patches/**"
- "scripts/update-custom-release.sh"
workflow_dispatch:
inputs:
upstream_tag:
description: "Optional upstream Syncthing tag, for example v2.1.0"
required: false
suffix:
description: "Optional custom release suffix, for example stignore.7"
required: false
schedule:
- cron: "17 04 * * *"
jobs:
build-custom-release:
runs-on: ffmini_macos_arm64
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: false
- name: Configure Git author
run: |
git config user.name "Gitea Actions"
git config user.email "actions@git.felixfoertsch.de"
- name: Set up tea
run: |
go install code.gitea.io/tea@v0.14.1
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
"$(go env GOPATH)/bin/tea" logins delete actions >/dev/null 2>&1 || true
"$(go env GOPATH)/bin/tea" logins add --name actions --url https://git.felixfoertsch.de --token "$GITEA_TOKEN" --no-version-check
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
- name: Import Developer ID certificate
run: |
set -euo pipefail
keychain_dir="$HOME/Library/Keychains"
mkdir -p "$keychain_dir"
keychain_path="$keychain_dir/syncthing-release-signing-${GITHUB_RUN_ID:-$$}.keychain-db"
keychain_password="$(openssl rand -hex 24)"
certificate_path="$RUNNER_TEMP/developer-id-application.p12"
previous_default_keychain="$(security default-keychain -d user 2>/dev/null | sed 's/[ "]//g' || true)"
echo "CUSTOM_RELEASE_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV"
echo "CUSTOM_RELEASE_KEYCHAIN_PASSWORD=$keychain_password" >> "$GITHUB_ENV"
echo "CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN=$previous_default_keychain" >> "$GITHUB_ENV"
if [ -z "$DEVELOPER_ID_APPLICATION_P12_BASE64" ]; then
echo "DEVELOPER_ID_APPLICATION_P12_BASE64 secret is required" >&2
exit 1
fi
printf '%s' "$DEVELOPER_ID_APPLICATION_P12_BASE64" | base64 -D > "$certificate_path"
rm -f "$keychain_path"
security create-keychain -p "$keychain_password" "$keychain_path"
security set-keychain-settings -lut 21600 "$keychain_path"
security unlock-keychain -p "$keychain_password" "$keychain_path"
security import "$certificate_path" -k "$keychain_path" -P "$DEVELOPER_ID_APPLICATION_P12_PASSWORD" -A -T /usr/bin/codesign -T /usr/bin/security
existing_keychains=()
while IFS= read -r existing_keychain; do
existing_keychain="$(printf '%s' "$existing_keychain" | sed 's/[ "]//g')"
if [ -n "$existing_keychain" ] && [ -e "$existing_keychain" ] && [[ "$existing_keychain" != *"/syncthing-release-signing-"*".keychain-db" ]]; then
existing_keychains+=("$existing_keychain")
fi
done < <(security list-keychains)
security list-keychains -s "$keychain_path" "${existing_keychains[@]}"
security list-keychains -d user -s "$keychain_path" "${existing_keychains[@]}" || true
security default-keychain -d user -s "$keychain_path" || true
security list-keychains
security list-keychains -d user || true
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$keychain_password" "$keychain_path"
identity_output="$(security find-identity -v -p codesigning "$keychain_path")"
printf '%s\n' "$identity_output"
security find-identity -v -p codesigning
codesign_identity_sha1="$(printf '%s\n' "$identity_output" | awk '/"Developer ID Application:/ { print $2; exit }')"
codesign_identity="$(printf '%s\n' "$identity_output" | sed -n 's/.*"\(Developer ID Application:[^"]*\)".*/\1/p' | head -n 1)"
if [ -z "$codesign_identity" ]; then
echo "Developer ID Application signing identity is required in DEVELOPER_ID_APPLICATION_P12_BASE64" >&2
exit 1
fi
probe_binary="$RUNNER_TEMP/codesign-probe"
cp /usr/bin/true "$probe_binary"
codesign --force --dryrun --sign "$codesign_identity" --keychain "$keychain_path" --options runtime --timestamp "$probe_binary"
echo "CUSTOM_RELEASE_CODESIGN_IDENTITY=$codesign_identity" >> "$GITHUB_ENV"
echo "CUSTOM_RELEASE_CODESIGN_IDENTITY_SHA1=$codesign_identity_sha1" >> "$GITHUB_ENV"
env:
DEVELOPER_ID_APPLICATION_P12_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64 }}
DEVELOPER_ID_APPLICATION_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
- name: Build patched Syncthing release
run: ./scripts/update-custom-release.sh
env:
CUSTOM_RELEASE_UPSTREAM_TAG: ${{ github.event.inputs.upstream_tag }}
CUSTOM_RELEASE_SUFFIX: ${{ github.event.inputs.suffix }}
CUSTOM_RELEASE_PUSH: "1"
CUSTOM_RELEASE_PUSH_BRANCH: "0"
CUSTOM_RELEASE_REMOTE: origin
CUSTOM_RELEASE_BUILDS: "darwin/arm64/zip/1 linux/amd64/tar/0 linux/arm64/tar/0"
CUSTOM_RELEASE_CODESIGN_TEAM_ID: "NG5W75WE8U"
CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT: "0"
CUSTOM_RELEASE_CREATE_GITEA_RELEASE: "1"
CUSTOM_RELEASE_TEA_REPO: felixfoertsch/syncthing
- name: Delete temporary keychain
if: always()
run: |
if [ -n "${CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN:-}" ] && [ -e "$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN" ]; then
security default-keychain -d user -s "$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN" || true
fi
if [ -n "${CUSTOM_RELEASE_KEYCHAIN_PATH:-}" ]; then
security delete-keychain "$CUSTOM_RELEASE_KEYCHAIN_PATH" || true
fi
@@ -22,7 +22,6 @@ jobs:
if: github.repository_owner == 'syncthing'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
pkg:
- stcrashreceiver
+2 -2
View File
@@ -164,7 +164,7 @@ jobs:
needs:
- build-test
- package-linux
- package-illumos
# - package-illumos
- package-cross
- package-source
- package-debian
@@ -646,7 +646,7 @@ jobs:
needs:
- codesign-windows
- package-linux
- package-illumos
# - package-illumos
- package-macos
- package-cross
- package-source
-1
View File
@@ -18,4 +18,3 @@ deb
/repos
/proto/scripts/protoc-gen-gosyncthing
/compat.json
/dist/
@@ -1,76 +0,0 @@
2026-05-21 10:03:01 INF syncthing v2.1.1-dev.9.gb3b7d228.dirty-morecrashrep "Hafnium Hornet" (go1.26.3 darwin-arm64) jb@jbo-m3wl72rv 2026-05-21 07:58:11 UTC [stnoupgrade] (log.pkg=main)
2026-05-21 10:03:01 INF No automatic upgrades; STNOUPGRADE environment variable defined (log.pkg=main)
2026-05-21 10:03:01 INF Calculated our device ID (device=I6KAH76-66SLLLB-5PFXSOA-UFJCDZC-YAOMLEK-CP2GB32-BV5RQST-3PSROAU log.pkg=syncthing)
2026-05-21 10:03:01 INF Overall rate limit in use (send="is unlimited" recv="is unlimited" log.pkg=connections)
2026-05-21 10:03:01 INF Using discovery mechanism (identity="IPv4 local broadcast discovery on port 21027" log.pkg=discover)
2026-05-21 10:03:01 INF Using discovery mechanism (identity="IPv6 local multicast discovery on address [ff12::8384]:21027" log.pkg=discover)
2026-05-21 10:03:01 INF TCP listener starting (address=127.0.0.1:22001 log.pkg=connections)
2026-05-21 10:03:01 INF Ready to synchronize (folder.id=default folder.type=sendreceive log.pkg=model)
2026-05-21 10:03:01 INF QUIC listener starting (address=127.0.0.1:22001 log.pkg=connections)
2026-05-21 10:03:01 INF GUI and API listening (address=127.0.0.1:8081 log.pkg=api)
...
2026-05-21 10:03:01 INF Access the GUI via the following URL: http://127.0.0.1:8081/ (log.pkg=api)
2026-05-21 10:03:01 INF Loaded configuration (name=s1 log.pkg=syncthing)
2026-05-21 10:03:01 INF Loaded peer device configuration (device=MRIW7OK name=s2 address="[tcp://127.0.0.1:22002 quic://127.0.0.1:22002]" log.pkg=syncthing)
2026-05-21 10:03:01 INF Completed initial scan (folder.id=default folder.type=sendreceive log.pkg=model)
0xee9de1fe260
2026-05-21 10:03:02 INF Measured hashing performance (perf="2789.71 MB/s" log.pkg=syncthing)
Panic at 2026-05-21T10:03:02+02:00
runtime: marked free object in span 0x108b34d20, elemsize=8 freeindex=34 (bad use of unsafe.Pointer or having race conditions? try -d=checkptr or -race)
0xee9de1fe000 alloc marked
0xee9de1fe008 alloc marked
...
0xee9de1fe250 free unmarked
0xee9de1fe258 free unmarked
0xee9de1fe260 free marked zombie
7 6 5 4 3 2 1 0 f e d c b a 9 8 0123456789abcdef
00000ee9de1fe260: 00000000 00000000 ........
0xee9de1fe268 free unmarked
0xee9de1fe270 free unmarked
...
0xee9de1fff60 free unmarked
0xee9de1fff68 free unmarked
0xee9de1fff70 free unmarked
0xee9de1fff78 free unmarked
fatal error: found pointer to free object
runtime stack:
runtime.throw({0x105881781?, 0x8?})
runtime/panic.go:1229 +0x38 fp=0x16bf82bb0 sp=0x16bf82b80 pc=0x104f0ca48
runtime.(*mspan).reportZombies(0x108b34d20)
runtime/mgcsweep.go:893 +0x314 fp=0x16bf82c30 sp=0x16bf82bb0 pc=0x104ec10b4
runtime.(*sweepLocked).sweep(0x16bf82d88?, 0x0)
runtime/mgcsweep.go:673 +0xbd0 fp=0x16bf82d50 sp=0x16bf82c30 pc=0x104ec0840
runtime.(*mcentral).uncacheSpan(0x16bf82db8?, 0x104ea4954?)
runtime/mcentral.go:237 +0xbc fp=0x16bf82d80 sp=0x16bf82d50 pc=0x104eaac3c
runtime.(*mcache).releaseAll(0x1089a85f0)
runtime/mcache.go:322 +0x188 fp=0x16bf82df0 sp=0x16bf82d80 pc=0x104eaa4e8
runtime.(*mcache).prepareForSweep(0x1089a85f0)
runtime/mcache.go:366 +0x4c fp=0x16bf82e20 sp=0x16bf82df0 pc=0x104eaa61c
runtime.gcMarkTermination.func4(0xee9de005808)
runtime/mgc.go:1546 +0x24 fp=0x16bf82e50 sp=0x16bf82e20 pc=0x104f076e4
runtime.forEachPInternal(0x10656f798)
runtime/proc.go:2167 +0x178 fp=0x16bf82ee0 sp=0x16bf82e50 pc=0x104eda728
runtime.gcMarkTermination.forEachP.func7()
runtime/proc.go:2126 +0x40 fp=0x16bf82f10 sp=0x16bf82ee0 pc=0x104eb3130
runtime.systemstack(0x7fc000)
runtime/asm_arm64.s:399 +0x68 fp=0x16bf82f20 sp=0x16bf82f10 pc=0x104f12888
goroutine 84 gp=0xee9de45c1e0 m=3 mp=0xee9de019008 [flushing proc caches]:
runtime.systemstack_switch()
runtime/asm_arm64.s:347 +0x8 fp=0xee9de805c40 sp=0xee9de805c30 pc=0x104f12808
runtime.forEachP(...)
runtime/proc.go:2112
runtime.gcMarkTermination({0xc0?, 0x1331f928480ca?, 0xc?, 0x0?})
runtime/mgc.go:1545 +0x5f4 fp=0xee9de805e80 sp=0xee9de805c40 pc=0x104eb28c4
runtime.gcMarkDone()
runtime/mgc.go:1173 +0x364 fp=0xee9de805f20 sp=0xee9de805e80 pc=0x104eb1bc4
runtime.gcBgMarkWorker(0xee9de341810)
runtime/mgc.go:1912 +0x29c fp=0xee9de805fb0 sp=0xee9de805f20 pc=0x104eb372c
runtime.gcBgMarkStartWorkers.gowrap1()
runtime/mgc.go:1695 +0x20 fp=0xee9de805fd0 sp=0xee9de805fb0 pc=0x104eb3470
runtime.goexit({})
runtime/asm_arm64.s:1447 +0x4 fp=0xee9de805fd0 sp=0xee9de805fd0 pc=0x104f14a04
created by runtime.gcBgMarkStartWorkers in goroutine 1
runtime/mgc.go:1695 +0x134
+4 -14
View File
@@ -136,25 +136,15 @@ func (d *diskStore) Exists(path string) bool {
}
func (d *diskStore) clean() {
numDeleted := 0
for idx := range d.currentFiles {
if len(d.currentFiles)-numDeleted < d.maxFiles && d.currentSize < d.maxBytes {
break
}
f := d.currentFiles[idx]
for len(d.currentFiles) > 0 && (len(d.currentFiles) > d.maxFiles || d.currentSize > d.maxBytes) {
f := d.currentFiles[0]
log.Println("Removing", f.path)
if err := os.Remove(f.path); err != nil {
log.Println("Failed to remove file:", err)
}
d.currentFiles = d.currentFiles[1:]
d.currentSize -= f.size
numDeleted = idx + 1
}
// Compact currentFiles
copy(d.currentFiles, d.currentFiles[numDeleted:])
d.currentFiles = d.currentFiles[:len(d.currentFiles)-numDeleted]
var oldest time.Duration
if len(d.currentFiles) > 0 {
oldest = time.Since(time.Unix(d.currentFiles[0].mtime, 0)).Truncate(time.Minute)
@@ -168,7 +158,7 @@ func (d *diskStore) clean() {
}
func (d *diskStore) inventory() error {
d.currentFiles = d.currentFiles[:0]
d.currentFiles = nil
d.currentSize = 0
err := filepath.Walk(d.dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
+8 -11
View File
@@ -20,7 +20,6 @@ import (
"io"
"log"
"net/http"
"net/http/pprof"
"os"
"path/filepath"
"regexp"
@@ -30,7 +29,7 @@ import (
raven "github.com/getsentry/raven-go"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/syncthing/syncthing/lib/build"
"github.com/syncthing/syncthing/lib/ur/contract"
"github.com/syncthing/syncthing/lib/ur"
)
const maxRequestSize = 1 << 20 // 1 MiB
@@ -90,7 +89,6 @@ func main() {
if params.MetricsListen != "" {
mmux := http.NewServeMux()
mmux.Handle("/metrics", promhttp.Handler())
mmux.HandleFunc("/debug/pprof/", pprof.Index)
go func() {
if err := http.ListenAndServe(params.MetricsListen, mmux); err != nil {
log.Fatalln("HTTP serve metrics:", err)
@@ -125,13 +123,12 @@ func handleFailureFn(dsn, failureDir string, ignore *ignorePatterns) func(w http
return
}
if pat, ok := ignore.match(bs); ok {
metricIgnoreMatchesTotal.WithLabelValues(pat).Inc()
if ignore.match(bs) {
result = "ignored"
return
}
var reports []contract.FailureReport
var reports []ur.FailureReport
err = json.Unmarshal(bs, &reports)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -179,7 +176,7 @@ func handleFailureFn(dsn, failureDir string, ignore *ignorePatterns) func(w http
}
}
func saveFailureWithGoroutines(data contract.FailureData, failureDir string) (string, error) {
func saveFailureWithGoroutines(data ur.FailureData, failureDir string) (string, error) {
bs := make([]byte, len(data.Description)+len(data.Goroutines))
copy(bs, data.Description)
copy(bs[len(data.Description):], data.Goroutines)
@@ -219,14 +216,14 @@ func loadIgnorePatterns(path string) (*ignorePatterns, error) {
return &ignorePatterns{patterns: patterns}, nil
}
func (i *ignorePatterns) match(report []byte) (string, bool) {
func (i *ignorePatterns) match(report []byte) bool {
if i == nil {
return "", false
return false
}
for _, re := range i.patterns {
if re.Match(report) {
return re.String(), true
return true
}
}
return "", false
return false
}
-20
View File
@@ -37,24 +37,4 @@ var (
Subsystem: "crashreceiver",
Name: "diskstore_oldest_age_seconds",
})
metricSentryReportsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "syncthing",
Subsystem: "crashreceiver",
Name: "sentry_reports_total",
}, []string{"result"})
metricIgnoreMatchesTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "syncthing",
Subsystem: "crashreceiver",
Name: "ignore_matches_total",
}, []string{"pattern"})
metricSourceCodeLoadsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "syncthing",
Subsystem: "crashreceiver",
Name: "source_code_loads_total",
}, []string{"result"})
metricSourceCodeCacheSize = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: "syncthing",
Subsystem: "crashreceiver",
Name: "source_code_cache_size",
})
)
+8 -8
View File
@@ -10,7 +10,6 @@ import (
"bytes"
"context"
"errors"
"fmt"
"io"
"log"
"regexp"
@@ -53,15 +52,11 @@ func (s *sentryService) Serve(ctx context.Context) {
pkt, err := parseCrashReport(req.reportID, req.data)
if err != nil {
log.Println("Failed to parse crash report:", err)
metricSentryReportsTotal.WithLabelValues("parse_failure").Inc()
continue
}
if err := sendReport(s.dsn, pkt, req.userID); err != nil {
log.Println("Failed to send crash report:", err)
metricSentryReportsTotal.WithLabelValues("send_failure").Inc()
continue
}
metricSentryReportsTotal.WithLabelValues("success").Inc()
case <-ctx.Done():
return
@@ -74,7 +69,6 @@ func (s *sentryService) Send(reportID, userID string, data []byte) bool {
case s.inbox <- sentryRequest{reportID, userID, data}:
return true
default:
metricCrashReportsTotal.WithLabelValues("overflow").Inc()
return false
}
}
@@ -114,10 +108,11 @@ func parseCrashReport(path string, report []byte) (*raven.Packet, error) {
version, err := build.ParseVersion(string(parts[0]))
if err != nil {
return nil, fmt.Errorf("%w in %q", err, parts[0])
return nil, err
}
report = parts[1]
foundPanic := false
var subjectLine []byte
for {
parts = bytes.SplitN(report, []byte("\n"), 2)
@@ -128,9 +123,14 @@ func parseCrashReport(path string, report []byte) (*raven.Packet, error) {
line := parts[0]
report = parts[1]
if bytes.HasPrefix(line, []byte("panic:")) || bytes.HasPrefix(line, []byte("fatal error:")) {
if foundPanic {
// The previous line was our "Panic at ..." header. We are now
// at the beginning of the real panic trace and this is our
// subject line.
subjectLine = line
break
} else if bytes.HasPrefix(line, []byte("Panic at")) {
foundPanic = true
}
}
+11 -18
View File
@@ -9,33 +9,26 @@ package main
import (
"fmt"
"os"
"path/filepath"
"testing"
)
func TestParseReport(t *testing.T) {
files, err := filepath.Glob("_testdata/*.log")
bs, err := os.ReadFile("_testdata/panic.log")
if err != nil {
t.Fatal(err)
}
for _, file := range files {
bs, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
pkt, err := parseCrashReport("1/2/345", bs)
if err != nil {
t.Fatal(err)
}
bs, err = pkt.JSON()
if err != nil {
t.Fatal(err)
}
fmt.Printf("%s\n", bs)
pkt, err := parseCrashReport("1/2/345", bs)
if err != nil {
t.Fatal(err)
}
bs, err = pkt.JSON()
if err != nil {
t.Fatal(err)
}
fmt.Printf("%s\n", bs)
}
func TestCrashReportFingerprint(t *testing.T) {
+11 -26
View File
@@ -15,33 +15,23 @@ import (
"strings"
"sync"
"time"
lru "github.com/hashicorp/golang-lru/v2"
)
const (
urlPrefix = "https://raw.githubusercontent.com/syncthing/syncthing/"
httpTimeout = 10 * time.Second
maxCacheEntries = 1000
urlPrefix = "https://raw.githubusercontent.com/syncthing/syncthing/"
httpTimeout = 10 * time.Second
)
type cacheKey struct {
version string
file string
}
type githubSourceCodeLoader struct {
mut sync.Mutex
version string
cache *lru.TwoQueueCache[cacheKey, [][]byte] // version & file -> lines
client *http.Client
cache map[string]map[string][][]byte // version -> file -> lines
client *http.Client
}
func newGithubSourceCodeLoader() *githubSourceCodeLoader {
cache, _ := lru.New2Q[cacheKey, [][]byte](maxCacheEntries)
return &githubSourceCodeLoader{
cache: cache,
cache: make(map[string]map[string][][]byte),
client: &http.Client{Timeout: httpTimeout},
}
}
@@ -49,6 +39,9 @@ func newGithubSourceCodeLoader() *githubSourceCodeLoader {
func (l *githubSourceCodeLoader) LockWithVersion(version string) {
l.mut.Lock()
l.version = version
if _, ok := l.cache[version]; !ok {
l.cache[version] = make(map[string][][]byte)
}
}
func (l *githubSourceCodeLoader) Unlock() {
@@ -57,13 +50,11 @@ func (l *githubSourceCodeLoader) Unlock() {
func (l *githubSourceCodeLoader) Load(filename string, line, context int) ([][]byte, int) {
filename = filepath.ToSlash(filename)
key := cacheKey{version: l.version, file: filename}
lines, ok := l.cache.Get(key)
lines, ok := l.cache[l.version][filename]
if !ok {
// Cache whatever we managed to find (or nil if nothing, so we don't try again)
defer func() {
l.cache.Add(key, lines)
metricSourceCodeCacheSize.Set(float64(l.cache.Len()))
l.cache[l.version][filename] = lines
}()
knownPrefixes := []string{"/lib/", "/cmd/"}
@@ -82,25 +73,19 @@ func (l *githubSourceCodeLoader) Load(filename string, line, context int) ([][]b
resp, err := l.client.Get(url)
if err != nil {
fmt.Println("Loading source:", err)
metricSourceCodeLoadsTotal.WithLabelValues("failed").Inc()
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Println("Loading source:", resp.Status)
metricSourceCodeLoadsTotal.WithLabelValues("failed").Inc()
return nil, 0
}
data, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
fmt.Println("Loading source:", err.Error())
metricSourceCodeLoadsTotal.WithLabelValues("failed").Inc()
return nil, 0
}
lines = bytes.Split(data, []byte{'\n'})
metricSourceCodeLoadsTotal.WithLabelValues("loaded").Inc()
} else {
metricSourceCodeLoadsTotal.WithLabelValues("cached").Inc()
}
return getLineFromLines(lines, line, context)
+29 -10
View File
@@ -7,18 +7,21 @@
package main
import (
"bytes"
"io"
"log"
"net/http"
"path"
"strings"
"sync"
)
type crashReceiver struct {
store *diskStore
sentry *sentryService
ignore *ignorePatterns
ignoredMut sync.RWMutex
ignored map[string]struct{}
}
func (r *crashReceiver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
@@ -66,6 +69,12 @@ func (r *crashReceiver) serveGet(reportID string, w http.ResponseWriter, _ *http
// serveHead responds to HEAD requests by checking if the named report
// already exists in the system.
func (r *crashReceiver) serveHead(reportID string, w http.ResponseWriter, _ *http.Request) {
r.ignoredMut.RLock()
_, ignored := r.ignored[reportID]
r.ignoredMut.RUnlock()
if ignored {
return // found
}
if !r.store.Exists(reportID) {
http.Error(w, "Not found", http.StatusNotFound)
}
@@ -78,7 +87,17 @@ func (r *crashReceiver) servePut(reportID string, w http.ResponseWriter, req *ht
metricCrashReportsTotal.WithLabelValues(result).Inc()
}()
r.ignoredMut.RLock()
_, ignored := r.ignored[reportID]
r.ignoredMut.RUnlock()
if ignored {
result = "ignored_cached"
io.Copy(io.Discard, req.Body)
return // found
}
// Read at most maxRequestSize of report data.
log.Println("Receiving report", reportID)
lr := io.LimitReader(req.Body, maxRequestSize)
bs, err := io.ReadAll(lr)
if err != nil {
@@ -87,12 +106,14 @@ func (r *crashReceiver) servePut(reportID string, w http.ResponseWriter, req *ht
return
}
first := string(bytes.TrimSpace(bytes.Split(bs, []byte("\n"))[0]))
if pat, ok := r.ignore.match(bs); ok {
metricIgnoreMatchesTotal.WithLabelValues(pat).Inc()
if r.ignore.match(bs) {
r.ignoredMut.Lock()
if r.ignored == nil {
r.ignored = make(map[string]struct{})
}
r.ignored[reportID] = struct{}{}
r.ignoredMut.Unlock()
result = "ignored"
log.Printf("Ignored report %s, matched: %s (%s)", reportID[:8], pat, first)
return
}
@@ -100,15 +121,13 @@ func (r *crashReceiver) servePut(reportID string, w http.ResponseWriter, req *ht
// Store the report
if !r.store.Put(reportID, bs) {
log.Println("Failed to store report (queue full):", reportID[:8])
log.Println("Failed to store report (queue full):", reportID)
result = "queue_failure"
}
// Send the report to Sentry
if !r.sentry.Send(reportID, userIDFor(req), bs) {
log.Println("Failed to send report to sentry (queue full):", reportID[:8])
log.Println("Failed to send report to sentry (queue full):", reportID)
result = "sentry_failure"
}
log.Printf("Received report %s (%s)", reportID[:8], first)
}
+4 -4
View File
@@ -15,7 +15,7 @@ import (
"io"
"log/slog"
"os"
"path/filepath"
"path"
"runtime"
"slices"
"strings"
@@ -77,7 +77,7 @@ func newInMemoryStore(dir string, flushInterval time.Duration, blobs blob.Store)
slog.Error("Failed to find database in blob storage", "error", cerr)
return s
}
fd, cerr := os.Create(filepath.Join(s.dir, "records.db"))
fd, cerr := os.Create(path.Join(s.dir, "records.db"))
if cerr != nil {
slog.Error("Failed to create database file", "error", cerr)
return s
@@ -257,7 +257,7 @@ func (s *inMemoryStore) write() (err error) {
}
}()
dbf := filepath.Join(s.dir, "records.db")
dbf := path.Join(s.dir, "records.db")
fd, err := os.Create(dbf + ".tmp")
if err != nil {
return err
@@ -340,7 +340,7 @@ func (s *inMemoryStore) write() (err error) {
}
func (s *inMemoryStore) read() (int, error) {
fd, err := os.Open(filepath.Join(s.dir, "records.db"))
fd, err := os.Open(path.Join(s.dir, "records.db"))
if err != nil {
return 0, err
}
+8 -27
View File
@@ -7,8 +7,6 @@
package main
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
)
@@ -115,11 +113,14 @@ var (
)
const (
dbOpGet = "get"
dbOpPut = "put"
dbOpMerge = "merge"
dbResSuccess = "success"
dbResNotFound = "not_found"
dbOpGet = "get"
dbOpPut = "put"
dbOpMerge = "merge"
dbOpDelete = "delete"
dbResSuccess = "success"
dbResNotFound = "not_found"
dbResError = "error"
dbResUnmarshalError = "unmarsh_err"
)
func init() {
@@ -131,24 +132,4 @@ func init() {
databaseOperations, databaseOperationSeconds,
databaseWriteSeconds, databaseLastWritten,
retryAfterLevel)
// Prewarm important counters so they're available with zero values at
// startup
apiRequestsTotal.WithLabelValues(http.MethodGet, "200")
apiRequestsTotal.WithLabelValues(http.MethodGet, "404")
apiRequestsTotal.WithLabelValues(http.MethodPost, "204")
apiRequestsTotal.WithLabelValues(http.MethodPost, "400")
apiRequestsTotal.WithLabelValues(http.MethodPost, "403")
lookupRequestsTotal.WithLabelValues("success")
lookupRequestsTotal.WithLabelValues("not_found_ever")
lookupRequestsTotal.WithLabelValues("not_found_recent")
announceRequestsTotal.WithLabelValues("success")
announceRequestsTotal.WithLabelValues("bad_request")
announceRequestsTotal.WithLabelValues("no_certificate")
replicationSendsTotal.WithLabelValues("success")
replicationRecvsTotal.WithLabelValues("success")
}
+3 -7
View File
@@ -915,14 +915,10 @@ func (u upgradeCmd) Run() error {
case err != nil && !os.IsNotExist(err):
slog.Error("Failed to lock for upgrade", slogutil.Error(err))
os.Exit(1)
case locked || os.IsNotExist(err):
// We got the lock, or the config directory didn't exist, so we
// can do a direct upgrade
err = upgrade.To(release)
default:
// We didn't get the lock, because Syncthing was running, so
// upgrade via REST.
case locked:
err = upgradeViaRest()
default:
err = upgrade.To(release)
}
}
if err != nil {
+1 -1
View File
@@ -233,7 +233,7 @@ func copyStderr(stderr io.Reader, dst io.Writer) {
dst.Write([]byte(line))
if panicFd == nil && (strings.HasPrefix(line, "panic:") || strings.HasPrefix(line, "fatal error:") || strings.HasPrefix(line, "runtime:")) {
if panicFd == nil && (strings.HasPrefix(line, "panic:") || strings.HasPrefix(line, "fatal error:")) {
panicFd, err = os.Create(locations.GetTimestamped(locations.PanicLog))
if err != nil {
slog.Error("Failed to create panic log", slogutil.Error(err))
+5 -3
View File
@@ -42,8 +42,8 @@ require (
github.com/wlynxg/anet v0.0.5
golang.org/x/crypto v0.51.0
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
golang.org/x/net v0.55.0
golang.org/x/sys v0.45.0
golang.org/x/net v0.54.0
golang.org/x/sys v0.44.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
google.golang.org/protobuf v1.36.11
@@ -111,7 +111,9 @@ replace github.com/gobwas/glob v0.2.3 => github.com/calmh/glob v0.0.0-2022061508
replace github.com/jackpal/gateway v1.1.1 => github.com/marbens-arch/gateway v1.1.2-0.20260308173556-c567cc04e7d4
// https://github.com/mattn/go-sqlite3/pull/1338
replace github.com/mattn/go-sqlite3 v1.14.44 => github.com/calmh/go-sqlite3 v1.14.35-0.20260509063420-822b4765116d
// https://github.com/mattn/go-sqlite3/pull/1399
// https://github.com/mattn/go-sqlite3/pull/1400
replace github.com/mattn/go-sqlite3 v1.14.44 => github.com/calmh/go-sqlite3 v1.14.45-0.20260519121030-00c8bf368e65
tool (
github.com/calmh/xdr/cmd/genxdr
+6 -6
View File
@@ -19,8 +19,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/calmh/glob v0.0.0-20220615080505-1d823af5017b h1:Fjm4GuJ+TGMgqfGHN42IQArJb77CfD/mAwLbDUoJe6g=
github.com/calmh/glob v0.0.0-20220615080505-1d823af5017b/go.mod h1:91K7jfEsgJSyfSrX+gmrRfZMtntx6JsHolWubGXDopg=
github.com/calmh/go-sqlite3 v1.14.35-0.20260509063420-822b4765116d h1:liVtRMlDBqDyR4qKkMRIFTdUK6+aoG6Oh97DKnhrCHc=
github.com/calmh/go-sqlite3 v1.14.35-0.20260509063420-822b4765116d/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/calmh/go-sqlite3 v1.14.45-0.20260519121030-00c8bf368e65 h1:4tbv1D+AkdxV4si6cdT3+8lr/c/gJqBdW6yGi8I7z3E=
github.com/calmh/go-sqlite3 v1.14.45-0.20260519121030-00c8bf368e65/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/calmh/incontainer v1.0.0 h1:g2cTUtZuFGmMGX8GoykPkN1Judj2uw8/3/aEtq4Z/rg=
github.com/calmh/incontainer v1.0.0/go.mod h1:eOhqnw15c9X+4RNBe0W3HlUZFfX16O0EDsCOInTndHY=
github.com/calmh/xdr v1.2.0 h1:GaGSNH4ZDw9kNdYqle6+RcAENiaQ8/611Ok+jQbBEeU=
@@ -280,8 +280,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -309,8 +309,8 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"errors"
"iter"
"os"
"path"
"path/filepath"
"sync"
"testing"
@@ -1167,7 +1168,7 @@ func TestOpenSpecialName(t *testing.T) {
// Create a "base" dir that is in the way if the path becomes
// incorrectly truncated in the next steps.
base := filepath.Join(dir, "test")
base := path.Join(dir, "test")
if err := os.Mkdir(base, 0o755); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -16,7 +16,7 @@ import (
// or, somewhere along the way the "+" in the version tag disappeared:
// syncthing v1.23.7-dev.26.gdf7b56ae.dirty-stversionextra "Fermium Flea" (go1.20.5 darwin-arm64) jb@ok.kastelo.net 2023-07-12 06:55:26 UTC [Some Wrapper, purego, stnoupgrade]
var (
longVersionRE = regexp.MustCompile(`syncthing\s+(v[^\s]+)\s+"([^"]+)"\s\(([^\s]+)\s+([^-]+)-([^)]+)\)\s+([^\s]+)[^\[]*(?:\[(.+)\])?`)
longVersionRE = regexp.MustCompile(`syncthing\s+(v[^\s]+)\s+"([^"]+)"\s\(([^\s]+)\s+([^-]+)-([^)]+)\)\s+([^\s]+)[^\[]*(?:\[(.+)\])?$`)
gitExtraRE = regexp.MustCompile(`\.\d+\.g[0-9a-f]+`) // ".1.g6aaae618"
gitExtraSepRE = regexp.MustCompile(`[.-]`) // dot or dash
)
-14
View File
@@ -57,20 +57,6 @@ func TestParseVersion(t *testing.T) {
Extra: []string{"Some Wrapper", "purego", "stnoupgrade"},
},
},
{
longVersion: `2026-05-18 14:53:32 INF syncthing v2.0.3 "Hafnium Hornet" (go1.25.0 darwin-amd64) builder@github.syncthing.net 2025-08-22 07:00:05 UTC [stnoupgrade] (log.pkg=main)`,
parsed: VersionParts{
Version: "v2.0.3",
Tag: "v2.0.3",
Commit: "",
Codename: "Hafnium Hornet",
Runtime: "go1.25.0",
GOOS: "darwin",
GOARCH: "amd64",
Builder: "builder@github.syncthing.net",
Extra: []string{"stnoupgrade"},
},
},
}
for _, tc := range cases {
+3 -5
View File
@@ -13,7 +13,6 @@ import (
"log/slog"
"net"
"net/url"
"slices"
"sync"
"time"
@@ -186,10 +185,9 @@ func (t *tcpListener) WANAddresses() []*url.URL {
t.mut.RUnlock()
// If we support ReusePort, and we are already announcing an unspecified
// address, add an unspecified zero port address, which will be resolved
// by the discovery server in hopes that TCP punch through works.
if dialer.SupportsReusePort && slices.ContainsFunc(uris, func(u *url.URL) bool { return u.Hostname() == "0.0.0.0" }) {
// If we support ReusePort, add an unspecified zero port address, which will be resolved by the discovery server
// in hopes that TCP punch through works.
if dialer.SupportsReusePort {
uri := *t.uri
uri.Host = "0.0.0.0:0"
uris = append([]*url.URL{&uri}, uris...)
+1
View File
@@ -289,6 +289,7 @@ func (c *globalClient) sendAnnouncement(ctx context.Context, timer *time.Timer)
resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
slog.DebugContext(ctx, "announce POST", "server", c.server, "status", resp.Status)
c.setError(errors.New(resp.Status))
if h := resp.Header.Get("Retry-After"); h != "" {
+2 -2
View File
@@ -284,8 +284,8 @@ func NewFilesystem(fsType FilesystemType, uri string, opts ...Option) Filesystem
}
// fs cannot import config or versioner, so we hard code .stfolder
// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath).
var internals = []string{".stfolder", ".stversions"}
// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath)
var internals = []string{".stfolder", ".stignore", ".stversions"}
// IsInternal returns true if the file, as a path relative to the folder
// root, represents an internal file that should always be ignored. The file
+2 -2
View File
@@ -21,8 +21,10 @@ func TestIsInternal(t *testing.T) {
internal bool
}{
{".stfolder", true},
{".stignore", true},
{".stversions", true},
{".stfolder/foo", true},
{".stignore/foo", true},
{".stversions/foo", true},
{".stfolderfoo", false},
@@ -32,8 +34,6 @@ func TestIsInternal(t *testing.T) {
{"foo.stignore", false},
{"foo.stversions", false},
{"foo/.stfolder", false},
{".stignore", false},
{".stignore/foo", false},
{"foo/.stignore", false},
{"foo/.stversions", false},
}
+3 -3
View File
@@ -60,15 +60,15 @@ func TestRecvOnlyRevertDeletes(t *testing.T) {
must(t, m.ScanFolder("ro"))
// We should now have three files and two directories, with global state unchanged.
// We should now have two files and two directories, with global state unchanged.
size = mustV(m.GlobalSize("ro"))
if size.Files != 1 || size.Directories != 1 {
t.Fatalf("Global: expected 1 file and 1 directory: %+v", size)
}
size = mustV(m.LocalSize("ro", protocol.LocalDeviceID))
if size.Files != 3 || size.Directories != 2 {
t.Fatalf("Local: expected 3 files and 2 directories: %+v", size)
if size.Files != 2 || size.Directories != 2 {
t.Fatalf("Local: expected 2 files and 2 directories: %+v", size)
}
size = mustV(m.ReceiveOnlySize("ro"))
if size.Files+size.Directories == 0 {
+2 -2
View File
@@ -20,7 +20,7 @@ import (
"github.com/syncthing/syncthing/lib/events"
"github.com/syncthing/syncthing/lib/protocol"
"github.com/syncthing/syncthing/lib/svcutil"
"github.com/syncthing/syncthing/lib/ur/contract"
"github.com/syncthing/syncthing/lib/ur"
)
type indexHandler struct {
@@ -470,7 +470,7 @@ func (s *indexHandler) logSequenceAnomaly(msg string, extra map[string]any) {
extraStrs[k] = fmt.Sprint(v)
}
s.evLogger.Log(events.Failure, contract.FailureData{
s.evLogger.Log(events.Failure, ur.FailureData{
Description: msg,
Extra: extraStrs,
})
+9 -9
View File
@@ -974,15 +974,13 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
file := "foobar"
contents := []byte("test file contents\n")
basicCheck := func(fs []protocol.FileInfo) protocol.FileInfo {
basicCheck := func(fs []protocol.FileInfo) {
t.Helper()
for _, f := range fs {
if f.Name == file {
return f
}
if len(fs) != 1 {
t.Fatal("expected a single index entry, got", len(fs))
} else if fs[0].Name != file {
t.Fatalf("expected a index entry for %v, got one for %v", file, fs[0].Name)
}
t.Fatalf("expected an index entry for %v, got %v", file, fs)
return protocol.FileInfo{}
}
done := make(chan struct{})
@@ -1003,7 +1001,8 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
done = make(chan struct{})
fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
f := basicCheck(fs)
basicCheck(fs)
f := fs[0]
if !f.IsInvalid() {
t.Errorf("Received non-invalid index update")
}
@@ -1023,7 +1022,8 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
done = make(chan struct{})
fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
f := basicCheck(fs)
basicCheck(fs)
f := fs[0]
if f.IsInvalid() {
t.Errorf("Received invalid index update")
}
-1
View File
@@ -40,7 +40,6 @@ type testfile struct {
type testfileList []testfile
var testdata = testfileList{
{".stignore", 48, "f60db5c36f642f8a6c1a636024330cd4dba6ab965083542f3629ff7d8c547911"},
{"afile", 4, "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
{"dir1", 128, ""},
{filepath.Join("dir1", "dfile"), 5, "49ae93732fcf8d63fe1cce759664982dbd5b23161f007dba8561862adc96d063"},
-13
View File
@@ -282,16 +282,3 @@ func clear(v interface{}, since int) error {
}
return nil
}
type FailureReport struct {
FailureData
Count int
Version string
}
type FailureData struct {
Description string
Goroutines string
Extra map[string]string
}
+26 -14
View File
@@ -23,7 +23,6 @@ import (
"github.com/syncthing/syncthing/lib/events"
"github.com/syncthing/syncthing/lib/svcutil"
"github.com/syncthing/syncthing/lib/tlsutil"
"github.com/syncthing/syncthing/lib/ur/contract"
"github.com/thejerf/suture/v4"
)
@@ -40,10 +39,23 @@ var (
invalidEventDataType = "failure event data is not a string"
)
func FailureDataWithGoroutines(description string) contract.FailureData {
type FailureReport struct {
FailureData
Count int
Version string
}
type FailureData struct {
Description string
Goroutines string
Extra map[string]string
}
func FailureDataWithGoroutines(description string) FailureData {
var buf strings.Builder
pprof.Lookup("goroutine").WriteTo(&buf, 1)
return contract.FailureData{
return FailureData{
Description: description,
Goroutines: buf.String(),
Extra: make(map[string]string),
@@ -74,7 +86,7 @@ type failureHandler struct {
type failureStat struct {
first, last time.Time
count int
data contract.FailureData
data FailureData
}
func (h *failureHandler) Serve(ctx context.Context) error {
@@ -93,24 +105,24 @@ func (h *failureHandler) Serve(ctx context.Context) error {
if !ok {
// Just to be safe - shouldn't ever happen, as
// evChan is set to nil when unsubscribing.
h.addReport(contract.FailureData{Description: evChanClosed}, time.Now())
h.addReport(FailureData{Description: evChanClosed}, time.Now())
evChan = nil
continue
}
var data contract.FailureData
var data FailureData
switch d := e.Data.(type) {
case string:
data.Description = d
case contract.FailureData:
case FailureData:
data = d
default:
// Same here, shouldn't ever happen.
h.addReport(contract.FailureData{Description: invalidEventDataType}, time.Now())
h.addReport(FailureData{Description: invalidEventDataType}, time.Now())
continue
}
h.addReport(data, e.Time)
case <-timer.C:
reports := make([]contract.FailureReport, 0, len(h.buf))
reports := make([]FailureReport, 0, len(h.buf))
now := time.Now()
for descr, stat := range h.buf {
if now.Sub(stat.last) > minDelay || now.Sub(stat.first) > maxDelay {
@@ -140,7 +152,7 @@ func (h *failureHandler) Serve(ctx context.Context) error {
if sub != nil {
sub.Unsubscribe()
if len(h.buf) > 0 {
reports := make([]contract.FailureReport, 0, len(h.buf))
reports := make([]FailureReport, 0, len(h.buf))
for _, stat := range h.buf {
reports = append(reports, newFailureReport(stat))
}
@@ -167,7 +179,7 @@ func (h *failureHandler) applyOpts(opts config.OptionsConfiguration, sub events.
return url, nil, nil
}
func (h *failureHandler) addReport(data contract.FailureData, evTime time.Time) {
func (h *failureHandler) addReport(data FailureData, evTime time.Time) {
if stat, ok := h.buf[data.Description]; ok {
stat.last = evTime
stat.count++
@@ -192,7 +204,7 @@ func (*failureHandler) String() string {
return "FailureHandler"
}
func sendFailureReports(ctx context.Context, reports []contract.FailureReport, url string) {
func sendFailureReports(ctx context.Context, reports []FailureReport, url string) {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(reports); err != nil {
panic(err)
@@ -223,8 +235,8 @@ func sendFailureReports(ctx context.Context, reports []contract.FailureReport, u
resp.Body.Close()
}
func newFailureReport(stat *failureStat) contract.FailureReport {
return contract.FailureReport{
func newFailureReport(stat *failureStat) FailureReport {
return FailureReport{
FailureData: stat.data,
Count: stat.count,
Version: build.LongVersion,
-55
View File
@@ -15,7 +15,6 @@ import (
"github.com/syncthing/syncthing/lib/build"
"github.com/syncthing/syncthing/lib/config"
"github.com/syncthing/syncthing/lib/fs"
)
func TestTaggedFilename(t *testing.T) {
@@ -257,57 +256,3 @@ func TestArchiveFoldersCreationPermission(t *testing.T) {
t.Errorf("földer2 permissions %v, want %v", folder2VersionsInfo.Mode(), folder2Perms)
}
}
func TestDupDirTreeWritePermissions(t *testing.T) {
// The structure should be replicated, with user permission bits set along the way
srcFs := fs.NewFilesystem(fs.FilesystemTypeFake, "TestDupDirTreeWritePermissions/srcFs")
dstFs := fs.NewFilesystem(fs.FilesystemTypeFake, "TestDupDirTreeWritePermissions/dstFs")
// A source dir to duplicate
_ = srcFs.Mkdir("foo", 0o444)
_ = srcFs.Mkdir("foo/bar", 0o555)
_ = srcFs.Mkdir("foo/bar/baz", 0o000)
// Duplication should succeed
if err := dupDirTree(srcFs, dstFs, "foo/bar/baz"); err != nil {
t.Fatal(err)
}
// Permissions should be the same, but with read/write/execute bits for
// the user
if info, err := dstFs.Lstat("foo"); err != nil || info.Mode() != 0o744 {
t.Fatalf("foo: 0o%o", info.Mode())
}
if info, err := dstFs.Lstat("foo/bar"); err != nil || info.Mode() != 0o755 {
t.Fatalf("foo/bar: 0o%o", info.Mode())
}
if info, err := dstFs.Lstat("foo/bar/baz"); err != nil || info.Mode() != 0o700 {
t.Fatalf("foo/bar/baz: 0o%o", info.Mode())
}
}
func TestDupDirFastPath(t *testing.T) {
srcFs := fs.NewFilesystem(fs.FilesystemTypeFake, "TestDupDirFastPath/srcFs")
dstFs := fs.NewFilesystem(fs.FilesystemTypeFake, "TestDupDirFastPath/dstFs")
// A source dir to duplicate
_ = srcFs.Mkdir("foo", 0o444)
_ = srcFs.Mkdir("foo/bar", 0o555)
_ = srcFs.Mkdir("foo/bar/baz", 0o000)
// The destination exists, but with too few permission bits
_ = dstFs.MkdirAll("foo/bar/baz", 0o555)
// Duplication should succeed
if err := dupDirTree(srcFs, dstFs, "foo/bar/baz"); err != nil {
t.Fatal(err)
}
// Permissions for the destination should have been updated. (This
// differs from what would have been created by the duplication of the
// 0o000 dir in the src, because it already existed.)
if info, err := dstFs.Lstat("foo/bar/baz"); err != nil || info.Mode() != 0o755 {
t.Fatalf("foo/bar/baz: 0o%o", info.Mode())
}
}
+39 -69
View File
@@ -192,78 +192,48 @@ func archiveFile(method fs.CopyRangeMethod, srcFs, dstFs fs.Filesystem, filePath
return err
}
// dupDirTree ensures folderPath exists in dstFs, copying permissions mostly
// from srcFs. Permissions are altered to have the user read, write, and
// execute bits set so that Syncthing file operations are possible within
// the destination directory.
//
// We want to retain the source group and other bits so that we do not
// inadvertently open up a directory for users who shouldn't have access to
// it, but we do not consider it a security issue to open up the permissions
// for the current user.
//
// This is based on os.MkdirAll with our srcFs adjustments.
func dupDirTree(srcFs, dstFs fs.Filesystem, path string) error {
const (
allPerms = 0o777
minDirPerms = 0o700
)
// Fast path: if we can tell whether path is a directory or file, stop with success or error.
if dir, err := dstFs.Lstat(path); err == nil {
if !dir.IsDir() {
return errors.New("destination exists but is not a directory")
}
if dir.Mode()&minDirPerms != minDirPerms {
// We want all the required permission bits set
_ = dstFs.Chmod(path, dir.Mode()&allPerms|minDirPerms)
}
return nil
}
// Slow path: make sure parent exists and then call Mkdir for path.
// Extract the parent folder from path by first removing any trailing
// path separator and then scanning backward until finding a path
// separator or reaching the beginning of the string.
i := len(path) - 1
for i >= 0 && os.IsPathSeparator(path[i]) {
i--
}
for i >= 0 && !os.IsPathSeparator(path[i]) {
i--
}
if i < 0 {
i = 0
}
// If there is a parent directory, and it is not the volume name,
// recurse to ensure parent directory exists.
if parent := path[:i]; len(parent) > len(filepath.VolumeName(path)) {
if err := dupDirTree(srcFs, dstFs, parent); err != nil {
return err
}
}
// Parent now exists; invoke Mkdir and use its result.
srcPerms := fs.FileMode(minDirPerms)
if srcDir, err := srcFs.Lstat(path); err == nil {
srcPerms = srcDir.Mode()&allPerms | minDirPerms
}
if err := dstFs.Mkdir(path, srcPerms); err != nil {
// Handle arguments like "foo/." by
// double-checking that directory doesn't exist.
dir, err1 := dstFs.Lstat(path)
if err1 == nil && dir.IsDir() {
return nil
}
func dupDirTree(srcFs, dstFs fs.Filesystem, folderPath string) error {
// Return early if the folder already exists.
_, err := dstFs.Stat(folderPath)
if err == nil || !fs.IsNotExist(err) {
return err
}
hadParent := true
for i := range folderPath {
if os.IsPathSeparator(folderPath[i]) {
// If the parent folder didn't exist, then this folder doesn't exist
// so we can skip the check
if hadParent {
_, err := dstFs.Stat(folderPath[:i])
if err == nil {
continue
}
if !fs.IsNotExist(err) {
return err
}
}
hadParent = false
err := dupDirWithPerms(srcFs, dstFs, folderPath[:i])
if err != nil {
return err
}
}
}
return dupDirWithPerms(srcFs, dstFs, folderPath)
}
// Extra chmod to ensure our permissions override umask
_ = dstFs.Chmod(path, srcPerms)
return nil
func dupDirWithPerms(srcFs, dstFs fs.Filesystem, folderPath string) error {
srcStat, err := srcFs.Stat(folderPath)
if err != nil {
return err
}
// If we call Mkdir with srcStat.Mode(), we won't get the expected perms because of umask
// So, we create the folder with 0700, and then change the perms to the srcStat.Mode()
err = dstFs.Mkdir(folderPath, 0o700)
if err != nil {
return err
}
return dstFs.Chmod(folderPath, srcStat.Mode())
}
func restoreFile(method fs.CopyRangeMethod, src, dst fs.Filesystem, filePath string, versionTime time.Time, tagger fileTagger) error {
-48
View File
@@ -1,48 +0,0 @@
# Local Syncthing Patches
Apply the local patches manually after pulling a new upstream Syncthing release:
```bash
git apply patches/sync-stignore.patch patches/webui-build-marker.patch
go run build.go -build-out bin/syncthing-stignore build syncthing
```
The automated release flow uses:
```bash
./scripts/update-custom-release.sh
```
By default the script finds the latest stable upstream tag, creates a local
`custom/<version>-<suffix>` branch, applies all local patches, removes upstream
GitHub/Gitea workflow files from the release commit, tags
`<upstream>-stignore.6`, regenerates embedded GUI assets, runs focused tests,
and writes build artifacts to `dist/`.
Useful options:
```bash
CUSTOM_RELEASE_UPSTREAM_TAG=v2.1.0 ./scripts/update-custom-release.sh
CUSTOM_RELEASE_FORCE=1 ./scripts/update-custom-release.sh
CUSTOM_RELEASE_PATCHES="patches/sync-stignore.patch patches/webui-build-marker.patch" ./scripts/update-custom-release.sh
CUSTOM_RELEASE_PUSH=1 CUSTOM_RELEASE_REMOTE=gitea ./scripts/update-custom-release.sh
CUSTOM_RELEASE_PUSH=1 CUSTOM_RELEASE_PUSH_BRANCH=1 CUSTOM_RELEASE_REMOTE=gitea ./scripts/update-custom-release.sh
CUSTOM_RELEASE_CREATE_GITEA_RELEASE=1 CUSTOM_RELEASE_TEA_REPO=felixfoertsch/syncthing ./scripts/update-custom-release.sh
CUSTOM_RELEASE_BUILDS="darwin/amd64/zip darwin/arm64/zip linux/amd64/tar linux/arm64/tar" ./scripts/update-custom-release.sh
```
The Gitea workflow in `.gitea/workflows/custom-release.yml` runs the script when
the local patchset changes on `felix/release-automation`, on a schedule, and on
manual dispatch. The repository's `main` branch stays a clean upstream mirror.
The workflow detects the latest upstream Syncthing stable tag, exits if the
matching custom tag already exists, pushes only the `<upstream>-stignore.6` tag
by default, and publishes the Gitea release assets.
The local `custom/<version>` branch is only pushed when
`CUSTOM_RELEASE_PUSH_BRANCH=1` is set. The workflow builds macOS amd64, macOS
arm64, Linux amd64, and Linux arm64 archives.
The patch makes root-level `.stignore` sync like regular folder content while
keeping `.stfolder` and `.stversions` protected as internal Syncthing paths.
The web UI patch adds `It syncs .stignore now!` to the GUI footer so the custom
binary is visually distinguishable from upstream builds. The release test fails
if the generated GUI asset bundle does not contain that marker.
-119
View File
@@ -1,119 +0,0 @@
diff --git a/lib/fs/filesystem.go b/lib/fs/filesystem.go
index 31bbb9770..dcece778e 100644
--- a/lib/fs/filesystem.go
+++ b/lib/fs/filesystem.go
@@ -284,8 +284,8 @@ func NewFilesystem(fsType FilesystemType, uri string, opts ...Option) Filesystem
}
// fs cannot import config or versioner, so we hard code .stfolder
-// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath)
-var internals = []string{".stfolder", ".stignore", ".stversions"}
+// (config.DefaultMarkerName) and .stversions (versioner.DefaultPath).
+var internals = []string{".stfolder", ".stversions"}
// IsInternal returns true if the file, as a path relative to the folder
// root, represents an internal file that should always be ignored. The file
diff --git a/lib/fs/filesystem_test.go b/lib/fs/filesystem_test.go
index 08e45ff50..2d100a2b0 100644
--- a/lib/fs/filesystem_test.go
+++ b/lib/fs/filesystem_test.go
@@ -21,10 +21,8 @@ func TestIsInternal(t *testing.T) {
internal bool
}{
{".stfolder", true},
- {".stignore", true},
{".stversions", true},
{".stfolder/foo", true},
- {".stignore/foo", true},
{".stversions/foo", true},
{".stfolderfoo", false},
@@ -34,6 +32,8 @@ func TestIsInternal(t *testing.T) {
{"foo.stignore", false},
{"foo.stversions", false},
{"foo/.stfolder", false},
+ {".stignore", false},
+ {".stignore/foo", false},
{"foo/.stignore", false},
{"foo/.stversions", false},
}
diff --git a/lib/model/folder_recvonly_test.go b/lib/model/folder_recvonly_test.go
index e84d0c49d..fbd442966 100644
--- a/lib/model/folder_recvonly_test.go
+++ b/lib/model/folder_recvonly_test.go
@@ -60,15 +60,15 @@ func TestRecvOnlyRevertDeletes(t *testing.T) {
must(t, m.ScanFolder("ro"))
- // We should now have two files and two directories, with global state unchanged.
+ // We should now have three files and two directories, with global state unchanged.
size = mustV(m.GlobalSize("ro"))
if size.Files != 1 || size.Directories != 1 {
t.Fatalf("Global: expected 1 file and 1 directory: %+v", size)
}
size = mustV(m.LocalSize("ro", protocol.LocalDeviceID))
- if size.Files != 2 || size.Directories != 2 {
- t.Fatalf("Local: expected 2 files and 2 directories: %+v", size)
+ if size.Files != 3 || size.Directories != 2 {
+ t.Fatalf("Local: expected 3 files and 2 directories: %+v", size)
}
size = mustV(m.ReceiveOnlySize("ro"))
if size.Files+size.Directories == 0 {
diff --git a/lib/model/requests_test.go b/lib/model/requests_test.go
index 288140721..6f8124e97 100644
--- a/lib/model/requests_test.go
+++ b/lib/model/requests_test.go
@@ -974,13 +974,15 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
file := "foobar"
contents := []byte("test file contents\n")
- basicCheck := func(fs []protocol.FileInfo) {
+ basicCheck := func(fs []protocol.FileInfo) protocol.FileInfo {
t.Helper()
- if len(fs) != 1 {
- t.Fatal("expected a single index entry, got", len(fs))
- } else if fs[0].Name != file {
- t.Fatalf("expected a index entry for %v, got one for %v", file, fs[0].Name)
+ for _, f := range fs {
+ if f.Name == file {
+ return f
+ }
}
+ t.Fatalf("expected an index entry for %v, got %v", file, fs)
+ return protocol.FileInfo{}
}
done := make(chan struct{})
@@ -1001,8 +1003,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
done = make(chan struct{})
fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
- basicCheck(fs)
- f := fs[0]
+ f := basicCheck(fs)
if !f.IsInvalid() {
t.Errorf("Received non-invalid index update")
}
@@ -1022,8 +1023,7 @@ func TestIgnoreDeleteUnignore(t *testing.T) {
done = make(chan struct{})
fc.setIndexFn(func(_ context.Context, folder string, fs []protocol.FileInfo) error {
- basicCheck(fs)
- f := fs[0]
+ f := basicCheck(fs)
if f.IsInvalid() {
t.Errorf("Received invalid index update")
}
diff --git a/lib/scanner/walk_test.go b/lib/scanner/walk_test.go
index d75dddaf3..3693ae4db 100644
--- a/lib/scanner/walk_test.go
+++ b/lib/scanner/walk_test.go
@@ -40,6 +40,7 @@ type testfile struct {
type testfileList []testfile
var testdata = testfileList{
+ {".stignore", 48, "f60db5c36f642f8a6c1a636024330cd4dba6ab965083542f3629ff7d8c547911"},
{"afile", 4, "b5bb9d8014a0f9b1d61e21e796d78dccdf1352f23cd32812f4850b878ae4944c"},
{"dir1", 128, ""},
{filepath.Join("dir1", "dfile"), 5, "49ae93732fcf8d63fe1cce759664982dbd5b23161f007dba8561862adc96d063"},
-62
View File
@@ -1,62 +0,0 @@
diff --git a/gui/default/index.html b/gui/default/index.html
index e3e573c9a..5ec76e740 100644
--- a/gui/default/index.html
+++ b/gui/default/index.html
@@ -1017,6 +1017,9 @@
</div> <!-- /row -->
</div> <!-- /container -->
+ <footer class="container text-center text-muted small" aria-label="Custom build marker">
+ It syncs .stignore now!
+ </footer>
</div> <!-- /ng-cloak -->
<ng-include src="'syncthing/core/networkErrorDialogView.html'"></ng-include>
diff --git a/lib/api/auto/custom_marker_test.go b/lib/api/auto/custom_marker_test.go
new file mode 100644
index 000000000..73eae3db7
--- /dev/null
+++ b/lib/api/auto/custom_marker_test.go
@@ -0,0 +1,42 @@
+// Copyright (C) 2026 The Syncthing Authors.
+//
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at https://mozilla.org/MPL/2.0/.
+
+package auto
+
+import (
+ "compress/gzip"
+ "io"
+ "strings"
+ "testing"
+)
+
+const customBuildMarker = "It syncs .stignore now!"
+
+func TestCustomBuildMarkerIsEmbedded(t *testing.T) {
+ asset, ok := Assets()["default/index.html"]
+ if !ok {
+ t.Fatal("default/index.html is missing from embedded GUI assets")
+ }
+
+ content := asset.Content
+ if asset.Gzipped {
+ reader, err := gzip.NewReader(strings.NewReader(asset.Content))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer reader.Close()
+
+ data, err := io.ReadAll(reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ content = string(data)
+ }
+
+ if !strings.Contains(content, customBuildMarker) {
+ t.Fatalf("embedded GUI assets do not contain custom build marker %q", customBuildMarker)
+ }
+}
@@ -1,180 +0,0 @@
#!/usr/bin/env bats
setup() {
REPO_ROOT="$(git rev-parse --show-toplevel)"
WORKFLOW="$REPO_ROOT/.gitea/workflows/custom-release.yml"
RELEASE_SCRIPT="$REPO_ROOT/scripts/update-custom-release.sh"
}
@test "custom release runs as one job on ffmini macos runner" {
run rg -n 'runs-on:[[:space:]]*ffmini_macos_arm64' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'ubuntu-latest' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'actions/upload-artifact' "$WORKFLOW"
[ "$status" -ne 0 ]
}
@test "custom release tea login setup is idempotent on persistent host runner" {
run rg -n 'tea" logins delete actions >/dev/null 2>&1 \|\| true' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'tea" logins add --name actions' "$WORKFLOW"
[ "$status" -eq 0 ]
}
@test "custom release workflow imports developer id signing material into temporary keychain" {
run rg -n 'DEVELOPER_ID_APPLICATION_P12_BASE64' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'DEVELOPER_ID_APPLICATION_P12_PASSWORD' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'DEVELOPER_ID_APPLICATION_P12_BASE64 secret is required' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'DEVELOPER_ID_APPLICATION_P12_PASSWORD secret is required' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'security import .* -P "\$DEVELOPER_ID_APPLICATION_P12_PASSWORD"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'security import .* -A ' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'security find-identity -v -p codesigning "\$keychain_path"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'security default-keychain -d user -s "\$keychain_path"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'previous_default_keychain=' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN=\$previous_default_keychain' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_PREVIOUS_DYNAMIC_DEFAULT_KEYCHAIN' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'security default-keychain -s "\$CUSTOM_RELEASE_PREVIOUS_DEFAULT_KEYCHAIN"' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'security default-keychain -d dynamic' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'security find-identity -v -p codesigning$' "$WORKFLOW" "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'codesign_identity_sha1=' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'sed -n .*Developer ID Application' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY=\$codesign_identity' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY_SHA1=\$codesign_identity_sha1' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_KEYCHAIN_PASSWORD=\$keychain_password' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'codesign --force --dryrun --sign "\$codesign_identity" --keychain "\$keychain_path" --options runtime --timestamp "\$probe_binary"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY: "Developer ID Application' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'security create-keychain' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'keychain_dir="\$HOME/Library/Keychains"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'rm -f "\$keychain_path"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'existing_keychains=\(\)' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'security list-keychains -s "\$keychain_path"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'security list-keychains -d dynamic' "$WORKFLOW"
[ "$status" -ne 0 ]
run rg -n 'security import' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'security delete-keychain' "$WORKFLOW"
[ "$status" -eq 0 ]
}
@test "custom release carries per-target cgo mode" {
run rg -n 'darwin/arm64/zip/1' "$WORKFLOW" "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'linux/amd64/tar/0' "$WORKFLOW" "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_CGO_ENABLED' "$WORKFLOW"
[ "$status" -ne 0 ]
}
@test "custom release signs darwin assets with hardened runtime and timestamp" {
run rg -n 'codesign_args=\(--force --sign "\$codesign_identity"\)' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n -- '--keychain "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'codesign_args\+=\(--options runtime --timestamp\)' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'security unlock-keychain -p "\$CUSTOM_RELEASE_KEYCHAIN_PASSWORD" "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'security find-identity -v -p codesigning "\$CUSTOM_RELEASE_KEYCHAIN_PATH"' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run awk '
/codesign_args=\(--force --sign "\$codesign_identity"\)/ { sign = NR }
/codesign_args\+=\(--keychain "\$CUSTOM_RELEASE_KEYCHAIN_PATH"\)/ { keychain = NR }
/codesign_args\+=\(--options runtime --timestamp\)/ { options = NR }
END { exit !(sign && keychain && options && sign < keychain && keychain < options) }
' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'Developer ID Application' "$WORKFLOW" "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_CODESIGN_IDENTITY' "$WORKFLOW" "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
}
@test "custom release validates signed darwin binaries before publishing" {
run rg -n 'modernc-sqlite' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'codesign --verify --strict --verbose=2' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'TeamIdentifier=NG5W75WE8U|TeamIdentifier.*NG5W75WE8U' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT: "0"' "$WORKFLOW"
[ "$status" -eq 0 ]
run rg -n 'require_gatekeeper_assessment="\$\{CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT:-0\}"' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'if \[\[ "\$require_gatekeeper_assessment" == "1" \]\]; then' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
run rg -n 'spctl -a -vv --type execute' "$RELEASE_SCRIPT"
[ "$status" -eq 0 ]
}
-416
View File
@@ -1,416 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Description: create a patched Syncthing release from the latest upstream tag.
# Usage: ./scripts/update-custom-release.sh
upstream_url="${CUSTOM_RELEASE_UPSTREAM_URL:-https://github.com/syncthing/syncthing.git}"
upstream_tag="${CUSTOM_RELEASE_UPSTREAM_TAG:-}"
suffix="${CUSTOM_RELEASE_SUFFIX:-stignore.7}"
branch_prefix="${CUSTOM_RELEASE_BRANCH_PREFIX:-custom}"
dist_dir="${CUSTOM_RELEASE_DIST_DIR:-dist}"
target="${CUSTOM_RELEASE_TARGET:-syncthing}"
archive_kind="${CUSTOM_RELEASE_ARCHIVE:-tar}"
build_specs="${CUSTOM_RELEASE_BUILDS:-}"
default_cgo_enabled="${CUSTOM_RELEASE_CGO_ENABLED:-0}"
codesign_identity="${CUSTOM_RELEASE_CODESIGN_IDENTITY:-}"
codesign_team_id="${CUSTOM_RELEASE_CODESIGN_TEAM_ID:-NG5W75WE8U}"
require_gatekeeper_assessment="${CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT:-0}"
push_release="${CUSTOM_RELEASE_PUSH:-0}"
push_branch="${CUSTOM_RELEASE_PUSH_BRANCH:-0}"
push_remote="${CUSTOM_RELEASE_REMOTE:-origin}"
run_tests="${CUSTOM_RELEASE_TEST:-1}"
force="${CUSTOM_RELEASE_FORCE:-0}"
publish_gitea_release="${CUSTOM_RELEASE_CREATE_GITEA_RELEASE:-0}"
tea_repo="${CUSTOM_RELEASE_TEA_REPO:-}"
patch_tmp_dir=""
patch_files=()
assets_rebuilt=0
if [[ -n "${CUSTOM_RELEASE_PATCHES:-}" ]]; then
read -r -a patch_files <<< "$CUSTOM_RELEASE_PATCHES"
elif [[ -n "${CUSTOM_RELEASE_PATCH:-}" ]]; then
patch_files=("$CUSTOM_RELEASE_PATCH")
else
patch_files=(
patches/sync-stignore.patch
patches/webui-build-marker.patch
)
fi
log() {
printf '%s\n' "$*"
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
require_clean_worktree() {
if [[ -n "$(git status --porcelain)" ]]; then
die "working tree has uncommitted changes"
fi
}
latest_stable_tag() {
git ls-remote --refs --tags --sort='version:refname' "$upstream_url" 'v[0-9]*' \
| awk '{ tag = $2; sub("refs/tags/", "", tag); if (tag ~ /^v[0-9]+\.[0-9]+\.[0-9]+$/) latest = tag } END { print latest }'
}
tag_exists() {
local tag="$1"
if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
return 0
fi
git ls-remote --quiet --exit-code --tags "$push_remote" "refs/tags/$tag" >/dev/null 2>&1
}
copy_patches_to_temp() {
local patch_tmp_dir="$1"
local patch_file
local patch_name
local patch_index=0
mkdir -p "$patch_tmp_dir"
for patch_file in "${patch_files[@]}"; do
[[ -f "$patch_file" ]] || die "patch file not found: $patch_file"
printf -v patch_name '%03d-%s' "$patch_index" "$(basename "$patch_file")"
cp "$patch_file" "$patch_tmp_dir/$patch_name"
patch_index=$((patch_index + 1))
done
}
fetch_upstream_tag() {
local tag="$1"
git fetch --force "$upstream_url" "refs/tags/$tag:refs/tags/$tag"
}
create_release_commit() {
local tag="$1"
local custom_tag="$2"
local branch="$3"
local patch_tmp_dir="$4"
local patch_file
git checkout -B "$branch" "$tag"
rm -rf "$dist_dir"
for patch_file in "$patch_tmp_dir"/*; do
[[ -f "$patch_file" ]] || continue
log "Applying $(basename "$patch_file")"
git apply --3way "$patch_file"
done
remove_upstream_workflows
git add -A
git commit -m "apply local Syncthing patches for $tag"
git tag -a "$custom_tag" -m "Syncthing $tag with local patches"
}
remove_upstream_workflows() {
local workflow_dir
for workflow_dir in .github/workflows .gitea/workflows; do
if [[ -e "$workflow_dir" ]]; then
log "Removing upstream workflow directory $workflow_dir from release commit"
rm -rf "$workflow_dir"
fi
done
}
delete_local_tag_if_forced() {
local tag="$1"
if [[ "$force" != "1" ]]; then
return
fi
if git rev-parse --quiet --verify "refs/tags/$tag" >/dev/null; then
git tag -d "$tag"
fi
}
test_release() {
if [[ "$run_tests" != "1" ]]; then
log "Skipping tests because CUSTOM_RELEASE_TEST=$run_tests"
return
fi
rebuild_assets_once
go test ./lib/api/auto ./lib/fs ./lib/ignore ./lib/scanner ./lib/model
}
rebuild_assets_once() {
if [[ "$assets_rebuilt" == "1" ]]; then
return
fi
log "Regenerating embedded GUI assets"
go run build.go assets
assets_rebuilt=1
}
format_build_specs() {
local spec
for spec in $build_specs; do
printf -- '- %s\n' "$spec"
done
}
build_release() {
local custom_tag="$1"
rebuild_assets_once
rm -rf "$dist_dir"
mkdir -p "$dist_dir"
if [[ -z "$build_specs" ]]; then
build_specs="$(go env GOOS)/$(go env GOARCH)/$archive_kind"
fi
local spec
for spec in $build_specs; do
build_one "$custom_tag" "$spec"
done
cat > "$dist_dir/release-notes.md" <<EOF
# $custom_tag
Syncthing $upstream_tag with the local patches applied.
Builds:
$(format_build_specs)
Patches:
$(printf -- '- %s\n' "${patch_files[@]}")
Patch behavior:
- Root-level .stignore syncs like regular folder content.
- .stfolder and .stversions stay protected as Syncthing internals.
- The web GUI footer includes "It syncs .stignore now!" as a custom build marker.
EOF
(
cd "$dist_dir"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum ./* > SHA256SUMS
else
shasum -a 256 ./* > SHA256SUMS
fi
)
}
build_one() {
local custom_tag="$1"
local spec="$2"
local goos
local goarch
local kind
local cgo_enabled
IFS=/ read -r goos goarch kind cgo_enabled <<< "$spec"
[[ -n "$goos" && -n "$goarch" && -n "$kind" ]] || die "invalid build spec: $spec"
cgo_enabled="${cgo_enabled:-$default_cgo_enabled}"
log "Building $target for $goos/$goarch as $kind with CGO_ENABLED=$cgo_enabled"
case "$kind" in
tar|zip)
local archive
archive="$(CGO_ENABLED="$cgo_enabled" go run build.go -version "$custom_tag" -goos "$goos" -goarch "$goarch" "$kind" "$target" | tail -n 1)"
if [[ "$goos" == "darwin" ]]; then
sign_and_validate_darwin_archive "$archive" "$kind"
fi
mv "$archive" "$dist_dir/"
;;
binary)
CGO_ENABLED="$cgo_enabled" go run build.go -version "$custom_tag" -goos "$goos" -goarch "$goarch" -build-out "$dist_dir/$target-$goos-$goarch" build "$target"
if [[ "$goos" == "darwin" ]]; then
sign_and_validate_darwin_binary "$dist_dir/$target-$goos-$goarch"
fi
;;
*)
die "unknown build archive kind in $spec"
;;
esac
}
find_release_binary() {
local root="$1"
local candidate
while IFS= read -r candidate; do
if [[ -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return
fi
done < <(find "$root" -type f -name "$target")
die "could not find executable $target in $root"
}
sign_and_validate_darwin_archive() {
local archive="$1"
local kind="$2"
local tmp
local archive_abs
local binary
[[ -n "$codesign_identity" ]] || die "CUSTOM_RELEASE_CODESIGN_IDENTITY is required for darwin builds"
tmp="$(mktemp -d)"
archive_abs="$(cd "$(dirname "$archive")" && pwd -P)/$(basename "$archive")"
case "$kind" in
zip)
unzip -q "$archive_abs" -d "$tmp"
;;
tar)
tar -xf "$archive_abs" -C "$tmp"
;;
*)
die "cannot sign archive kind $kind"
;;
esac
binary="$(find_release_binary "$tmp")"
sign_and_validate_darwin_binary "$binary"
rm -f "$archive_abs"
case "$kind" in
zip)
(
cd "$tmp"
zip -qr "$archive_abs" .
)
;;
tar)
(
cd "$tmp"
tar -czf "$archive_abs" .
)
;;
esac
rm -rf "$tmp"
}
sign_and_validate_darwin_binary() {
local binary="$1"
local version_output
local codesign_details
local codesign_args
[[ -n "$codesign_identity" ]] || die "CUSTOM_RELEASE_CODESIGN_IDENTITY is required for darwin builds"
codesign_args=(--force --sign "$codesign_identity")
if [[ -n "${CUSTOM_RELEASE_KEYCHAIN_PATH:-}" ]]; then
if [[ -n "${CUSTOM_RELEASE_KEYCHAIN_PASSWORD:-}" ]]; then
security unlock-keychain -p "$CUSTOM_RELEASE_KEYCHAIN_PASSWORD" "$CUSTOM_RELEASE_KEYCHAIN_PATH"
fi
security find-identity -v -p codesigning "$CUSTOM_RELEASE_KEYCHAIN_PATH"
security find-identity -v -p codesigning
codesign_args+=(--keychain "$CUSTOM_RELEASE_KEYCHAIN_PATH")
fi
codesign_args+=(--options runtime --timestamp)
codesign "${codesign_args[@]}" "$binary"
version_output="$("$binary" --version)"
if [[ "$version_output" == *modernc-sqlite* ]]; then
die "darwin build unexpectedly reports [modernc-sqlite]: $version_output"
fi
codesign --verify --strict --verbose=2 "$binary"
codesign_details="$(codesign -dv --verbose=4 "$binary" 2>&1)"
if [[ "$codesign_team_id" == "NG5W75WE8U" && "$codesign_details" != *"TeamIdentifier=NG5W75WE8U"* ]]; then
printf '%s\n' "$codesign_details" >&2
die "darwin build is not signed by TeamIdentifier=NG5W75WE8U"
fi
if [[ "$codesign_details" != *"TeamIdentifier=$codesign_team_id"* ]]; then
printf '%s\n' "$codesign_details" >&2
die "darwin build is not signed by TeamIdentifier=$codesign_team_id"
fi
if [[ "$require_gatekeeper_assessment" == "1" ]]; then
spctl -a -vv --type execute "$binary"
else
log "Skipping Gatekeeper assessment because CUSTOM_RELEASE_REQUIRE_GATEKEEPER_ASSESSMENT=$require_gatekeeper_assessment"
fi
}
push_refs() {
local branch="$1"
local custom_tag="$2"
if [[ "$push_release" != "1" ]]; then
log "Skipping push because CUSTOM_RELEASE_PUSH=$push_release"
return
fi
if [[ "$push_branch" == "1" ]]; then
git push "$push_remote" "$branch"
else
log "Skipping release branch push because CUSTOM_RELEASE_PUSH_BRANCH=$push_branch"
fi
git push "$push_remote" "$custom_tag"
}
publish_release() {
local custom_tag="$1"
if [[ "$publish_gitea_release" != "1" ]]; then
log "Skipping Gitea release publishing because CUSTOM_RELEASE_CREATE_GITEA_RELEASE=$publish_gitea_release"
return
fi
command -v tea >/dev/null 2>&1 || die "tea is required for Gitea release publishing"
local args=(releases create "$custom_tag" --title "$custom_tag" --note-file "$dist_dir/release-notes.md")
if [[ -n "$tea_repo" ]]; then
args+=(--repo "$tea_repo")
else
args+=(--remote "$push_remote")
fi
local asset
for asset in "$dist_dir"/*; do
[[ -f "$asset" ]] || continue
[[ "$(basename "$asset")" == "release-notes.md" ]] && continue
args+=(--asset "$asset")
done
tea "${args[@]}"
}
main() {
require_clean_worktree
if [[ -z "$upstream_tag" ]]; then
upstream_tag="$(latest_stable_tag)"
fi
[[ -n "$upstream_tag" ]] || die "could not determine latest upstream tag"
local custom_tag="${upstream_tag}-${suffix}"
local branch="${branch_prefix}/${upstream_tag#v}-${suffix}"
if tag_exists "$custom_tag" && [[ "$force" != "1" ]]; then
log "Custom release $custom_tag already exists; nothing to do."
return
fi
patch_tmp_dir="$(mktemp -d)"
trap 'rm -rf "$patch_tmp_dir"' EXIT
copy_patches_to_temp "$patch_tmp_dir"
fetch_upstream_tag "$upstream_tag"
delete_local_tag_if_forced "$custom_tag"
create_release_commit "$upstream_tag" "$custom_tag" "$branch" "$patch_tmp_dir"
test_release
build_release "$custom_tag"
push_refs "$branch" "$custom_tag"
publish_release "$custom_tag"
log "Built custom release $custom_tag from upstream $upstream_tag"
log "Artifacts are in $dist_dir/"
}
main "$@"