diff --git a/internals/httputil/certloader_linux.go b/internals/httputil/certloader_linux.go
new file mode 100644
index 000000000..fd167d53f
--- /dev/null
+++ b/internals/httputil/certloader_linux.go
@@ -0,0 +1,34 @@
+// Code generated by tools/gencertloader from crypto/x509/root_linux.go (go1.26.3); DO NOT EDIT.
+
+// Copyright 2015 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package httputil
+
+// import "internal/goos"
+
+// Possible certificate files; stop after finding one.
+var certFiles = []string{
+ "/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu/Gentoo etc.
+ "/etc/pki/tls/certs/ca-bundle.crt", // Fedora/RHEL 6
+ "/etc/ssl/ca-bundle.pem", // OpenSUSE
+ "/etc/pki/tls/cacert.pem", // OpenELEC
+ "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", // CentOS/RHEL 7
+ "/etc/ssl/cert.pem", // Alpine Linux
+}
+
+// Possible directories with certificate files; all will be read.
+var certDirectories = []string{
+ "/etc/ssl/certs", // SLES10/SLES11, https://golang.org/issue/12139
+ "/etc/pki/tls/certs", // Fedora/RHEL
+}
+
+// func init() {
+// if goos.IsAndroid == 1 {
+// certDirectories = append(certDirectories,
+// "/system/etc/security/cacerts", // Android system roots
+// "/data/misc/keychain/certs-added", // User trusted CA folder
+// )
+// }
+// }
diff --git a/internals/httputil/certloader_unix.go b/internals/httputil/certloader_unix.go
new file mode 100644
index 000000000..63122a9f6
--- /dev/null
+++ b/internals/httputil/certloader_unix.go
@@ -0,0 +1,119 @@
+// Code generated by tools/gencertloader from crypto/x509/root_unix.go (go1.26.3); DO NOT EDIT.
+
+// Copyright 2011 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+//go:build aix || dragonfly || freebsd || (js && wasm) || linux || netbsd || openbsd || solaris || wasip1
+
+package httputil
+
+import (
+ "crypto/x509"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const (
+ // certFileEnv is the environment variable which identifies where to locate
+ // the SSL certificate file. If set this overrides the system default.
+ certFileEnv = "SSL_CERT_FILE"
+
+ // certDirEnv is the environment variable which identifies which directory
+ // to check for SSL certificate files. If set this overrides the system default.
+ // It is a colon separated list of directories.
+ // See https://www.openssl.org/docs/man1.0.2/man1/c_rehash.html.
+ certDirEnv = "SSL_CERT_DIR"
+)
+
+// func (c *Certificate) systemVerify(opts *VerifyOptions) (chains [][]*Certificate, err error) {
+// return nil, nil
+// }
+
+// func loadSystemRoots() (*CertPool, error) {
+func loadSystemRoots() (*x509.CertPool, error) {
+ // roots := NewCertPool()
+ roots := x509.NewCertPool()
+
+ files := certFiles
+ if f := os.Getenv(certFileEnv); f != "" {
+ files = []string{f}
+ }
+
+ var firstErr error
+ var hasContent bool
+ for _, file := range files {
+ data, err := os.ReadFile(file)
+ if err == nil {
+ // roots.AppendCertsFromPEM(data)
+ // roots.AppendCertsFromPEM(data)
+ hasContent = roots.AppendCertsFromPEM(data) || hasContent
+ break
+ }
+ if firstErr == nil && !os.IsNotExist(err) {
+ firstErr = err
+ }
+ }
+
+ dirs := certDirectories
+ if d := os.Getenv(certDirEnv); d != "" {
+ // OpenSSL and BoringSSL both use ":" as the SSL_CERT_DIR separator.
+ // See:
+ // * https://golang.org/issue/35325
+ // * https://www.openssl.org/docs/man1.0.2/man1/c_rehash.html
+ dirs = strings.Split(d, ":")
+ }
+
+ for _, directory := range dirs {
+ fis, err := readUniqueDirectoryEntries(directory)
+ if err != nil {
+ if firstErr == nil && !os.IsNotExist(err) {
+ firstErr = err
+ }
+ continue
+ }
+ for _, fi := range fis {
+ data, err := os.ReadFile(directory + "/" + fi.Name())
+ if err == nil {
+ // roots.AppendCertsFromPEM(data)
+ // roots.AppendCertsFromPEM(data)
+ hasContent = roots.AppendCertsFromPEM(data) || hasContent
+ }
+ }
+ }
+
+ // if roots.len() > 0 || firstErr == nil {
+ if hasContent || firstErr == nil {
+ return roots, nil
+ }
+
+ return nil, firstErr
+}
+
+// readUniqueDirectoryEntries is like os.ReadDir but omits
+// symlinks that point within the directory.
+func readUniqueDirectoryEntries(dir string) ([]fs.DirEntry, error) {
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ uniq := files[:0]
+ for _, f := range files {
+ if !isSameDirSymlink(f, dir) {
+ uniq = append(uniq, f)
+ }
+ }
+ return uniq, nil
+}
+
+// isSameDirSymlink reports whether fi in dir is a symlink with a
+// target not containing a slash.
+func isSameDirSymlink(f fs.DirEntry, dir string) bool {
+ if f.Type()&fs.ModeSymlink == 0 {
+ return false
+ }
+ target, err := os.Readlink(filepath.Join(dir, f.Name()))
+ return err == nil && !strings.Contains(target, "/")
+}
diff --git a/internals/httputil/doc.go b/internals/httputil/doc.go
new file mode 100644
index 000000000..e56ff5f61
--- /dev/null
+++ b/internals/httputil/doc.go
@@ -0,0 +1,19 @@
+// Copyright (c) 2026 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// Package httputil provides a lazy-loading, refreshable HTTP transport
+// that reloads the system TLS root cert pool on demand. The cert pool
+// discovery logic is generated from the Go standard library by the
+// tools/conformcertloader tool.
+package httputil
diff --git a/internals/httputil/export_test.go b/internals/httputil/export_test.go
new file mode 100644
index 000000000..057632b4c
--- /dev/null
+++ b/internals/httputil/export_test.go
@@ -0,0 +1,28 @@
+// Copyright (c) 2026 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+//go:build linux
+
+package httputil
+
+// LoadSystemRoots exposes loadSystemRoots for conformance testing.
+var LoadSystemRoots = loadSystemRoots
+
+// Initialised reports whether the Transport's underlying http.Transport
+// has been created yet (i.e. whether lazyInit has run).
+func (t *Transport) Initialised() bool {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return t.transport != nil
+}
diff --git a/internals/httputil/generate.go b/internals/httputil/generate.go
new file mode 100644
index 000000000..272779db2
--- /dev/null
+++ b/internals/httputil/generate.go
@@ -0,0 +1,17 @@
+// Copyright (c) 2026 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package httputil
+
+//go:generate go run github.com/canonical/pebble/tools/gencertloader -output .
diff --git a/internals/httputil/transport.go b/internals/httputil/transport.go
new file mode 100644
index 000000000..ed9b4a3eb
--- /dev/null
+++ b/internals/httputil/transport.go
@@ -0,0 +1,333 @@
+// Copyright (c) 2026 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+//go:build linux
+
+package httputil
+
+import (
+ "crypto/sha256"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/canonical/pebble/internals/logger"
+)
+
+// Transport is a lazy-loading, refreshable http.RoundTripper. The
+// system root cert pool is loaded on first use via x509.SystemCertPool.
+// Call Refresh to reload cert pool entries from disk, logging any
+// additions or removals.
+type Transport struct {
+ mu sync.RWMutex
+ transport *http.Transport
+ trackedCerts map[[32]byte]string // fingerprint -> subject string
+ fileStates []certFileState
+}
+
+type certFileState struct {
+ path string
+ modTime time.Time
+ size int64
+}
+
+// NewTransport creates a Transport. No cert loading is done until the
+// transport is first used via RoundTrip.
+func NewTransport() *Transport {
+ return &Transport{}
+}
+
+// RoundTrip implements http.RoundTripper. It lazily loads the system
+// cert pool on the first call.
+func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
+ err := t.lazyInit()
+ if err != nil {
+ return nil, fmt.Errorf("cannot initialise transport: %w", err)
+ }
+ t.mu.RLock()
+ transport := t.transport
+ t.mu.RUnlock()
+ return transport.RoundTrip(req)
+}
+
+// Refresh reloads cert files from disk and compares against the
+// currently tracked cert set. Added and removed certificates are
+// logged via logger.Noticef. If the transport has not yet been used,
+// Refresh is a no-op. If no cert files have changed on disk since the
+// last Refresh, Refresh is also a no-op.
+func (t *Transport) Refresh() error {
+ t.mu.RLock()
+ initialized := t.transport != nil
+ t.mu.RUnlock()
+ if !initialized {
+ return nil
+ }
+ if !t.certFilesChanged() {
+ return nil
+ }
+ certs, err := loadSystemCerts()
+ if err != nil {
+ return fmt.Errorf("cannot load system certs: %w", err)
+ }
+ newTracked := make(map[[32]byte]string, len(certs))
+ for _, c := range certs {
+ fp := sha256.Sum256(c.Raw)
+ newTracked[fp] = c.Subject.String()
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ changed := false
+ for fp, subject := range newTracked {
+ if _, ok := t.trackedCerts[fp]; !ok {
+ logger.Noticef(
+ "Certificate %q (%s) added to root cert pool.",
+ subject, formatFingerprint(fp),
+ )
+ changed = true
+ }
+ }
+ for fp, subject := range t.trackedCerts {
+ if _, ok := newTracked[fp]; !ok {
+ logger.Noticef(
+ "Certificate %q (%s) removed from root cert pool.",
+ subject, formatFingerprint(fp),
+ )
+ changed = true
+ }
+ }
+ if !changed {
+ return nil
+ }
+ pool := x509.NewCertPool()
+ for _, c := range certs {
+ pool.AddCert(c)
+ }
+ old := t.transport
+ t.transport = buildTransport(pool)
+ t.trackedCerts = newTracked
+ t.fileStates = currentFileStates()
+ old.CloseIdleConnections()
+ return nil
+}
+
+// lazyInit loads the system cert pool on first use.
+func (t *Transport) lazyInit() error {
+ t.mu.RLock()
+ initialized := t.transport != nil
+ t.mu.RUnlock()
+ if initialized {
+ return nil
+ }
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ if t.transport != nil {
+ return nil
+ }
+ // Use the stdlib's memoized pool for the initial load.
+ pool, err := x509.SystemCertPool()
+ if err != nil {
+ return fmt.Errorf("cannot load system cert pool: %w", err)
+ }
+ // Load individual certs from disk to establish the initial tracked set.
+ certs, certsErr := loadSystemCerts()
+ if certsErr != nil {
+ logger.Noticef("Cannot track system cert pool: %v", certsErr)
+ }
+ tracked := make(map[[32]byte]string, len(certs))
+ for _, c := range certs {
+ fp := sha256.Sum256(c.Raw)
+ tracked[fp] = c.Subject.String()
+ }
+ t.transport = buildTransport(pool)
+ t.trackedCerts = tracked
+ t.fileStates = currentFileStates()
+ return nil
+}
+
+// certFilesChanged returns true if any cert file's mtime or size has
+// changed since the last check.
+func (t *Transport) certFilesChanged() bool {
+ t.mu.RLock()
+ prev := t.fileStates
+ t.mu.RUnlock()
+ curr := currentFileStates()
+ if len(prev) != len(curr) {
+ return true
+ }
+ for i, p := range prev {
+ c := curr[i]
+ if p.path != c.path || p.modTime != c.modTime || p.size != c.size {
+ return true
+ }
+ }
+ return false
+}
+
+// currentFileStates returns the current mtime and size for each cert
+// file and directory that exists, following the same discovery order
+// as loadSystemRoots.
+func currentFileStates() []certFileState {
+ var states []certFileState
+ seen := make(map[string]bool)
+ files := certFiles
+ if f := os.Getenv(certFileEnv); f != "" {
+ files = []string{f}
+ }
+ for _, path := range files {
+ info, err := os.Stat(path)
+ if err != nil {
+ continue
+ }
+ if !seen[path] {
+ seen[path] = true
+ states = append(states, certFileState{
+ path: path,
+ modTime: info.ModTime(),
+ size: info.Size(),
+ })
+ }
+ break // certFiles: stop after finding first
+ }
+ dirs := certDirectories
+ if d := os.Getenv(certDirEnv); d != "" {
+ dirs = strings.Split(d, ":")
+ }
+ for _, dir := range dirs {
+ dinfo, err := os.Stat(dir)
+ if err != nil {
+ continue
+ }
+ if !seen[dir] {
+ seen[dir] = true
+ states = append(states, certFileState{
+ path: dir,
+ modTime: dinfo.ModTime(),
+ size: dinfo.Size(),
+ })
+ }
+ fis, err := readUniqueDirectoryEntries(dir)
+ if err != nil {
+ continue
+ }
+ for _, fi := range fis {
+ path := dir + "/" + fi.Name()
+ info, err := fi.Info()
+ if err != nil {
+ continue
+ }
+ if !seen[path] {
+ seen[path] = true
+ states = append(states, certFileState{
+ path: path,
+ modTime: info.ModTime(),
+ size: info.Size(),
+ })
+ }
+ }
+ }
+ return states
+}
+
+// loadSystemCerts reads individual x509 certificates from the system
+// cert files, following the same discovery logic as loadSystemRoots.
+func loadSystemCerts() ([]*x509.Certificate, error) {
+ var certs []*x509.Certificate
+ files := certFiles
+ if f := os.Getenv(certFileEnv); f != "" {
+ files = []string{f}
+ }
+ var firstErr error
+ for _, path := range files {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if !os.IsNotExist(err) && firstErr == nil {
+ firstErr = err
+ }
+ continue
+ }
+ certs = append(certs, parsePEMCerts(data)...)
+ break
+ }
+ dirs := certDirectories
+ if d := os.Getenv(certDirEnv); d != "" {
+ dirs = strings.Split(d, ":")
+ }
+ for _, dir := range dirs {
+ fis, err := readUniqueDirectoryEntries(dir)
+ if err != nil {
+ if !os.IsNotExist(err) && firstErr == nil {
+ firstErr = err
+ }
+ continue
+ }
+ for _, fi := range fis {
+ data, err := os.ReadFile(dir + "/" + fi.Name())
+ if err == nil {
+ certs = append(certs, parsePEMCerts(data)...)
+ }
+ }
+ }
+ if len(certs) > 0 || firstErr == nil {
+ return certs, nil
+ }
+ return nil, firstErr
+}
+
+// parsePEMCerts parses all PEM-encoded certificates from data.
+func parsePEMCerts(data []byte) []*x509.Certificate {
+ var certs []*x509.Certificate
+ for len(data) > 0 {
+ var block *pem.Block
+ block, data = pem.Decode(data)
+ if block == nil {
+ break
+ }
+ if block.Type != "CERTIFICATE" {
+ continue
+ }
+ cert, err := x509.ParseCertificate(block.Bytes)
+ if err != nil {
+ continue
+ }
+ certs = append(certs, cert)
+ }
+ return certs
+}
+
+// buildTransport creates a new *http.Transport with the given cert
+// pool, cloning DefaultTransport's connection settings.
+func buildTransport(pool *x509.CertPool) *http.Transport {
+ t := http.DefaultTransport.(*http.Transport).Clone()
+ t.TLSClientConfig = &tls.Config{RootCAs: pool}
+ return t
+}
+
+// formatFingerprint formats a SHA-256 fingerprint as a
+// colon-separated hex string.
+func formatFingerprint(fp [32]byte) string {
+ var sb strings.Builder
+ for i, b := range fp {
+ if i > 0 {
+ sb.WriteByte(':')
+ }
+ fmt.Fprintf(&sb, "%02x", b)
+ }
+ return sb.String()
+}
diff --git a/internals/httputil/transport_test.go b/internals/httputil/transport_test.go
new file mode 100644
index 000000000..ba098e4fa
--- /dev/null
+++ b/internals/httputil/transport_test.go
@@ -0,0 +1,62 @@
+// Copyright (c) 2026 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+//go:build linux
+
+package httputil_test
+
+import (
+ "crypto/x509"
+ "testing"
+
+ . "gopkg.in/check.v1"
+
+ "github.com/canonical/pebble/internals/httputil"
+)
+
+func Test(t *testing.T) { TestingT(t) }
+
+type transportSuite struct{}
+
+var _ = Suite(&transportSuite{})
+
+// TestLoadSystemRootsConformance validates that our generated
+// loadSystemRoots produces a cert pool equal to x509.SystemCertPool.
+// This confirms the generated code correctly replicates stdlib behaviour.
+func (s *transportSuite) TestLoadSystemRootsConformance(c *C) {
+ systemPool, err := x509.SystemCertPool()
+ c.Assert(err, IsNil)
+
+ ourPool, err := httputil.LoadSystemRoots()
+ c.Assert(err, IsNil)
+
+ c.Check(ourPool.Equal(systemPool), Equals, true)
+}
+
+// TestTransportLazyLoad verifies no cert loading occurs until RoundTrip
+// is called.
+func (s *transportSuite) TestTransportLazyLoad(c *C) {
+ t := httputil.NewTransport()
+ // Transport is initialised but transport field must be nil.
+ c.Check(t.Initialised(), Equals, false)
+}
+
+// TestTransportRefreshBeforeUseIsNoop verifies Refresh is a no-op
+// before the transport has been used.
+func (s *transportSuite) TestTransportRefreshBeforeUseIsNoop(c *C) {
+ t := httputil.NewTransport()
+ err := t.Refresh()
+ c.Assert(err, IsNil)
+ c.Check(t.Initialised(), Equals, false)
+}
diff --git a/internals/overlord/checkstate/checkers.go b/internals/overlord/checkstate/checkers.go
index c9b2a099b..6fcd11d6e 100644
--- a/internals/overlord/checkstate/checkers.go
+++ b/internals/overlord/checkstate/checkers.go
@@ -44,14 +44,15 @@ const (
// httpChecker is a checker that ensures an HTTP GET at a specified URL returns 2xx.
type httpChecker struct {
- name string
- url string
- headers map[string]string
+ name string
+ url string
+ headers map[string]string
+ transport http.RoundTripper
}
func (c *httpChecker) check(ctx context.Context) error {
logger.Debugf("Check %q (http): requesting %q", c.name, c.url)
- client := &http.Client{}
+ client := &http.Client{Transport: c.transport}
request, err := http.NewRequestWithContext(ctx, "GET", c.url, nil)
if err != nil {
return fmt.Errorf("cannot build request: %w", err)
diff --git a/internals/overlord/checkstate/checkers_test.go b/internals/overlord/checkstate/checkers_test.go
index 57608d577..477c70c04 100644
--- a/internals/overlord/checkstate/checkers_test.go
+++ b/internals/overlord/checkstate/checkers_test.go
@@ -56,15 +56,16 @@ func (s *CheckersSuite) TestHTTP(c *C) {
defer server.Close()
// Good 200 URL works
- chk := &httpChecker{url: server.URL + "/foo/bar"}
+ chk := &httpChecker{url: server.URL + "/foo/bar", transport: nil}
err := chk.check(context.Background())
c.Assert(err, IsNil)
c.Assert(path, Equals, "/foo/bar")
// Custom headers are sent through
chk = &httpChecker{
- url: server.URL + "/foo/bar",
- headers: map[string]string{"X-Name": "Bob Smith", "User-Agent": "pebble-test"},
+ url: server.URL + "/foo/bar",
+ headers: map[string]string{"X-Name": "Bob Smith", "User-Agent": "pebble-test"},
+ transport: nil,
}
err = chk.check(context.Background())
c.Assert(err, IsNil)
@@ -73,14 +74,14 @@ func (s *CheckersSuite) TestHTTP(c *C) {
c.Assert(headers.Get("User-Agent"), Equals, "pebble-test")
// Non-2xx status code returns error
- chk = &httpChecker{url: server.URL + "/404"}
+ chk = &httpChecker{url: server.URL + "/404", transport: nil}
err = chk.check(context.Background())
c.Assert(err, ErrorMatches, "non-2xx status code 404")
c.Assert(path, Equals, "/404")
// In case of non-2xx status, short response body is fully included in error details
response = "error details"
- chk = &httpChecker{url: server.URL + "/500"}
+ chk = &httpChecker{url: server.URL + "/500", transport: nil}
err = chk.check(context.Background())
c.Assert(err, ErrorMatches, "non-2xx status code 500")
detailsErr, ok := err.(*detailsError)
@@ -93,7 +94,7 @@ func (s *CheckersSuite) TestHTTP(c *C) {
fmt.Fprintf(&output, "line %d\n", i)
}
response = output.String()
- chk = &httpChecker{url: server.URL + "/500"}
+ chk = &httpChecker{url: server.URL + "/500", transport: nil}
err = chk.check(context.Background())
c.Assert(err, ErrorMatches, "non-2xx status code 500")
detailsErr, ok = err.(*detailsError)
@@ -103,18 +104,18 @@ func (s *CheckersSuite) TestHTTP(c *C) {
// Cancelled context returns error
ctx, cancel := context.WithCancel(context.Background())
cancel()
- chk = &httpChecker{url: server.URL}
+ chk = &httpChecker{url: server.URL, transport: nil}
err = chk.check(ctx)
c.Assert(err, ErrorMatches, ".* context canceled")
// After server closed, should get a network dial error
server.Close()
- chk = &httpChecker{url: server.URL}
+ chk = &httpChecker{url: server.URL, transport: nil}
err = chk.check(context.Background())
c.Assert(err, ErrorMatches, ".* connection refused")
// Malformed URL returns an error
- chk = &httpChecker{url: "#!@$%@#@"}
+ chk = &httpChecker{url: "#!@$%@#@", transport: nil}
err = chk.check(ctx)
c.Assert(err, ErrorMatches, "cannot build request: .*")
}
@@ -266,7 +267,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) {
URL: "https://example.com/foo",
Headers: map[string]string{"k": "v"},
},
- })
+ }, nil)
http, ok := chk.(*httpChecker)
c.Assert(ok, Equals, true)
c.Check(http.name, Equals, "http")
@@ -279,7 +280,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) {
Port: 80,
Host: "localhost",
},
- })
+ }, nil)
tcp, ok := chk.(*tcpChecker)
c.Assert(ok, Equals, true)
c.Check(tcp.name, Equals, "tcp")
@@ -298,7 +299,7 @@ func (s *CheckersSuite) TestNewChecker(c *C) {
Group: "group",
WorkingDir: "/working/dir",
},
- })
+ }, nil)
exec, ok := chk.(*execChecker)
c.Assert(ok, Equals, true)
c.Assert(exec.name, Equals, "exec")
@@ -329,7 +330,7 @@ func (s *CheckersSuite) TestExecContextNoOverride(c *C) {
ServiceContext: "svc1",
},
})
- chk := newChecker(config)
+ chk := newChecker(config, nil)
exec, ok := chk.(*execChecker)
c.Assert(ok, Equals, true)
c.Check(exec.name, Equals, "exec")
@@ -367,7 +368,7 @@ func (s *CheckersSuite) TestExecContextOverride(c *C) {
WorkingDir: "/working/dir",
},
})
- chk := newChecker(config)
+ chk := newChecker(config, nil)
exec, ok := chk.(*execChecker)
c.Assert(ok, Equals, true)
c.Check(exec.name, Equals, "exec")
diff --git a/internals/overlord/checkstate/handlers.go b/internals/overlord/checkstate/handlers.go
index 88697f594..3477dd7bc 100644
--- a/internals/overlord/checkstate/handlers.go
+++ b/internals/overlord/checkstate/handlers.go
@@ -48,7 +48,7 @@ func (m *CheckManager) doPerformCheck(task *state.Task, tomb *tombpkg.Tomb) erro
prevChangeID := data.prevChangeID
m.checksLock.Unlock()
- chk := newChecker(config)
+ chk := newChecker(config, m.transport)
performCheck := func() (shouldExit bool, err error) {
//lint:ignore SA1012 providing a nil context to tomb.Context() is valid
@@ -166,7 +166,7 @@ func (m *CheckManager) doRecoverCheck(task *state.Task, tomb *tombpkg.Tomb) erro
prevChangeID := data.prevChangeID
m.checksLock.Unlock()
- chk := newChecker(config)
+ chk := newChecker(config, m.transport)
recoverCheck := func() (shouldExit bool, err error) {
//lint:ignore SA1012 providing a nil context to tomb.Context() is valid
diff --git a/internals/overlord/checkstate/manager.go b/internals/overlord/checkstate/manager.go
index 99d2e324f..e8b9ab9da 100644
--- a/internals/overlord/checkstate/manager.go
+++ b/internals/overlord/checkstate/manager.go
@@ -17,6 +17,7 @@ package checkstate
import (
"context"
"fmt"
+ "net/http"
"reflect"
"sort"
"strings"
@@ -24,6 +25,7 @@ import (
"gopkg.in/tomb.v2"
+ "github.com/canonical/pebble/internals/httputil"
"github.com/canonical/pebble/internals/logger"
"github.com/canonical/pebble/internals/metrics"
"github.com/canonical/pebble/internals/overlord/planstate"
@@ -48,6 +50,7 @@ type CheckManager struct {
checksLock sync.Mutex
checks map[string]*checkData
+ transport *httputil.Transport
}
// FailureFunc is the type of function called when a failure action is triggered.
@@ -56,9 +59,10 @@ type FailureFunc func(name string)
// NewManager creates a new check manager.
func NewManager(s *state.State, runner *state.TaskRunner, planMgr *planstate.PlanManager) *CheckManager {
manager := &CheckManager{
- state: s,
- checks: make(map[string]*checkData),
- planMgr: planMgr,
+ state: s,
+ checks: make(map[string]*checkData),
+ planMgr: planMgr,
+ transport: httputil.NewTransport(),
}
// Health check changes can be long-running; ensure they don't get pruned.
@@ -103,6 +107,10 @@ func (m *CheckManager) NotifyCheckFailed(f FailureFunc) {
// PlanChanged handles updates to the plan (server configuration),
// stopping the previous checks and starting the new ones as required.
func (m *CheckManager) PlanChanged(newPlan *plan.Plan) {
+ if err := m.transport.Refresh(); err != nil {
+ logger.Noticef("Cannot refresh TLS cert pool: %v", err)
+ }
+
m.state.Lock()
defer m.state.Unlock()
@@ -254,13 +262,14 @@ func checkType(config *plan.Check) string {
// newChecker creates a new checker of the configured type. Assumes
// mergeServiceContext has already been called.
-func newChecker(config *plan.Check) checker {
+func newChecker(config *plan.Check, transport http.RoundTripper) checker {
switch {
case config.HTTP != nil:
return &httpChecker{
- name: config.Name,
- url: config.HTTP.URL,
- headers: config.HTTP.Headers,
+ name: config.Name,
+ url: config.HTTP.URL,
+ headers: config.HTTP.Headers,
+ transport: transport,
}
case config.TCP != nil:
@@ -658,7 +667,7 @@ func (m *CheckManager) RefreshCheck(ctx context.Context, check *plan.Check) (*Ch
// If the check is stopped, run the check directly without using changes and tasks.
if changeID == "" {
- chk := newChecker(check)
+ chk := newChecker(check, m.transport)
err := runCheck(ctx, chk, check.Timeout.Value)
if err != nil {
return getCheckInfo(), fmt.Errorf("%s", errorDetails(err))
diff --git a/internals/overlord/logstate/gatherer.go b/internals/overlord/logstate/gatherer.go
index 47ecbdfe7..422e70eba 100644
--- a/internals/overlord/logstate/gatherer.go
+++ b/internals/overlord/logstate/gatherer.go
@@ -17,6 +17,7 @@ package logstate
import (
"context"
"fmt"
+ "net/http"
"os"
"time"
@@ -89,6 +90,7 @@ type logGathererOptions struct {
maxBufferedEntries int
timeoutCurrentFlush time.Duration
timeoutFinalFlush time.Duration
+ transport http.RoundTripper
// method to get a new client
newClient func(*plan.LogTarget) (logClient, error)
}
@@ -137,7 +139,10 @@ func fillDefaultOptions(options *logGathererOptions) *logGathererOptions {
options.timeoutFinalFlush = timeoutFinalFlush
}
if options.newClient == nil {
- options.newClient = newLogClient
+ t := options.transport
+ options.newClient = func(target *plan.LogTarget) (logClient, error) {
+ return newLogClient(target, t)
+ }
}
return options
}
@@ -367,13 +372,14 @@ type logClient interface {
SetLabels(serviceName string, labels map[string]string)
}
-func newLogClient(target *plan.LogTarget) (logClient, error) {
+func newLogClient(target *plan.LogTarget, transport http.RoundTripper) (logClient, error) {
switch target.Type {
case plan.LokiTarget:
return loki.NewClient(&loki.ClientOptions{
TargetName: target.Name,
Location: target.Location,
UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version),
+ Transport: transport,
}), nil
case plan.OpenTelemetryTarget:
return opentelemetry.NewClient(&opentelemetry.ClientOptions{
@@ -381,6 +387,7 @@ func newLogClient(target *plan.LogTarget) (logClient, error) {
Location: target.Location,
UserAgent: fmt.Sprintf("%s/%s", cmd.ProgramName, cmd.Version),
ScopeName: cmd.ProgramName,
+ Transport: transport,
}), nil
case plan.SyslogTarget:
hostname, err := os.Hostname()
diff --git a/internals/overlord/logstate/loki/loki.go b/internals/overlord/logstate/loki/loki.go
index 1bba17640..07fd73510 100644
--- a/internals/overlord/logstate/loki/loki.go
+++ b/internals/overlord/logstate/loki/loki.go
@@ -54,7 +54,7 @@ func NewClient(options *ClientOptions) *Client {
fillDefaultOptions(&opts)
c := &Client{
options: &opts,
- httpClient: &http.Client{Timeout: opts.RequestTimeout},
+ httpClient: &http.Client{Timeout: opts.RequestTimeout, Transport: opts.Transport},
buffer: make([]entryWithService, 2*opts.MaxRequestEntries),
labels: make(map[string]json.RawMessage),
}
@@ -70,6 +70,7 @@ type ClientOptions struct {
UserAgent string
TargetName string
Location string
+ Transport http.RoundTripper
}
func fillDefaultOptions(options *ClientOptions) {
diff --git a/internals/overlord/logstate/manager.go b/internals/overlord/logstate/manager.go
index 465828993..4084df63d 100644
--- a/internals/overlord/logstate/manager.go
+++ b/internals/overlord/logstate/manager.go
@@ -17,6 +17,7 @@ package logstate
import (
"sync"
+ "github.com/canonical/pebble/internals/httputil"
"github.com/canonical/pebble/internals/logger"
"github.com/canonical/pebble/internals/plan"
"github.com/canonical/pebble/internals/servicelog"
@@ -29,19 +30,30 @@ type LogManager struct {
plan *plan.Plan
newGatherer func(*plan.LogTarget) (*logGatherer, error)
+ transport *httputil.Transport
}
func NewLogManager() *LogManager {
- return &LogManager{
- gatherers: map[string]*logGatherer{},
- buffers: map[string]*servicelog.RingBuffer{},
- newGatherer: newLogGatherer,
+ m := &LogManager{
+ gatherers: map[string]*logGatherer{},
+ buffers: map[string]*servicelog.RingBuffer{},
+ transport: httputil.NewTransport(),
}
+ m.newGatherer = func(t *plan.LogTarget) (*logGatherer, error) {
+ return newLogGathererInternal(t, &logGathererOptions{
+ transport: m.transport,
+ })
+ }
+ return m
}
// PlanChanged is called by the service manager when the plan changes.
// Based on the new plan, we will Stop old gatherers and start new ones.
func (m *LogManager) PlanChanged(pl *plan.Plan) {
+ if err := m.transport.Refresh(); err != nil {
+ logger.Noticef("Cannot refresh TLS cert pool: %v", err)
+ }
+
m.mu.Lock()
defer m.mu.Unlock()
diff --git a/internals/overlord/logstate/opentelemetry/opentelemetry.go b/internals/overlord/logstate/opentelemetry/opentelemetry.go
index f41e94fc3..4ace82edb 100644
--- a/internals/overlord/logstate/opentelemetry/opentelemetry.go
+++ b/internals/overlord/logstate/opentelemetry/opentelemetry.go
@@ -131,7 +131,7 @@ func NewClient(options *ClientOptions) *Client {
fillDefaultOptions(&opts)
c := &Client{
options: &opts,
- httpClient: &http.Client{Timeout: opts.RequestTimeout},
+ httpClient: &http.Client{Timeout: opts.RequestTimeout, Transport: opts.Transport},
buffer: make([]entryWithService, 2*opts.MaxRequestEntries),
resourceAttributes: make(map[string][]keyValue),
}
@@ -148,6 +148,7 @@ type ClientOptions struct {
ScopeName string
TargetName string
Location string
+ Transport http.RoundTripper
}
func fillDefaultOptions(options *ClientOptions) {
diff --git a/tools/gencertloader/main.go b/tools/gencertloader/main.go
new file mode 100644
index 000000000..fc5a2c91b
--- /dev/null
+++ b/tools/gencertloader/main.go
@@ -0,0 +1,693 @@
+// Copyright (c) 2026 Canonical Ltd
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 3 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// Command gencertloader generates certloader_unix.go and
+// certloader_linux.go in the output directory by extracting and
+// transforming certificate loading code from the Go standard library.
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "go/ast"
+ "go/format"
+ "go/parser"
+ "go/token"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// commentNote records a single mutation: searchFor is a line of the
+// formatted output to locate; comment is the original code to insert
+// as a comment immediately before that line.
+type commentNote struct {
+ searchFor string
+ comment string
+}
+
+// stmtTransform inspects a statement and, if it matches a pattern,
+// returns the comment note for the mutation and the (possibly new)
+// replacement statement.
+type stmtTransform func(*token.FileSet, ast.Stmt) (commentNote, ast.Stmt, bool)
+
+func main() {
+ outDir, err := parseFlags()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+
+ goroot, err := exec.Command("go", "env", "GOROOT").Output()
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "Error running go env GOROOT: %v\n", err)
+ os.Exit(1)
+ }
+ gorootStr := strings.TrimSpace(string(goroot))
+
+ fileSet := token.NewFileSet()
+
+ unixSrc := filepath.Join(
+ gorootStr, "src", "crypto", "x509", "root_unix.go",
+ )
+ if err := processUnix(fileSet, unixSrc, outDir); err != nil {
+ fmt.Fprintf(os.Stderr, "Error processing root_unix.go: %v\n", err)
+ os.Exit(1)
+ }
+
+ linuxSrc := filepath.Join(
+ gorootStr, "src", "crypto", "x509", "root_linux.go",
+ )
+ if err := processLinux(fileSet, linuxSrc, outDir); err != nil {
+ fmt.Fprintf(os.Stderr, "Error processing root_linux.go: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func parseFlags() (string, error) {
+ args := os.Args[1:]
+ outDir := "."
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "-output":
+ if i+1 >= len(args) {
+ return "", fmt.Errorf("-output flag requires an argument")
+ }
+ outDir = args[i+1]
+ i++
+ default:
+ return "", fmt.Errorf("unknown flag: %s", args[i])
+ }
+ }
+ return outDir, nil
+}
+
+func processUnix(fset *token.FileSet, srcPath string, outDir string) error {
+ src, err := os.ReadFile(srcPath)
+ if err != nil {
+ return err
+ }
+ f, err := parser.ParseFile(fset, srcPath, src, parser.ParseComments)
+ if err != nil {
+ return err
+ }
+
+ systemVerifyText := findFuncDeclText(fset, src, f.Decls, "systemVerify")
+
+ var excluded []ast.Node
+ if fn := findFuncDecl(f.Decls, "systemVerify"); fn != nil {
+ excluded = append(excluded, fn)
+ }
+ f.Comments = filterComments(f.Comments, fset, excluded)
+
+ f.Name.Name = "httputil"
+ f.Decls = removeMethod(f.Decls, "systemVerify")
+ addImport(f, "crypto/x509")
+ notes, funcStart := fixLoadSystemRoots(fset, f)
+
+ var body strings.Builder
+ if err := format.Node(&body, fset, f); err != nil {
+ return fmt.Errorf("format certloader_unix.go: %w", err)
+ }
+ code := postProcessUnix(body.String(), notes, systemVerifyText, funcStart)
+ goVersion := goVersion()
+ return writeOutputText(
+ outDir, "certloader_unix.go", "root_unix.go",
+ goVersion, code,
+ )
+}
+
+func processLinux(fset *token.FileSet, srcPath string, outDir string) error {
+ src, err := os.ReadFile(srcPath)
+ if err != nil {
+ return err
+ }
+ f, err := parser.ParseFile(fset, srcPath, src, parser.ParseComments)
+ if err != nil {
+ return err
+ }
+
+ importsText := findImportDeclText(fset, src, f)
+ initText := findFuncDeclText(fset, src, f.Decls, "init")
+
+ var excluded []ast.Node
+ if imp := findImportDecl(f); imp != nil {
+ excluded = append(excluded, imp)
+ }
+ if fn := findFuncDecl(f.Decls, "init"); fn != nil {
+ excluded = append(excluded, fn)
+ }
+ f.Comments = filterComments(f.Comments, fset, excluded)
+
+ f.Name.Name = "httputil"
+ f.Decls = removeInitFunction(f.Decls)
+ removeAllImports(f)
+
+ var body strings.Builder
+ if err := format.Node(&body, fset, f); err != nil {
+ return fmt.Errorf("format certloader_linux.go: %w", err)
+ }
+ code := postProcessLinux(
+ body.String(), f.Name.Name, importsText, initText,
+ )
+ goVersion := goVersion()
+ return writeOutputText(
+ outDir, "certloader_linux.go", "root_linux.go",
+ goVersion, code,
+ )
+}
+
+// goVersion returns the current Go toolchain version string (e.g. "go1.26.3").
+func goVersion() string {
+ out, err := exec.Command("go", "env", "GOVERSION").Output()
+ if err != nil {
+ return "unknown"
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// formatNode formats any single AST node to trimmed source text.
+func formatNode(fset *token.FileSet, node ast.Node) string {
+ var buf bytes.Buffer
+ _ = format.Node(&buf, fset, node)
+ return strings.TrimSpace(buf.String())
+}
+
+// firstLine returns the first newline-delimited line of s.
+func firstLine(s string) string {
+ if i := strings.IndexByte(s, '\n'); i >= 0 {
+ return s[:i]
+ }
+ return s
+}
+
+func removeMethod(decls []ast.Decl, name string) []ast.Decl {
+ var result []ast.Decl
+ for _, decl := range decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if ok && fn.Name.Name == name {
+ continue
+ }
+ result = append(result, decl)
+ }
+ return result
+}
+
+func removeInitFunction(decls []ast.Decl) []ast.Decl {
+ var result []ast.Decl
+ for _, decl := range decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if ok && fn.Name.Name == "init" {
+ continue
+ }
+ result = append(result, decl)
+ }
+ return result
+}
+
+// fixLoadSystemRoots applies all mutations to the loadSystemRoots
+// function. It returns a slice of comment notes (one per mutation)
+// and the function-start string used to anchor the systemVerify
+// comment block insertion.
+func fixLoadSystemRoots(
+ fset *token.FileSet, f *ast.File,
+) (notes []commentNote, funcStart string) {
+ for _, decl := range f.Decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok || fn.Name.Name != "loadSystemRoots" {
+ continue
+ }
+ funcStart = "func " + fn.Name.Name + "("
+ notes = append(notes, fixReturnType(fset, fn)...)
+ notes = append(notes, transformBlock(fset, fn.Body, []stmtTransform{
+ transformNewCertPool,
+ transformAppendCerts,
+ transformRootsLen,
+ })...)
+ // Insert var hasContent bool before the first range statement.
+ hasContentVar := &ast.DeclStmt{
+ Decl: &ast.GenDecl{
+ Tok: token.VAR,
+ Specs: []ast.Spec{
+ &ast.ValueSpec{
+ Names: []*ast.Ident{{Name: "hasContent"}},
+ Type: &ast.Ident{Name: "bool"},
+ },
+ },
+ },
+ }
+ idx := indexOfFirstRange(fn.Body.List)
+ fn.Body.List = append(
+ fn.Body.List[:idx],
+ append([]ast.Stmt{hasContentVar}, fn.Body.List[idx:]...)...,
+ )
+ return
+ }
+ return
+}
+
+// fixReturnType changes *CertPool in the results list to *x509.CertPool,
+// returning a comment note derived from the actual formatted signature.
+func fixReturnType(fset *token.FileSet, fn *ast.FuncDecl) []commentNote {
+ if fn.Type.Results == nil {
+ return nil
+ }
+ for _, field := range fn.Type.Results.List {
+ star, ok := field.Type.(*ast.StarExpr)
+ if !ok {
+ continue
+ }
+ id, ok := star.X.(*ast.Ident)
+ if !ok || id.Name != "CertPool" {
+ continue
+ }
+ origSig := firstLine(formatNode(fset, fn))
+ star.X = &ast.SelectorExpr{
+ X: &ast.Ident{Name: "x509"},
+ Sel: &ast.Ident{Name: "CertPool"},
+ }
+ newSig := firstLine(formatNode(fset, fn))
+ return []commentNote{{searchFor: newSig, comment: origSig}}
+ }
+ return nil
+}
+
+// transformBlock applies each transform to every statement in block,
+// recursing into nested block-containing statements.
+func transformBlock(
+ fset *token.FileSet,
+ block *ast.BlockStmt,
+ ts []stmtTransform,
+) []commentNote {
+ var notes []commentNote
+ for i, stmt := range block.List {
+ for _, t := range ts {
+ note, newStmt, ok := t(fset, stmt)
+ if !ok {
+ continue
+ }
+ block.List[i] = newStmt
+ notes = append(notes, note)
+ break
+ }
+ notes = append(notes, recurseBlocks(fset, block.List[i], ts)...)
+ }
+ return notes
+}
+
+// recurseBlocks recurses transformBlock into the bodies of statements
+// that contain nested block statements.
+func recurseBlocks(
+ fset *token.FileSet,
+ stmt ast.Stmt,
+ ts []stmtTransform,
+) []commentNote {
+ switch s := stmt.(type) {
+ case *ast.RangeStmt:
+ return transformBlock(fset, s.Body, ts)
+ case *ast.ForStmt:
+ return transformBlock(fset, s.Body, ts)
+ case *ast.IfStmt:
+ notes := transformBlock(fset, s.Body, ts)
+ switch e := s.Else.(type) {
+ case *ast.BlockStmt:
+ notes = append(notes, transformBlock(fset, e, ts)...)
+ case *ast.IfStmt:
+ notes = append(notes, recurseBlocks(fset, e, ts)...)
+ }
+ return notes
+ }
+ return nil
+}
+
+// transformNewCertPool replaces NewCertPool() with x509.NewCertPool(),
+// deriving the comment note from the formatted AST before and after.
+func transformNewCertPool(
+ fset *token.FileSet, stmt ast.Stmt,
+) (commentNote, ast.Stmt, bool) {
+ assign, ok := stmt.(*ast.AssignStmt)
+ if !ok || len(assign.Rhs) != 1 {
+ return commentNote{}, nil, false
+ }
+ call, ok := assign.Rhs[0].(*ast.CallExpr)
+ if !ok {
+ return commentNote{}, nil, false
+ }
+ id, ok := call.Fun.(*ast.Ident)
+ if !ok || id.Name != "NewCertPool" {
+ return commentNote{}, nil, false
+ }
+ orig := formatNode(fset, stmt)
+ call.Fun = &ast.SelectorExpr{
+ X: &ast.Ident{Name: "x509"},
+ Sel: &ast.Ident{Name: "NewCertPool"},
+ }
+ return commentNote{
+ searchFor: formatNode(fset, stmt),
+ comment: orig,
+ }, stmt, true
+}
+
+// transformAppendCerts replaces roots.AppendCertsFromPEM(data) with
+// hasContent = roots.AppendCertsFromPEM(data) || hasContent, deriving
+// the comment note from the formatted AST before and after.
+func transformAppendCerts(
+ fset *token.FileSet, stmt ast.Stmt,
+) (commentNote, ast.Stmt, bool) {
+ exprStmt, ok := stmt.(*ast.ExprStmt)
+ if !ok {
+ return commentNote{}, nil, false
+ }
+ call, ok := exprStmt.X.(*ast.CallExpr)
+ if !ok || !isAppendCertsCall(call) {
+ return commentNote{}, nil, false
+ }
+ orig := formatNode(fset, exprStmt)
+ newStmt := &ast.AssignStmt{
+ Lhs: []ast.Expr{&ast.Ident{Name: "hasContent"}},
+ Tok: token.ASSIGN,
+ Rhs: []ast.Expr{
+ &ast.BinaryExpr{
+ X: call,
+ Op: token.LOR,
+ Y: &ast.Ident{Name: "hasContent"},
+ },
+ },
+ }
+ return commentNote{
+ searchFor: formatNode(fset, newStmt),
+ comment: orig,
+ }, newStmt, true
+}
+
+// transformRootsLen replaces the roots.len() > 0 sub-expression in
+// the final if condition with hasContent, deriving the comment note
+// from the first line of the formatted if statement before and after.
+func transformRootsLen(
+ fset *token.FileSet, stmt ast.Stmt,
+) (commentNote, ast.Stmt, bool) {
+ ifStmt, ok := stmt.(*ast.IfStmt)
+ if !ok {
+ return commentNote{}, nil, false
+ }
+ binCond, ok := ifStmt.Cond.(*ast.BinaryExpr)
+ if !ok || binCond.Op != token.LOR {
+ return commentNote{}, nil, false
+ }
+ lhs, ok := binCond.X.(*ast.BinaryExpr)
+ if !ok || lhs.Op != token.GTR {
+ return commentNote{}, nil, false
+ }
+ call, ok := lhs.X.(*ast.CallExpr)
+ if !ok || !isRootsLenCall(call) {
+ return commentNote{}, nil, false
+ }
+ origLine := firstLine(formatNode(fset, ifStmt))
+ binCond.X = &ast.Ident{Name: "hasContent"}
+ newLine := firstLine(formatNode(fset, ifStmt))
+ return commentNote{
+ searchFor: newLine,
+ comment: origLine,
+ }, stmt, true
+}
+
+// indexOfFirstRange returns the index of the first RangeStmt in stmts.
+func indexOfFirstRange(stmts []ast.Stmt) int {
+ for i, s := range stmts {
+ if _, ok := s.(*ast.RangeStmt); ok {
+ return i
+ }
+ }
+ return len(stmts)
+}
+
+func isAppendCertsCall(call *ast.CallExpr) bool {
+ if len(call.Args) != 1 {
+ return false
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ return ok && ident.Name == "roots" && sel.Sel.Name == "AppendCertsFromPEM"
+}
+
+func isRootsLenCall(call *ast.CallExpr) bool {
+ if len(call.Args) != 0 {
+ return false
+ }
+ sel, ok := call.Fun.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ ident, ok := sel.X.(*ast.Ident)
+ return ok && ident.Name == "roots" && sel.Sel.Name == "len"
+}
+
+// filterComments drops comments whose position falls inside one of
+// the excluded nodes (i.e. comments belonging to removed declarations).
+func filterComments(
+ comments []*ast.CommentGroup,
+ fset *token.FileSet,
+ exclude []ast.Node,
+) []*ast.CommentGroup {
+ if len(exclude) == 0 {
+ return comments
+ }
+ var result []*ast.CommentGroup
+ for _, cg := range comments {
+ if len(cg.List) == 0 {
+ continue
+ }
+ pos := fset.Position(cg.Pos()).Offset
+ inExcluded := false
+ for _, node := range exclude {
+ start := fset.Position(node.Pos()).Offset
+ end := fset.Position(node.End()).Offset
+ if pos >= start && pos < end {
+ inExcluded = true
+ break
+ }
+ }
+ if !inExcluded {
+ result = append(result, cg)
+ }
+ }
+ return result
+}
+
+// findFuncDecl returns the named function declaration or nil.
+func findFuncDecl(decls []ast.Decl, name string) *ast.FuncDecl {
+ for _, decl := range decls {
+ if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name {
+ return fn
+ }
+ }
+ return nil
+}
+
+// findImportDecl returns the first import declaration in f or nil.
+func findImportDecl(f *ast.File) *ast.GenDecl {
+ for _, decl := range f.Decls {
+ if g, ok := decl.(*ast.GenDecl); ok && g.Tok == token.IMPORT {
+ return g
+ }
+ }
+ return nil
+}
+
+// findFuncDeclText returns the source text of the named function
+// declaration, or empty string if not found.
+func findFuncDeclText(
+ fset *token.FileSet, src []byte,
+ decls []ast.Decl, name string,
+) string {
+ for _, decl := range decls {
+ fn, ok := decl.(*ast.FuncDecl)
+ if !ok || fn.Name.Name != name {
+ continue
+ }
+ start := fset.Position(fn.Pos()).Offset
+ end := fset.Position(fn.End()).Offset
+ return strings.TrimSpace(string(src[start:end]))
+ }
+ return ""
+}
+
+// findImportDeclText returns the source text of the first import
+// declaration in f, or empty string if none.
+func findImportDeclText(
+ fset *token.FileSet, src []byte, f *ast.File,
+) string {
+ for _, decl := range f.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.IMPORT {
+ continue
+ }
+ start := fset.Position(genDecl.Pos()).Offset
+ end := fset.Position(genDecl.End()).Offset
+ return strings.TrimSpace(string(src[start:end]))
+ }
+ return ""
+}
+
+// commentBlock prepends "// " to each non-empty line and "//" to
+// empty lines.
+func commentBlock(text string) string {
+ lines := strings.Split(text, "\n")
+ for i, line := range lines {
+ if line == "" {
+ lines[i] = "//"
+ } else {
+ lines[i] = "// " + line
+ }
+ }
+ return strings.Join(lines, "\n")
+}
+
+// insertCommentBefore finds each line containing searchFor and inserts
+// a comment line (with the same indentation) immediately before it.
+func insertCommentBefore(text, searchFor, comment string) string {
+ lines := strings.Split(text, "\n")
+ var result []string
+ for _, line := range lines {
+ if strings.Contains(line, searchFor) {
+ indent := leadingTabs(line)
+ result = append(result, indent+"// "+comment)
+ }
+ result = append(result, line)
+ }
+ return strings.Join(result, "\n")
+}
+
+// insertBefore inserts ins immediately before the first occurrence of
+// before in s.
+func insertBefore(s, before, ins string) string {
+ idx := strings.Index(s, before)
+ if idx < 0 {
+ return s
+ }
+ return s[:idx] + ins + s[idx:]
+}
+
+// leadingTabs returns the leading tab characters of s.
+func leadingTabs(s string) string {
+ trimmed := strings.TrimLeft(s, "\t")
+ return s[:len(s)-len(trimmed)]
+}
+
+// postProcessUnix inserts comment lines for every mutation recorded
+// in notes, and prepends the commented-out systemVerify block before
+// the loadSystemRoots function.
+func postProcessUnix(
+ code string,
+ notes []commentNote,
+ systemVerifyText, funcStart string,
+) string {
+ if systemVerifyText != "" && funcStart != "" {
+ code = insertBefore(
+ code,
+ funcStart,
+ commentBlock(systemVerifyText)+"\n\n",
+ )
+ }
+ for _, note := range notes {
+ code = insertCommentBefore(code, note.searchFor, note.comment)
+ }
+ return code
+}
+
+// postProcessLinux inserts the commented-out import and init() for
+// the linux certloader source. The package name is derived from the
+// AST rather than hardcoded.
+func postProcessLinux(
+ code, packageName, importsText, initText string,
+) string {
+ if importsText != "" {
+ code = strings.Replace(
+ code,
+ "package "+packageName+"\n",
+ "package "+packageName+"\n\n"+commentBlock(importsText)+"\n",
+ 1,
+ )
+ }
+ if initText != "" {
+ code = strings.TrimRight(code, "\n") +
+ "\n\n" + commentBlock(initText) + "\n"
+ }
+ return code
+}
+
+// addImport adds a new import path to the file's existing import block.
+func addImport(f *ast.File, path string) {
+ spec := &ast.ImportSpec{
+ Path: &ast.BasicLit{
+ Kind: token.STRING,
+ Value: `"` + path + `"`,
+ },
+ }
+ f.Imports = append(f.Imports, spec)
+ for _, decl := range f.Decls {
+ genDecl, ok := decl.(*ast.GenDecl)
+ if !ok || genDecl.Tok != token.IMPORT {
+ continue
+ }
+ genDecl.Specs = append([]ast.Spec{spec}, genDecl.Specs...)
+ return
+ }
+ // No import block exists — create one.
+ genDecl := &ast.GenDecl{
+ Tok: token.IMPORT,
+ Lparen: 1,
+ Specs: []ast.Spec{spec},
+ }
+ f.Decls = append([]ast.Decl{genDecl}, f.Decls...)
+}
+
+// removeAllImports removes all import declarations from f.
+func removeAllImports(f *ast.File) {
+ f.Imports = nil
+ var decls []ast.Decl
+ for _, d := range f.Decls {
+ if g, ok := d.(*ast.GenDecl); ok && g.Tok == token.IMPORT {
+ continue
+ }
+ decls = append(decls, d)
+ }
+ f.Decls = decls
+}
+
+func writeOutputText(
+ outDir, outFile, srcFile, ver, code string,
+) error {
+ if err := os.MkdirAll(outDir, 0755); err != nil {
+ return err
+ }
+ var out strings.Builder
+ fmt.Fprintf(
+ &out,
+ "// Code generated by tools/gencertloader"+
+ " from crypto/x509/%s (%s); DO NOT EDIT.\n\n",
+ srcFile, ver,
+ )
+ out.WriteString(code)
+ return os.WriteFile(
+ filepath.Join(outDir, outFile),
+ []byte(out.String()),
+ fs.ModePerm,
+ )
+}