secfile

package
v0.0.9 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 25, 2026 License: MIT Imports: 3 Imported by: 0

Documentation

Overview

Package secfile holds small file-permission primitives used by the nine provider conversation-history modules. It exists to keep the hardening from drifting again (issue #22): every history-file Save goes through WritePrivate and every Load goes through ReadPrivate, so adding a tenth provider can't reintroduce world-readable Q&A.

This is a security primitive (file modes + Chmod-repair), not a conversation-history abstraction. The larger refactor — extracting shared Load/Save logic across providers — is tracked in #25.

Index

Constants

View Source
const (
	PrivateDirMode  os.FileMode = 0o700
	PrivateFileMode os.FileMode = 0o600
)

PrivateDirMode and PrivateFileMode are the modes we want every history file and its parent directory to end up at. 0o700 / 0o600 keeps conversation contents out of non-owner reach on shared boxes (CI runners, EC2 bastions, multi-UID containers).

Variables

This section is empty.

Functions

func EnsurePrivateDir

func EnsurePrivateDir(dir string) error

EnsurePrivateDir creates dir (and parents) with 0o700, then Chmods it to 0o700 in case it already existed with looser perms. Chmod is a no-op on Windows; safe to call on every Save.

func ReadPrivate

func ReadPrivate(path string) ([]byte, error)

ReadPrivate opens path read-only, repairs its perms via the file descriptor (NOT the path — avoids TOCTOU where a local attacker could rename or symlink between read and chmod), then reads it all. Returns the file contents.

func SafeSlug

func SafeSlug(s string) string

SafeSlug strips every byte outside [A-Za-z0-9_-] from s so that caller-supplied identifiers (cluster names, account IDs, org slugs) cannot escape ~/.clanker via path traversal. Inputs like "../../etc/passwd" collapse to "etcpasswd"; "my.cluster" → "mycluster".

We iterate as bytes rather than runes intentionally: multi-byte UTF-8 codepoints all fall outside the ASCII allowlist, so they would be dropped byte-by-byte either way. Operating on bytes keeps the function predictable and ~5x cheaper, and matches the historic pattern in the three providers that got this right (sentry, linear, notion).

The result is bounded to 64 bytes (see maxSlugLen). An empty result returns "default" so the caller never produces a hidden file (".json") or an empty filename.

Note on collisions: distinct identifiers that differ only in stripped characters (e.g. "acme/prod" and "acme-prod") will produce the same slug. In practice the inputs are constrained (UUIDs, hex IDs, 12-digit AWS account numbers) so collisions are exceedingly rare. The cross-tenant "default" fallback collision (two unset identifiers both writing to the same history file) is tracked in #25 and deliberately out of scope here.

func WritePrivate

func WritePrivate(path string, data []byte) error

WritePrivate writes data to path with 0o600. If the file already exists with looser perms (pre-fix users), WriteFile does NOT re-apply the mode — so we Chmod afterwards as a belt-and-braces repair. Chmod is a no-op on Windows.

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL