Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
561ec93634 | ||
|
|
a8b80c9a4c |
@@ -1,4 +0,0 @@
|
|||||||
# Every PR adds an entry at the top of TODO.md's Completed Steps; union keeps
|
|
||||||
# both sides instead of conflicting. Git never reports a conflict here: read
|
|
||||||
# the merged entries after every merge or rebase.
|
|
||||||
TODO.md merge=union
|
|
||||||
@@ -414,10 +414,10 @@ pixa finds: `/etc/pixa/config.yml`, `/etc/pixa/config.yaml`,
|
|||||||
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
|
`~/.config/pixa/config.yml`, `~/.config/pixa/config.yaml`, then `config.yml`
|
||||||
and `config.yaml` in the working directory. A named file that does not exist,
|
and `config.yaml` in the working directory. A named file that does not exist,
|
||||||
cannot be read or does not parse aborts startup. Of the files pixa looks for on
|
cannot be read or does not parse aborts startup. Of the files pixa looks for on
|
||||||
its own, only one that does not exist is passed over, without a message. One
|
its own, one it finds but cannot read or parse aborts startup; one it cannot
|
||||||
that pixa cannot read or parse aborts startup, naming the file. So does one in a
|
find, for any reason, is passed over without a message, even when the file is
|
||||||
directory pixa may not enter, whether or not it is there, since pixa cannot
|
there in a directory pixa may not enter. With no file, pixa uses the environment
|
||||||
tell. With no file, pixa uses the environment and the defaults.
|
and the defaults.
|
||||||
|
|
||||||
| Variable | Config key | Meaning |
|
| Variable | Config key | Meaning |
|
||||||
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------- | ---------------------------------------------------------------------------- |
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* branch per issue from `next`
|
* branch per issue from `next`
|
||||||
* do the work in Next Step
|
* do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
* move Next Step to the top of Completed Steps
|
||||||
* `TODO.md` merges with git's union merge (`.gitattributes`), which never
|
|
||||||
reports a conflict: read the merged entries after every merge or rebase
|
|
||||||
* move the top item of Future Steps into Next Step
|
* move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
* open a PR based on `next`
|
* open a PR based on `next`
|
||||||
@@ -31,14 +29,6 @@ P2: security: referer blacklist
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-04 `TODO.md` merges with git's union merge (closes #190): a root
|
|
||||||
`.gitattributes`, copied from `sneak/prompts`, marks it `merge=union`, so two
|
|
||||||
branches that each add an entry at the top of Completed Steps merge without a
|
|
||||||
conflict and keep both entries. Git now never reports a conflict in
|
|
||||||
`TODO.md`: a real one keeps both versions of the lines, and two entries that
|
|
||||||
share an identical line can end up one inside the other, which a rebase can
|
|
||||||
do to an entry already on `next`. The Workflow above says to read the merged
|
|
||||||
entries after every merge or rebase.
|
|
||||||
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
|
- 2026-10-04 the default `cache_max_bytes` no longer shrinks as the cache fills
|
||||||
(closes #184): for an omitted key, the cache works out the limit when it
|
(closes #184): for an omitted key, the cache works out the limit when it
|
||||||
opens, after the database is open, as 75% of the sum of the free space on the
|
opens, after the database is open, as 75% of the sum of the free space on the
|
||||||
@@ -56,11 +46,6 @@ P2: security: referer blacklist
|
|||||||
files and the eviction pass after it evicts them. No other test in
|
files and the eviction pass after it evicts them. No other test in
|
||||||
`internal/imgcache` inserts a row by hand after starting the evictor. Test
|
`internal/imgcache` inserts a row by hand after starting the evictor. Test
|
||||||
only.
|
only.
|
||||||
- 2026-10-04 a config file pixa cannot read aborts startup (closes #176): of the
|
|
||||||
places pixa looks for its config file on its own, only one where the file does
|
|
||||||
not exist is passed over; any other error, such as a directory on the path
|
|
||||||
that pixa may not enter, aborts startup naming the file, as a file that does
|
|
||||||
not parse already did.
|
|
||||||
- 2026-10-04 deployment guide and example Caddy config (closes #89):
|
- 2026-10-04 deployment guide and example Caddy config (closes #89):
|
||||||
"Deployment" in `README.md` says what the reverse proxy in front of pixa must
|
"Deployment" in `README.md` says what the reverse proxy in front of pixa must
|
||||||
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
|
do (terminate TLS; pass `Host`, `Origin` and `Referer` on unchanged; set
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -15,7 +14,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/smartconfig"
|
"git.eeqj.de/sneak/smartconfig"
|
||||||
@@ -783,19 +781,10 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
for _, path := range configPaths {
|
for _, path := range configPaths {
|
||||||
cleanPath := filepath.Clean(path)
|
cleanPath := filepath.Clean(path)
|
||||||
|
|
||||||
// Only a config file that does not exist is skipped, including
|
|
||||||
// one whose path runs through a file, such as under a HOME of
|
|
||||||
// /dev/null. One that cannot be read or does not parse is a
|
|
||||||
// fatal startup error.
|
|
||||||
_, statErr := os.Stat(cleanPath)
|
_, statErr := os.Stat(cleanPath)
|
||||||
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
if statErr == nil {
|
||||||
continue
|
// A config file that exists but does not parse is a fatal
|
||||||
}
|
// startup error, never something to skip over.
|
||||||
|
|
||||||
if statErr != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read config file %s: %w", path, statErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc, err := smartconfig.NewFromConfigPath(path)
|
sc, err := smartconfig.NewFromConfigPath(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
return nil, fmt.Errorf("failed to parse config file %s: %w", path, err)
|
||||||
@@ -805,6 +794,7 @@ func loadConfigFile(log *slog.Logger, appName string) (*smartconfig.Config, erro
|
|||||||
|
|
||||||
return sc, nil
|
return sc, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
return nil, nil //nolint:nilnil // nil config is valid (use defaults)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -564,116 +564,6 @@ func TestMalformedConfigFileAbortsStartup(t *testing.T) {
|
|||||||
t.Logf("got expected error: %v", err)
|
t.Logf("got expected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup checks that a
|
|
||||||
// config file pixa cannot read because it may not enter its directory
|
|
||||||
// aborts startup instead of being passed over.
|
|
||||||
func TestConfigFileInDirectoryPixaMayNotEnterAbortsStartup(t *testing.T) {
|
|
||||||
if os.Geteuid() == 0 {
|
|
||||||
t.Skip("root may enter any directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
home := t.TempDir()
|
|
||||||
configDir := filepath.Join(home, ".config", "pixa-test-nonexistent-app")
|
|
||||||
configPath := filepath.Join(configDir, "config.yml")
|
|
||||||
|
|
||||||
err := os.MkdirAll(configDir, 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create config directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.WriteFile(configPath, []byte(signingKeyLine), 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to write config: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = os.Chmod(configDir, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to remove the config directory's permissions: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Give the directory back its permissions so t.TempDir can remove it.
|
|
||||||
t.Cleanup(func() {
|
|
||||||
//nolint:gosec // G302: a directory needs its execute bit to be removed
|
|
||||||
_ = os.Chmod(configDir, 0o700)
|
|
||||||
})
|
|
||||||
|
|
||||||
// The ~/.config candidate is the only one that exists: the appname
|
|
||||||
// rules out /etc, and the working directory is empty.
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
|
||||||
t.Setenv("HOME", home)
|
|
||||||
t.Chdir(t.TempDir())
|
|
||||||
|
|
||||||
log := slog.New(slog.DiscardHandler)
|
|
||||||
|
|
||||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
|
||||||
sc)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("got expected error: %v", err)
|
|
||||||
|
|
||||||
if !strings.Contains(err.Error(), configPath) {
|
|
||||||
t.Errorf("error %q does not name the config file %s", err.Error(), configPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigFileLinkingToItselfAbortsStartup checks that a config file
|
|
||||||
// pixa cannot read for a reason other than not existing aborts startup,
|
|
||||||
// as root too: a symbolic link to itself fails with "too many levels of
|
|
||||||
// symbolic links".
|
|
||||||
func TestConfigFileLinkingToItselfAbortsStartup(t *testing.T) {
|
|
||||||
workDir := t.TempDir()
|
|
||||||
|
|
||||||
err := os.Symlink("config.yml", filepath.Join(workDir, "config.yml"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to create symbolic link: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only the working directory's config.yml is there: the appname rules
|
|
||||||
// out /etc, and HOME is empty.
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
|
||||||
t.Setenv("HOME", t.TempDir())
|
|
||||||
t.Chdir(workDir)
|
|
||||||
|
|
||||||
log := slog.New(slog.DiscardHandler)
|
|
||||||
|
|
||||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("config file pixa cannot read must abort startup, got config: %v",
|
|
||||||
sc)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("got expected error: %v", err)
|
|
||||||
|
|
||||||
if !strings.Contains(err.Error(), "config.yml") {
|
|
||||||
t.Errorf("error %q does not name the config file config.yml", err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConfigPathThroughFileIsPassedOver checks that a config file path
|
|
||||||
// that runs through a file, such as one under a HOME of /dev/null, is
|
|
||||||
// passed over like one that does not exist, since no file can be there.
|
|
||||||
func TestConfigPathThroughFileIsPassedOver(t *testing.T) {
|
|
||||||
// No config file is there: the appname rules out /etc, HOME is
|
|
||||||
// /dev/null, and the working directory is empty.
|
|
||||||
t.Setenv("PIXA_CONFIG_PATH", "")
|
|
||||||
t.Setenv("HOME", os.DevNull)
|
|
||||||
t.Chdir(t.TempDir())
|
|
||||||
|
|
||||||
log := slog.New(slog.DiscardHandler)
|
|
||||||
|
|
||||||
sc, err := loadConfigFile(log, "pixa-test-nonexistent-app")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("a config path through a file must be passed over, got error: %v",
|
|
||||||
err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if sc != nil {
|
|
||||||
t.Errorf("expected no config file, got config: %v", sc)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
func TestEnsureStateDirCreatesDirectory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -13,79 +13,6 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/logger"
|
"sneak.berlin/go/pixa/internal/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNewCacheConfigFromCacheMaxBytes checks the cache configuration
|
|
||||||
// built from cache_max_bytes: omitted, the cache works out the default
|
|
||||||
// limit; 0 turns the disk cache off; a positive value is the limit,
|
|
||||||
// unchanged.
|
|
||||||
func TestNewCacheConfigFromCacheMaxBytes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const oneGiB = 1 << 30
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cacheMaxBytes int64
|
|
||||||
cacheMaxBytesExplicit bool
|
|
||||||
wantMaxBytes int64
|
|
||||||
wantUseDefaultMaxBytes bool
|
|
||||||
wantDisableDiskCache bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "cache_max_bytes omitted",
|
|
||||||
cacheMaxBytes: 0,
|
|
||||||
cacheMaxBytesExplicit: false,
|
|
||||||
wantMaxBytes: 0,
|
|
||||||
wantUseDefaultMaxBytes: true,
|
|
||||||
wantDisableDiskCache: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cache_max_bytes: 0",
|
|
||||||
cacheMaxBytes: 0,
|
|
||||||
cacheMaxBytesExplicit: true,
|
|
||||||
wantMaxBytes: 0,
|
|
||||||
wantUseDefaultMaxBytes: false,
|
|
||||||
wantDisableDiskCache: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cache_max_bytes: 1 GiB",
|
|
||||||
cacheMaxBytes: oneGiB,
|
|
||||||
cacheMaxBytesExplicit: true,
|
|
||||||
wantMaxBytes: oneGiB,
|
|
||||||
wantUseDefaultMaxBytes: false,
|
|
||||||
wantDisableDiskCache: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cfg := &config.Config{
|
|
||||||
CacheMaxBytes: tc.cacheMaxBytes,
|
|
||||||
CacheMaxBytesExplicit: tc.cacheMaxBytesExplicit,
|
|
||||||
}
|
|
||||||
|
|
||||||
got := newCacheConfig(cfg, nil)
|
|
||||||
t.Logf("MaxBytes = %d, UseDefaultMaxBytes = %v, DisableDiskCache = %v",
|
|
||||||
got.MaxBytes, got.UseDefaultMaxBytes, got.DisableDiskCache)
|
|
||||||
|
|
||||||
if got.MaxBytes != tc.wantMaxBytes {
|
|
||||||
t.Errorf("MaxBytes = %d, want %d", got.MaxBytes, tc.wantMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.UseDefaultMaxBytes != tc.wantUseDefaultMaxBytes {
|
|
||||||
t.Errorf("UseDefaultMaxBytes = %v, want %v",
|
|
||||||
got.UseDefaultMaxBytes, tc.wantUseDefaultMaxBytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got.DisableDiskCache != tc.wantDisableDiskCache {
|
|
||||||
t.Errorf("DisableDiskCache = %v, want %v",
|
|
||||||
got.DisableDiskCache, tc.wantDisableDiskCache)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
|
// TestDiskCacheOffOnlyForExplicitZeroCacheMaxBytes starts the handlers
|
||||||
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
|
// once with cache_max_bytes omitted and once with cache_max_bytes: 0,
|
||||||
// and checks by whether the cache directories were created that the
|
// and checks by whether the cache directories were created that the
|
||||||
|
|||||||
Reference in New Issue
Block a user