Consolidate edit-history bookkeeping under the single reserved .zddc.d/
sidecar (where tokens + access logs already live), instead of its own
top-level .history/ dot-name:
- history.go: record + text history now write/read <dir>/.zddc.d/history/<stem>/
(was <dir>/.history/<stem>/). Const renamed .history → .zddc.d/history and
unexported (the only external user was the dispatch carve-out). The history
VIEWER endpoints (<record>.yaml?history=1, <file>?history=…) read it
server-side, so they keep working for anyone with read on the live file;
the raw store is bookkeeping, blocked by the existing dot-prefix guard.
- main.go: drop the .history GET carve-out (b9ebee7) — superseded; history is
reached via the viewer, not raw browsing. Reword the guard comment to
"reserve .zddc.d/ bookkeeping" (Part B will replace the blanket block with a
.zddc.d/ admin-fence).
- Delete dead .devshell references (the dev-shell was dropped from the chart):
guard comment, paths.go comment, test fixtures/cases (→ .zddc.d), and docs.
This is Part A of the approved plan: ship history in its permanent home so we
never migrate it twice. Tests updated to the new paths; the obsolete
TestDispatchHistoryReadCarveOut is removed (raw-block covered by
TestDispatchHidesDotPrefixedSegments, viewer by mdhistory_test).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
85 lines
2.5 KiB
Go
85 lines
2.5 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// URL ↔ filesystem path math used by several handler files. Pure
|
|
// string manipulation — no I/O, no policy decisions — so it lives
|
|
// in its own file rather than being attached to any one feature.
|
|
|
|
// resolvePath translates a URL `path=` query (relative to fsRoot, with
|
|
// '/' separator and leading '/') into an absolute filesystem path. It
|
|
// rejects path traversal and any segment beginning with '.' or '_' so
|
|
// reserved namespaces (e.g. the .zddc.d/ bookkeeping sidecar) cannot be
|
|
// addressed through admin APIs. Returns the cleaned absolute path or an
|
|
// error suitable for a 404.
|
|
func resolvePath(fsRoot, urlPath string) (string, error) {
|
|
urlPath = strings.TrimSpace(urlPath)
|
|
if urlPath == "" {
|
|
urlPath = "/"
|
|
}
|
|
if !strings.HasPrefix(urlPath, "/") {
|
|
return "", errors.New("path must be absolute (start with /)")
|
|
}
|
|
cleanURL := filepath.ToSlash(filepath.Clean(urlPath))
|
|
|
|
// Reject reserved-prefix segments so callers cannot create
|
|
// .foo/.zddc or _bar/.zddc through admin APIs.
|
|
for _, seg := range strings.Split(strings.Trim(cleanURL, "/"), "/") {
|
|
if seg == "" {
|
|
continue
|
|
}
|
|
if strings.HasPrefix(seg, ".") || strings.HasPrefix(seg, "_") {
|
|
return "", errors.New("reserved-prefix path segment")
|
|
}
|
|
}
|
|
|
|
rel := strings.TrimPrefix(cleanURL, "/")
|
|
abs := filepath.Join(fsRoot, filepath.FromSlash(rel))
|
|
abs = filepath.Clean(abs)
|
|
|
|
// Path containment.
|
|
if abs != fsRoot && !strings.HasPrefix(abs, fsRoot+string(filepath.Separator)) {
|
|
return "", errors.New("path escapes root")
|
|
}
|
|
return abs, nil
|
|
}
|
|
|
|
// urlPathOf produces the URL form of an absolute filesystem path under
|
|
// fsRoot. Returns "/" for fsRoot itself, otherwise "/<rel>".
|
|
func urlPathOf(fsRoot, abs string) string {
|
|
if abs == fsRoot {
|
|
return "/"
|
|
}
|
|
rel, err := filepath.Rel(fsRoot, abs)
|
|
if err != nil {
|
|
return "/"
|
|
}
|
|
return "/" + filepath.ToSlash(rel)
|
|
}
|
|
|
|
// chainDirs reproduces EffectivePolicy's directory walk so callers can
|
|
// label each policy-chain level with the directory it came from. Used
|
|
// by the virtual-.zddc body to annotate which ancestor contributed
|
|
// which rule.
|
|
func chainDirs(fsRoot, dirPath string) []string {
|
|
fsRoot = filepath.Clean(fsRoot)
|
|
dirPath = filepath.Clean(dirPath)
|
|
dirs := []string{fsRoot}
|
|
if dirPath == fsRoot {
|
|
return dirs
|
|
}
|
|
rel, err := filepath.Rel(fsRoot, dirPath)
|
|
if err != nil || rel == "." {
|
|
return dirs
|
|
}
|
|
current := fsRoot
|
|
for _, part := range strings.Split(rel, string(filepath.Separator)) {
|
|
current = filepath.Join(current, part)
|
|
dirs = append(dirs, current)
|
|
}
|
|
return dirs
|
|
}
|