mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-14 17:03:25 +08:00
- 新增脱敏审计存储、筛选、保留策略、完整性校验与 JSON/CSV 导出 - 覆盖查询编辑器、事务、导入同步、对象操作、AI/MCP 与 Web 运行时入口 - 完善事务完整语句日志、Windows 快捷键映射及 SQL 分析布局 - 补充多语言、健康状态、数据目录迁移和回归测试
1162 lines
35 KiB
Go
1162 lines
35 KiB
Go
package app
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"GoNavi-Wails/internal/connection"
|
||
"GoNavi-Wails/internal/logger"
|
||
"GoNavi-Wails/internal/sqlaudit"
|
||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||
)
|
||
|
||
const sqlAuditFingerprintVersion = "sql-audit-connection-v1"
|
||
|
||
var errSQLAuditTemporarilyUnavailable = errors.New("SQL audit store is temporarily unavailable during data-root switching")
|
||
|
||
var replaceSQLAuditFile = atomicReplaceSQLAuditFile
|
||
|
||
var closeSQLAuditStoreHandle = func(store *sqlaudit.Store) error {
|
||
return store.Close()
|
||
}
|
||
|
||
const (
|
||
sqlAuditHealthStatusHealthy = "healthy"
|
||
sqlAuditHealthStatusDegraded = "degraded"
|
||
webSQLAuditExportMaxRecords = int64(10_000)
|
||
webSQLAuditExportMaxBytes = 8 * 1024 * 1024
|
||
)
|
||
|
||
type sqlAuditHealthState struct {
|
||
Status string `json:"status"`
|
||
DroppedEvents int64 `json:"droppedEvents"`
|
||
FirstFailureAt int64 `json:"firstFailureAt"`
|
||
LastFailureAt int64 `json:"lastFailureAt"`
|
||
LastSuccessAt int64 `json:"lastSuccessAt"`
|
||
LastError string `json:"lastError"`
|
||
// CaptureEnabled and CaptureMode are populated only for the API response.
|
||
// Pointer + omitempty keeps the durable health sidecar independent from
|
||
// settings while still representing an explicitly disabled capture state.
|
||
CaptureEnabled *bool `json:"captureEnabled,omitempty"`
|
||
CaptureMode string `json:"captureMode,omitempty"`
|
||
}
|
||
|
||
type sqlAuditPendingGap struct {
|
||
DroppedEvents int64
|
||
FirstFailureAt int64
|
||
LastFailureAt int64
|
||
LastError string
|
||
}
|
||
|
||
type sqlAuditQueryInput struct {
|
||
Config connection.ConnectionConfig
|
||
Database string
|
||
DBType string
|
||
QueryID string
|
||
SQL string
|
||
Source string
|
||
CommitMode string
|
||
Duration time.Duration
|
||
StatementCount int
|
||
Result connection.QueryResult
|
||
}
|
||
|
||
type sqlAuditTransactionEventInput struct {
|
||
Config connection.ConnectionConfig
|
||
Database string
|
||
DBType string
|
||
QueryID string
|
||
TransactionID string
|
||
EventType string
|
||
Status string
|
||
Source string
|
||
CommitMode string
|
||
BoundaryMode string
|
||
SQL string
|
||
StatementIndex int
|
||
StatementCount int
|
||
Duration time.Duration
|
||
RowsAffected int64
|
||
RowsReturned int64
|
||
Err error
|
||
}
|
||
|
||
type sqlAuditExportPayload struct {
|
||
FileName string `json:"fileName"`
|
||
MimeType string `json:"mimeType"`
|
||
Content string `json:"content"`
|
||
}
|
||
|
||
func (a *App) sqlAuditDatabasePath() string {
|
||
return filepath.Join(a.auditRootDir(), "audit", "sql_audit.db")
|
||
}
|
||
|
||
func (a *App) sqlAuditHealthFilePath() string {
|
||
return filepath.Join(a.auditRootDir(), "audit", "sql_audit_health.json")
|
||
}
|
||
|
||
func normalizeSQLAuditHealth(state sqlAuditHealthState) sqlAuditHealthState {
|
||
state.CaptureEnabled = nil
|
||
state.CaptureMode = ""
|
||
if state.Status != sqlAuditHealthStatusDegraded {
|
||
state.Status = sqlAuditHealthStatusHealthy
|
||
}
|
||
if state.DroppedEvents < 0 {
|
||
state.DroppedEvents = 0
|
||
}
|
||
if state.FirstFailureAt < 0 {
|
||
state.FirstFailureAt = 0
|
||
}
|
||
if state.LastFailureAt < 0 {
|
||
state.LastFailureAt = 0
|
||
}
|
||
if state.LastSuccessAt < 0 {
|
||
state.LastSuccessAt = 0
|
||
}
|
||
state.LastError = sqlaudit.RedactError(state.LastError)
|
||
return state
|
||
}
|
||
|
||
func (a *App) loadSQLAuditHealth(path string, pendingGap sqlAuditPendingGap) {
|
||
path = filepath.Clean(path)
|
||
loaded := sqlAuditHealthState{Status: sqlAuditHealthStatusHealthy}
|
||
payload, err := os.ReadFile(path)
|
||
if err == nil {
|
||
if decodeErr := json.Unmarshal(payload, &loaded); decodeErr != nil {
|
||
loaded = sqlAuditHealthState{
|
||
Status: sqlAuditHealthStatusDegraded,
|
||
DroppedEvents: 1,
|
||
FirstFailureAt: time.Now().UnixMilli(),
|
||
LastFailureAt: time.Now().UnixMilli(),
|
||
LastError: "SQL audit health state could not be decoded",
|
||
}
|
||
}
|
||
} else if !os.IsNotExist(err) {
|
||
loaded = sqlAuditHealthState{
|
||
Status: sqlAuditHealthStatusDegraded,
|
||
DroppedEvents: 1,
|
||
FirstFailureAt: time.Now().UnixMilli(),
|
||
LastFailureAt: time.Now().UnixMilli(),
|
||
LastError: "SQL audit health state could not be read",
|
||
}
|
||
}
|
||
loaded = normalizeSQLAuditHealth(loaded)
|
||
|
||
// Carry only events actually dropped during the suspension. Historical state
|
||
// belongs to the selected target root (whether migrated or pre-existing) and
|
||
// must not be copied or max-merged from the previous root.
|
||
a.sqlAuditHealthMu.Lock()
|
||
if pendingGap.DroppedEvents > 0 {
|
||
if loaded.Status != sqlAuditHealthStatusDegraded {
|
||
loaded.Status = sqlAuditHealthStatusDegraded
|
||
loaded.DroppedEvents = 0
|
||
loaded.FirstFailureAt = pendingGap.FirstFailureAt
|
||
} else if pendingGap.FirstFailureAt > 0 && (loaded.FirstFailureAt == 0 || pendingGap.FirstFailureAt < loaded.FirstFailureAt) {
|
||
loaded.FirstFailureAt = pendingGap.FirstFailureAt
|
||
}
|
||
loaded.DroppedEvents += pendingGap.DroppedEvents
|
||
if pendingGap.LastFailureAt >= loaded.LastFailureAt {
|
||
loaded.LastFailureAt = pendingGap.LastFailureAt
|
||
loaded.LastError = pendingGap.LastError
|
||
}
|
||
}
|
||
a.sqlAuditHealth = normalizeSQLAuditHealth(loaded)
|
||
a.sqlAuditHealthPath = path
|
||
a.sqlAuditHealthRevision++
|
||
snapshot := a.sqlAuditHealth
|
||
a.sqlAuditHealthMu.Unlock()
|
||
|
||
if snapshot.Status == sqlAuditHealthStatusDegraded {
|
||
a.persistSQLAuditHealth(snapshot, path)
|
||
}
|
||
}
|
||
|
||
func (a *App) persistSQLAuditHealth(state sqlAuditHealthState, path string) {
|
||
state = normalizeSQLAuditHealth(state)
|
||
payload, err := json.MarshalIndent(state, "", " ")
|
||
if err != nil {
|
||
logger.Warnf("编码 SQL 审计健康状态失败:%v", err)
|
||
return
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||
logger.Warnf("创建 SQL 审计健康状态目录失败:%v", err)
|
||
return
|
||
}
|
||
if err := writeSQLAuditExportAtomically(path, append(payload, '\n')); err != nil {
|
||
logger.Warnf("持久化 SQL 审计健康状态失败:%v", err)
|
||
}
|
||
}
|
||
|
||
func (a *App) sqlAuditHealthSnapshot() sqlAuditHealthState {
|
||
state, _ := a.sqlAuditHealthSnapshotWithRevision()
|
||
return state
|
||
}
|
||
|
||
func (a *App) sqlAuditHealthSnapshotWithRevision() (sqlAuditHealthState, uint64) {
|
||
a.sqlAuditHealthMu.RLock()
|
||
defer a.sqlAuditHealthMu.RUnlock()
|
||
return normalizeSQLAuditHealth(a.sqlAuditHealth), a.sqlAuditHealthRevision
|
||
}
|
||
|
||
// withSQLAuditStore serializes store lifecycle changes with operations. SQLite
|
||
// still owns record-level concurrency; this lock prevents a data-root switch
|
||
// from closing the handle between lookup and use.
|
||
func (a *App) withSQLAuditStore(requireRuntimeActive bool, operation func(*sqlaudit.Store) error) error {
|
||
for {
|
||
a.sqlAuditMu.RLock()
|
||
if a.sqlAuditSuspended {
|
||
a.sqlAuditMu.RUnlock()
|
||
return errSQLAuditTemporarilyUnavailable
|
||
}
|
||
if requireRuntimeActive && !a.sqlAuditRuntimeActive {
|
||
a.sqlAuditMu.RUnlock()
|
||
return nil
|
||
}
|
||
path := filepath.Clean(a.sqlAuditDatabasePath())
|
||
if a.sqlAuditStore != nil && filepath.Clean(a.sqlAuditStorePath) == path {
|
||
var err error
|
||
if operation != nil {
|
||
err = operation(a.sqlAuditStore)
|
||
}
|
||
a.sqlAuditMu.RUnlock()
|
||
return err
|
||
}
|
||
a.sqlAuditMu.RUnlock()
|
||
|
||
a.sqlAuditMu.Lock()
|
||
if a.sqlAuditSuspended {
|
||
a.sqlAuditMu.Unlock()
|
||
return errSQLAuditTemporarilyUnavailable
|
||
}
|
||
if requireRuntimeActive && !a.sqlAuditRuntimeActive {
|
||
a.sqlAuditMu.Unlock()
|
||
return nil
|
||
}
|
||
_, err := a.ensureSQLAuditStoreLocked()
|
||
a.sqlAuditMu.Unlock()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
|
||
func (a *App) ensureSQLAuditStoreLocked() (*sqlaudit.Store, error) {
|
||
path := filepath.Clean(a.sqlAuditDatabasePath())
|
||
if a.sqlAuditStore != nil && filepath.Clean(a.sqlAuditStorePath) == path {
|
||
return a.sqlAuditStore, nil
|
||
}
|
||
if a.sqlAuditStore != nil {
|
||
if err := a.sqlAuditStore.Close(); err != nil {
|
||
logger.Warnf("关闭旧 SQL 审计存储失败:%v", err)
|
||
}
|
||
a.sqlAuditStore = nil
|
||
a.sqlAuditStorePath = ""
|
||
}
|
||
store, err := sqlaudit.Open(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
a.sqlAuditStore = store
|
||
a.sqlAuditStorePath = path
|
||
return store, nil
|
||
}
|
||
|
||
func (a *App) activateSQLAudit() {
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
pendingGap := sqlAuditPendingGap{
|
||
DroppedEvents: a.sqlAuditSuspensionDropped,
|
||
FirstFailureAt: a.sqlAuditSuspensionFirstAt,
|
||
LastFailureAt: a.sqlAuditSuspensionLastAt,
|
||
LastError: a.sqlAuditSuspensionLastError,
|
||
}
|
||
a.sqlAuditSuspensionDropped = 0
|
||
a.sqlAuditSuspensionFirstAt = 0
|
||
a.sqlAuditSuspensionLastAt = 0
|
||
a.sqlAuditSuspensionLastError = ""
|
||
|
||
a.sqlAuditMu.Lock()
|
||
// Keep all appenders out until both the store and its persisted health state
|
||
// have been restored for the current data root.
|
||
a.sqlAuditRuntimeActive = false
|
||
a.sqlAuditSuspended = true
|
||
_, err := a.ensureSQLAuditStoreLocked()
|
||
a.loadSQLAuditHealth(a.sqlAuditHealthFilePath(), pendingGap)
|
||
if err != nil {
|
||
// SQL execution remains fail-open; users can see the same error when opening
|
||
// the audit center instead of losing the database operation itself.
|
||
a.markSQLAuditFailure(0, err)
|
||
}
|
||
// Keep runtime auditing active even after an open failure so a later write can
|
||
// retry opening the store and close the degraded state with a durable marker.
|
||
a.sqlAuditRuntimeActive = true
|
||
a.sqlAuditSuspended = false
|
||
a.sqlAuditMu.Unlock()
|
||
if err != nil {
|
||
logger.Warnf("初始化 SQL 审计存储失败:%v", err)
|
||
}
|
||
}
|
||
|
||
func (a *App) suspendSQLAudit() (bool, error) {
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
a.sqlAuditSuspensionDropped = 0
|
||
a.sqlAuditSuspensionFirstAt = 0
|
||
a.sqlAuditSuspensionLastAt = 0
|
||
a.sqlAuditSuspensionLastError = ""
|
||
a.sqlAuditMu.Lock()
|
||
wasActive := a.sqlAuditRuntimeActive
|
||
a.sqlAuditRuntimeActive = false
|
||
a.sqlAuditSuspended = true
|
||
var closeErr error
|
||
if a.sqlAuditStore != nil {
|
||
if closeErr = closeSQLAuditStoreHandle(a.sqlAuditStore); closeErr != nil {
|
||
logger.Warnf("暂停 SQL 审计时 checkpoint/关闭存储失败:%v", closeErr)
|
||
}
|
||
}
|
||
a.sqlAuditStore = nil
|
||
a.sqlAuditStorePath = ""
|
||
a.sqlAuditMu.Unlock()
|
||
return wasActive, closeErr
|
||
}
|
||
|
||
func (a *App) resumeSQLAudit(wasActive bool) {
|
||
if wasActive {
|
||
a.activateSQLAudit()
|
||
return
|
||
}
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
a.sqlAuditMu.Lock()
|
||
a.sqlAuditSuspended = false
|
||
a.sqlAuditMu.Unlock()
|
||
}
|
||
|
||
func (a *App) closeSQLAuditStore() {
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
a.sqlAuditMu.Lock()
|
||
defer a.sqlAuditMu.Unlock()
|
||
a.sqlAuditRuntimeActive = false
|
||
a.sqlAuditSuspended = true
|
||
if a.sqlAuditStore == nil {
|
||
return
|
||
}
|
||
if err := closeSQLAuditStoreHandle(a.sqlAuditStore); err != nil {
|
||
logger.Warnf("关闭 SQL 审计存储失败:%v", err)
|
||
}
|
||
a.sqlAuditStore = nil
|
||
a.sqlAuditStorePath = ""
|
||
}
|
||
|
||
func (a *App) recordSQLAuditQuery(input sqlAuditQueryInput) {
|
||
status := sqlAuditStatusFromResult(input.Result)
|
||
statementCount := input.StatementCount
|
||
if statementCount <= 0 {
|
||
statementCount = countSQLAuditStatements(input.SQL)
|
||
}
|
||
event := sqlaudit.Event{
|
||
EventType: "query",
|
||
Status: status,
|
||
ConnectionID: strings.TrimSpace(input.Config.ID),
|
||
ConnectionFingerprint: buildSQLAuditConnectionFingerprint(input.Config, input.Database),
|
||
DBType: strings.ToLower(strings.TrimSpace(input.DBType)),
|
||
Database: resolveSQLAuditDatabase(input.Config, input.Database),
|
||
QueryID: strings.TrimSpace(input.QueryID),
|
||
Source: normalizeSQLAuditSource(input.Source),
|
||
CommitMode: normalizeSQLAuditCommitMode(input.CommitMode),
|
||
SQLText: input.SQL,
|
||
StatementCount: statementCount,
|
||
DurationMs: durationMilliseconds(input.Duration),
|
||
RowsAffected: sqlAuditRowsAffected(input.Result),
|
||
RowsReturned: queryResultRowsReturned(input.Result),
|
||
}
|
||
if status != "success" {
|
||
event.Error = strings.TrimSpace(input.Result.Message)
|
||
}
|
||
a.appendSQLAuditEvent(event)
|
||
}
|
||
|
||
func (a *App) sqlAuditTransactionStatementObserver(
|
||
config connection.ConnectionConfig,
|
||
database string,
|
||
dbType string,
|
||
queryID string,
|
||
transactionID string,
|
||
boundaryMode string,
|
||
events *[]sqlaudit.Event,
|
||
) managedSQLStatementObserver {
|
||
return func(observation managedSQLStatementObservation) {
|
||
input := sqlAuditTransactionEventInput{
|
||
Config: config,
|
||
Database: database,
|
||
DBType: dbType,
|
||
QueryID: queryID,
|
||
TransactionID: transactionID,
|
||
EventType: "transaction_statement",
|
||
Status: sqlAuditStatusFromError(observation.Err),
|
||
Source: "query_editor",
|
||
CommitMode: "pending",
|
||
BoundaryMode: boundaryMode,
|
||
SQL: observation.Statement,
|
||
StatementIndex: observation.StatementIndex,
|
||
StatementCount: observation.StatementCount,
|
||
Duration: observation.Duration,
|
||
RowsAffected: observation.RowsAffected,
|
||
RowsReturned: observation.RowsReturned,
|
||
Err: observation.Err,
|
||
}
|
||
event := buildSQLAuditTransactionEvent(input)
|
||
if events == nil {
|
||
a.appendSQLAuditEvent(event)
|
||
return
|
||
}
|
||
*events = append(*events, event)
|
||
}
|
||
}
|
||
|
||
func (a *App) recordSQLAuditTransactionEvent(input sqlAuditTransactionEventInput) {
|
||
a.appendSQLAuditEvent(buildSQLAuditTransactionEvent(input))
|
||
}
|
||
|
||
func buildSQLAuditTransactionEvent(input sqlAuditTransactionEventInput) sqlaudit.Event {
|
||
event := sqlaudit.Event{
|
||
EventType: strings.TrimSpace(input.EventType),
|
||
Status: normalizeSQLAuditStatus(input.Status),
|
||
ConnectionID: strings.TrimSpace(input.Config.ID),
|
||
ConnectionFingerprint: buildSQLAuditConnectionFingerprint(input.Config, input.Database),
|
||
DBType: strings.ToLower(strings.TrimSpace(input.DBType)),
|
||
Database: resolveSQLAuditDatabase(input.Config, input.Database),
|
||
QueryID: strings.TrimSpace(input.QueryID),
|
||
TransactionID: strings.TrimSpace(input.TransactionID),
|
||
Source: normalizeSQLAuditSource(input.Source),
|
||
CommitMode: normalizeSQLAuditCommitMode(input.CommitMode),
|
||
BoundaryMode: normalizeSQLAuditBoundaryMode(input.BoundaryMode),
|
||
SQLText: input.SQL,
|
||
StatementIndex: input.StatementIndex,
|
||
StatementCount: input.StatementCount,
|
||
DurationMs: durationMilliseconds(input.Duration),
|
||
RowsAffected: input.RowsAffected,
|
||
RowsReturned: input.RowsReturned,
|
||
}
|
||
if input.Err != nil {
|
||
event.Error = input.Err.Error()
|
||
}
|
||
return event
|
||
}
|
||
|
||
func (a *App) appendSQLAuditEvent(event sqlaudit.Event) {
|
||
a.appendSQLAuditEvents([]sqlaudit.Event{event})
|
||
}
|
||
|
||
func (a *App) appendSQLAuditEvents(events []sqlaudit.Event) {
|
||
if len(events) == 0 {
|
||
return
|
||
}
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
|
||
health := a.sqlAuditHealthSnapshot()
|
||
wasDegraded := health.Status == sqlAuditHealthStatusDegraded
|
||
var gapEvent *sqlaudit.Event
|
||
if wasDegraded {
|
||
gap := buildSQLAuditGapEvent(health)
|
||
gapEvent = &gap
|
||
}
|
||
|
||
auditDisabled := false
|
||
gapWriteFailed := false
|
||
err := a.withSQLAuditStore(true, func(store *sqlaudit.Store) error {
|
||
if wasDegraded {
|
||
settings, settingsErr := store.GetSettings()
|
||
if settingsErr != nil {
|
||
return settingsErr
|
||
}
|
||
if !settings.Enabled {
|
||
auditDisabled = true
|
||
return nil
|
||
}
|
||
}
|
||
if appendErr := store.AppendBatch(events); appendErr != nil {
|
||
return appendErr
|
||
}
|
||
if gapEvent != nil {
|
||
// Keep the gap marker as the newest record. AppendBatch intentionally
|
||
// retains only the configured tail, so prepending the marker to a large
|
||
// transaction batch could otherwise evict it and falsely report recovery.
|
||
if appendErr := store.Append(*gapEvent); appendErr != nil {
|
||
gapWriteFailed = true
|
||
return appendErr
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
first := events[0]
|
||
logger.Warnf(
|
||
"写入 SQL 审计失败(已按 fail-open 保留数据库操作结果):count=%d event=%s tx=%s query=%s err=%v",
|
||
len(events),
|
||
first.EventType,
|
||
first.TransactionID,
|
||
first.QueryID,
|
||
err,
|
||
)
|
||
dropped := int64(len(events))
|
||
if gapWriteFailed {
|
||
// The current events were durable; only the marker failed. Preserve the
|
||
// existing unresolved gap count without counting these events as lost.
|
||
dropped = 0
|
||
}
|
||
a.markSQLAuditFailure(dropped, err)
|
||
return
|
||
}
|
||
if auditDisabled {
|
||
return
|
||
}
|
||
a.markSQLAuditSuccess(wasDegraded)
|
||
}
|
||
|
||
func buildSQLAuditGapEvent(health sqlAuditHealthState) sqlaudit.Event {
|
||
message := "SQL audit storage recovered after a writer failure; no dropped-event count was available"
|
||
if health.DroppedEvents > 0 {
|
||
message = fmt.Sprintf(
|
||
"SQL audit storage recovered after a persistence gap; %d event(s) were not persisted",
|
||
health.DroppedEvents,
|
||
)
|
||
}
|
||
return sqlaudit.Event{
|
||
Timestamp: time.Now().UnixMilli(),
|
||
EventType: "audit_gap",
|
||
Status: "error",
|
||
Source: "system",
|
||
Error: message,
|
||
}
|
||
}
|
||
|
||
func (a *App) markSQLAuditFailure(dropped int64, auditErr error) {
|
||
now := time.Now().UnixMilli()
|
||
a.sqlAuditHealthMu.Lock()
|
||
state := normalizeSQLAuditHealth(a.sqlAuditHealth)
|
||
if state.Status != sqlAuditHealthStatusDegraded {
|
||
state.Status = sqlAuditHealthStatusDegraded
|
||
state.DroppedEvents = 0
|
||
state.FirstFailureAt = now
|
||
}
|
||
if dropped > 0 {
|
||
state.DroppedEvents += dropped
|
||
}
|
||
state.LastFailureAt = now
|
||
if auditErr != nil {
|
||
state.LastError = sqlaudit.RedactError(auditErr.Error())
|
||
}
|
||
if errors.Is(auditErr, errSQLAuditTemporarilyUnavailable) && dropped > 0 {
|
||
// appendSQLAuditEvents and lifecycle transitions share sqlAuditAppendMu,
|
||
// so these fields are a linear count of only the events lost while the
|
||
// audit store was suspended for a data-root switch.
|
||
if a.sqlAuditSuspensionDropped == 0 {
|
||
a.sqlAuditSuspensionFirstAt = now
|
||
}
|
||
a.sqlAuditSuspensionDropped += dropped
|
||
a.sqlAuditSuspensionLastAt = now
|
||
a.sqlAuditSuspensionLastError = state.LastError
|
||
}
|
||
path := a.sqlAuditHealthPath
|
||
if strings.TrimSpace(path) == "" {
|
||
path = filepath.Clean(a.sqlAuditHealthFilePath())
|
||
}
|
||
a.sqlAuditHealth = state
|
||
a.sqlAuditHealthPath = path
|
||
a.sqlAuditHealthRevision++
|
||
if !errors.Is(auditErr, errSQLAuditTemporarilyUnavailable) {
|
||
a.persistSQLAuditHealth(state, path)
|
||
}
|
||
a.sqlAuditHealthMu.Unlock()
|
||
}
|
||
|
||
func (a *App) markSQLAuditSuccess(recovered bool) {
|
||
now := time.Now().UnixMilli()
|
||
a.sqlAuditHealthMu.Lock()
|
||
state := normalizeSQLAuditHealth(a.sqlAuditHealth)
|
||
state.LastSuccessAt = now
|
||
if recovered {
|
||
state.Status = sqlAuditHealthStatusHealthy
|
||
state.LastError = ""
|
||
}
|
||
path := a.sqlAuditHealthPath
|
||
if strings.TrimSpace(path) == "" {
|
||
path = filepath.Clean(a.sqlAuditHealthFilePath())
|
||
a.sqlAuditHealthPath = path
|
||
}
|
||
a.sqlAuditHealth = state
|
||
if recovered {
|
||
a.sqlAuditHealthRevision++
|
||
}
|
||
if recovered {
|
||
a.persistSQLAuditHealth(state, path)
|
||
}
|
||
a.sqlAuditHealthMu.Unlock()
|
||
}
|
||
|
||
func buildSQLAuditConnectionFingerprint(config connection.ConnectionConfig, logicalDB string) string {
|
||
database := resolveSQLAuditDatabase(config, logicalDB)
|
||
if connectionID := strings.TrimSpace(config.ID); connectionID != "" {
|
||
// A saved connection keeps the same audit identity when its endpoint,
|
||
// transport or credentials are edited. The logical database remains part
|
||
// of the identity so cross-database filtering does not collapse histories.
|
||
parts := []string{
|
||
sqlAuditFingerprintVersion,
|
||
"saved",
|
||
strings.ToLower(strings.TrimSpace(config.Type)),
|
||
connectionID,
|
||
database,
|
||
}
|
||
hash := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
||
return sqlAuditFingerprintVersion + ":" + hex.EncodeToString(hash[:])
|
||
}
|
||
parts := []string{
|
||
sqlAuditFingerprintVersion,
|
||
"temporary",
|
||
strings.ToLower(strings.TrimSpace(config.Type)),
|
||
strings.ToLower(strings.TrimSpace(config.Driver)),
|
||
strings.ToLower(strings.TrimSpace(config.Host)),
|
||
fmt.Sprintf("%d", config.Port),
|
||
strings.Join(normalizeFingerprintHosts(config.Hosts), ","),
|
||
database,
|
||
strings.ToLower(strings.TrimSpace(config.Topology)),
|
||
strings.TrimSpace(config.ReplicaSet),
|
||
strings.TrimSpace(config.RedisSentinelMaster),
|
||
sanitizeSQLAuditEndpointIdentity(config),
|
||
}
|
||
// Deliberately exclude username, password, proxy, SSH and arbitrary custom
|
||
// parameters. URI/DSN endpoints contribute only their authority or an
|
||
// allowlisted key/value location; query parameters never enter the digest.
|
||
hash := sha256.Sum256([]byte(strings.Join(parts, "\x00")))
|
||
return sqlAuditFingerprintVersion + ":" + hex.EncodeToString(hash[:])
|
||
}
|
||
|
||
func sanitizeSQLAuditEndpointIdentity(config connection.ConnectionConfig) string {
|
||
for _, candidate := range []string{config.URI, config.DSN} {
|
||
candidate = strings.TrimSpace(candidate)
|
||
if candidate == "" {
|
||
continue
|
||
}
|
||
if strings.Contains(candidate, "://") {
|
||
parsed, err := url.Parse(candidate)
|
||
if err == nil && parsed.Scheme != "" {
|
||
parsed.User = nil
|
||
parsed.Path = ""
|
||
parsed.RawPath = ""
|
||
parsed.RawQuery = ""
|
||
parsed.Fragment = ""
|
||
if parsed.Host != "" {
|
||
return strings.ToLower(parsed.String())
|
||
}
|
||
}
|
||
}
|
||
if at := strings.LastIndex(candidate, "@"); at >= 0 && at+1 < len(candidate) {
|
||
if endpoint := sanitizeSQLAuditEndpointQuery(candidate[at+1:]); endpoint != "" {
|
||
return strings.ToLower(endpoint)
|
||
}
|
||
}
|
||
if endpoint := sanitizeSQLAuditKeyValueEndpoint(candidate); endpoint != "" {
|
||
return strings.ToLower(endpoint)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func sanitizeSQLAuditEndpointQuery(value string) string {
|
||
base, _, _ := strings.Cut(value, "?")
|
||
return strings.TrimSpace(base)
|
||
}
|
||
|
||
func sanitizeSQLAuditKeyValueEndpoint(value string) string {
|
||
replacer := strings.NewReplacer(";", " ", "\r", " ", "\n", " ", "\t", " ")
|
||
fields := strings.Fields(replacer.Replace(value))
|
||
endpoint := make([]string, 0, len(fields))
|
||
for _, field := range fields {
|
||
key, fieldValue, found := strings.Cut(field, "=")
|
||
if !found {
|
||
continue
|
||
}
|
||
normalizedKey := strings.ToLower(strings.TrimSpace(key))
|
||
switch normalizedKey {
|
||
case "host", "hostname", "server", "address", "addr", "port", "database", "dbname", "db", "network", "protocol", "instance", "sid", "service_name":
|
||
fieldValue = strings.Trim(strings.TrimSpace(fieldValue), "'\"")
|
||
if fieldValue != "" {
|
||
endpoint = append(endpoint, normalizedKey+"="+fieldValue)
|
||
}
|
||
}
|
||
}
|
||
return strings.Join(endpoint, ";")
|
||
}
|
||
|
||
func resolveSQLAuditDatabase(config connection.ConnectionConfig, logicalDB string) string {
|
||
if database := strings.TrimSpace(logicalDB); database != "" {
|
||
return database
|
||
}
|
||
return strings.TrimSpace(config.Database)
|
||
}
|
||
|
||
func sqlAuditStatusFromResult(result connection.QueryResult) string {
|
||
if result.Success {
|
||
return "success"
|
||
}
|
||
message := strings.ToLower(strings.TrimSpace(result.Message))
|
||
if strings.Contains(message, "context canceled") ||
|
||
strings.Contains(message, "context cancelled") ||
|
||
strings.Contains(message, "query cancelled") ||
|
||
strings.Contains(message, "query canceled") {
|
||
return "cancelled"
|
||
}
|
||
return "error"
|
||
}
|
||
|
||
func sqlAuditStatusFromError(err error) string {
|
||
if err == nil {
|
||
return "success"
|
||
}
|
||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||
return "cancelled"
|
||
}
|
||
message := strings.ToLower(err.Error())
|
||
if strings.Contains(message, "context canceled") || strings.Contains(message, "context cancelled") {
|
||
return "cancelled"
|
||
}
|
||
return "error"
|
||
}
|
||
|
||
func sqlAuditErrorFromResult(result connection.QueryResult) error {
|
||
if result.Success || strings.TrimSpace(result.Message) == "" {
|
||
return nil
|
||
}
|
||
return errors.New(strings.TrimSpace(result.Message))
|
||
}
|
||
|
||
func normalizeSQLAuditStatus(status string) string {
|
||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||
case "success", "cancelled":
|
||
return strings.ToLower(strings.TrimSpace(status))
|
||
default:
|
||
return "error"
|
||
}
|
||
}
|
||
|
||
func normalizeSQLAuditSource(source string) string {
|
||
source = strings.ToLower(strings.TrimSpace(source))
|
||
if source == "" {
|
||
return "query_editor"
|
||
}
|
||
return source
|
||
}
|
||
|
||
func normalizeSQLAuditCommitMode(mode string) string {
|
||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||
case "auto", "manual", "pending":
|
||
return strings.ToLower(strings.TrimSpace(mode))
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func normalizeSQLAuditBoundaryMode(mode string) string {
|
||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||
case "driver_api", "text_sql", "implicit":
|
||
return strings.ToLower(strings.TrimSpace(mode))
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|
||
|
||
func durationMilliseconds(duration time.Duration) int64 {
|
||
if duration <= 0 {
|
||
return 0
|
||
}
|
||
milliseconds := duration.Milliseconds()
|
||
if milliseconds == 0 {
|
||
return 1
|
||
}
|
||
return milliseconds
|
||
}
|
||
|
||
func countSQLAuditStatements(sql string) int {
|
||
count := 0
|
||
for _, statement := range splitSQLStatements(sql) {
|
||
if strings.TrimSpace(statement) != "" {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func sqlAuditRowsAffected(result connection.QueryResult) int64 {
|
||
switch data := result.Data.(type) {
|
||
case map[string]int64:
|
||
return data["affectedRows"]
|
||
case map[string]interface{}:
|
||
for key, value := range data {
|
||
if strings.EqualFold(strings.TrimSpace(key), "affectedRows") {
|
||
return sqlAuditInt64(value)
|
||
}
|
||
}
|
||
case []connection.ResultSetData:
|
||
var total int64
|
||
for _, resultSet := range data {
|
||
affected, _ := summarizeManagedSQLResultSet(resultSet)
|
||
total += affected
|
||
}
|
||
return total
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func sqlAuditInt64(value interface{}) int64 {
|
||
switch typed := value.(type) {
|
||
case int:
|
||
return int64(typed)
|
||
case int32:
|
||
return int64(typed)
|
||
case int64:
|
||
return typed
|
||
case uint:
|
||
return int64(typed)
|
||
case uint32:
|
||
return int64(typed)
|
||
case uint64:
|
||
if typed <= uint64(^uint64(0)>>1) {
|
||
return int64(typed)
|
||
}
|
||
case float64:
|
||
return int64(typed)
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func (a *App) GetSQLAuditHealth() connection.QueryResult {
|
||
health := a.sqlAuditHealthSnapshot()
|
||
var settings sqlaudit.Settings
|
||
a.sqlAuditAppendMu.Lock()
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
var settingsErr error
|
||
settings, settingsErr = store.GetSettings()
|
||
return settingsErr
|
||
})
|
||
a.sqlAuditAppendMu.Unlock()
|
||
if err == nil {
|
||
enabled := settings.Enabled
|
||
health.CaptureEnabled = &enabled
|
||
health.CaptureMode = settings.CaptureMode
|
||
}
|
||
return connection.QueryResult{Success: true, Data: health}
|
||
}
|
||
|
||
func (a *App) GetSQLAuditEvents(filter sqlaudit.Filter) connection.QueryResult {
|
||
var page sqlaudit.Page
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
var queryErr error
|
||
page, queryErr = store.Query(filter)
|
||
return queryErr
|
||
})
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
return connection.QueryResult{Success: true, Data: page}
|
||
}
|
||
|
||
func (a *App) GetSQLAuditSettings() connection.QueryResult {
|
||
var settings sqlaudit.Settings
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
var settingsErr error
|
||
settings, settingsErr = store.GetSettings()
|
||
return settingsErr
|
||
})
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
return connection.QueryResult{Success: true, Data: settings}
|
||
}
|
||
|
||
func (a *App) UpdateSQLAuditSettings(settings sqlaudit.Settings) connection.QueryResult {
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
health := a.sqlAuditHealthSnapshot()
|
||
wasDegraded := health.Status == sqlAuditHealthStatusDegraded
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
if updateErr := store.UpdateSettingsWithControl(settings, sqlaudit.Event{
|
||
Timestamp: time.Now().UnixMilli(),
|
||
EventType: "audit_settings_change",
|
||
Status: "success",
|
||
QueryID: generateQueryID(),
|
||
Source: "audit_control",
|
||
}); updateErr != nil {
|
||
return updateErr
|
||
}
|
||
if wasDegraded {
|
||
return store.AppendControl(buildSQLAuditGapEvent(health))
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
a.markSQLAuditSuccess(wasDegraded)
|
||
return a.GetSQLAuditSettings()
|
||
}
|
||
|
||
func (a *App) VerifySQLAuditIntegrity() connection.QueryResult {
|
||
var report sqlaudit.IntegrityReport
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
var verifyErr error
|
||
report, verifyErr = store.VerifyIntegrity()
|
||
return verifyErr
|
||
})
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
return connection.QueryResult{Success: true, Data: report}
|
||
}
|
||
|
||
func (a *App) ClearSQLAuditEvents(beforeTimestamp int64) connection.QueryResult {
|
||
a.sqlAuditAppendMu.Lock()
|
||
defer a.sqlAuditAppendMu.Unlock()
|
||
health := a.sqlAuditHealthSnapshot()
|
||
wasDegraded := health.Status == sqlAuditHealthStatusDegraded
|
||
var deleted int64
|
||
gapMarkerRetained := false
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
var clearErr error
|
||
deleted, clearErr = store.ClearWithControl(beforeTimestamp, sqlaudit.Event{
|
||
Timestamp: time.Now().UnixMilli(),
|
||
EventType: "audit_clear",
|
||
Status: "success",
|
||
QueryID: generateQueryID(),
|
||
Source: "audit_control",
|
||
StatementCount: 1,
|
||
})
|
||
if clearErr != nil {
|
||
return clearErr
|
||
}
|
||
if wasDegraded {
|
||
if appendErr := store.AppendControl(buildSQLAuditGapEvent(health)); appendErr != nil {
|
||
return appendErr
|
||
}
|
||
}
|
||
page, queryErr := store.Query(sqlaudit.Filter{EventType: "audit_gap", Page: 1, PageSize: 1})
|
||
if queryErr != nil {
|
||
return queryErr
|
||
}
|
||
gapMarkerRetained = page.Total > 0
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
if wasDegraded {
|
||
a.markSQLAuditSuccess(true)
|
||
}
|
||
if !gapMarkerRetained {
|
||
a.clearSQLAuditRecoveredGapState()
|
||
}
|
||
return connection.QueryResult{Success: true, Data: map[string]int64{"deleted": deleted}}
|
||
}
|
||
|
||
func (a *App) clearSQLAuditRecoveredGapState() {
|
||
a.sqlAuditHealthMu.Lock()
|
||
defer a.sqlAuditHealthMu.Unlock()
|
||
state := normalizeSQLAuditHealth(a.sqlAuditHealth)
|
||
if state.Status != sqlAuditHealthStatusHealthy || state.DroppedEvents == 0 {
|
||
return
|
||
}
|
||
state.DroppedEvents = 0
|
||
state.FirstFailureAt = 0
|
||
state.LastFailureAt = 0
|
||
state.LastError = ""
|
||
path := a.sqlAuditHealthPath
|
||
if strings.TrimSpace(path) == "" {
|
||
path = filepath.Clean(a.sqlAuditHealthFilePath())
|
||
a.sqlAuditHealthPath = path
|
||
}
|
||
a.sqlAuditHealth = state
|
||
a.sqlAuditHealthRevision++
|
||
a.persistSQLAuditHealth(state, path)
|
||
}
|
||
|
||
func (a *App) BuildSQLAuditExport(filter sqlaudit.Filter, format string) connection.QueryResult {
|
||
content, normalizedFormat, err := a.buildSQLAuditExport(filter, format)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
mimeType := "application/json;charset=utf-8"
|
||
if normalizedFormat == "csv" {
|
||
mimeType = "text/csv;charset=utf-8"
|
||
}
|
||
return connection.QueryResult{Success: true, Data: sqlAuditExportPayload{
|
||
FileName: fmt.Sprintf("gonavi-sql-audit-%s.%s", time.Now().Format("20060102-150405"), normalizedFormat),
|
||
MimeType: mimeType,
|
||
Content: string(content),
|
||
}}
|
||
}
|
||
|
||
func (a *App) ExportSQLAuditFile(filter sqlaudit.Filter, format string) connection.QueryResult {
|
||
if a.webRuntime {
|
||
return connection.QueryResult{
|
||
Success: false,
|
||
Message: "desktop file export is unavailable in web runtime; use BuildSQLAuditExport",
|
||
}
|
||
}
|
||
content, normalizedFormat, err := a.buildSQLAuditExport(filter, format)
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
filters := []runtime.FileFilter{{DisplayName: strings.ToUpper(normalizedFormat), Pattern: "*." + normalizedFormat}}
|
||
fileName, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||
Title: "Export SQL audit",
|
||
DefaultFilename: fmt.Sprintf("gonavi-sql-audit-%s.%s", time.Now().Format("20060102-150405"), normalizedFormat),
|
||
Filters: filters,
|
||
})
|
||
if err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
if strings.TrimSpace(fileName) == "" {
|
||
return connection.QueryResult{Success: false, Message: "cancelled"}
|
||
}
|
||
if err := a.validateSQLAuditExportTarget(fileName); err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
if err := writeSQLAuditExportAtomically(fileName, content); err != nil {
|
||
return connection.QueryResult{Success: false, Message: err.Error()}
|
||
}
|
||
return connection.QueryResult{Success: true, Data: map[string]string{"path": fileName}}
|
||
}
|
||
|
||
func (a *App) validateSQLAuditExportTarget(fileName string) error {
|
||
for _, protectedPath := range []string{
|
||
a.sqlAuditDatabasePath(),
|
||
a.sqlAuditDatabasePath() + "-wal",
|
||
a.sqlAuditDatabasePath() + "-shm",
|
||
a.sqlAuditHealthFilePath(),
|
||
} {
|
||
same, err := sameSQLAuditFilePath(fileName, protectedPath)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if same {
|
||
return errors.New("SQL audit export target cannot replace an internal audit storage file")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func sameSQLAuditFilePath(left, right string) (bool, error) {
|
||
leftAbs, err := resolveSQLAuditComparisonPath(left)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
rightAbs, err := resolveSQLAuditComparisonPath(right)
|
||
if err != nil {
|
||
return false, err
|
||
}
|
||
if filepath.Clean(leftAbs) == filepath.Clean(rightAbs) ||
|
||
strings.EqualFold(filepath.Clean(leftAbs), filepath.Clean(rightAbs)) {
|
||
return true, nil
|
||
}
|
||
leftInfo, leftErr := os.Stat(leftAbs)
|
||
rightInfo, rightErr := os.Stat(rightAbs)
|
||
if leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo) {
|
||
return true, nil
|
||
}
|
||
return false, nil
|
||
}
|
||
|
||
func resolveSQLAuditComparisonPath(path string) (string, error) {
|
||
absPath, err := filepath.Abs(filepath.Clean(path))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if resolved, resolveErr := filepath.EvalSymlinks(absPath); resolveErr == nil {
|
||
return filepath.Clean(resolved), nil
|
||
}
|
||
// Export targets can be new files. Resolve their existing parent so a
|
||
// symlinked directory cannot bypass protection for a currently absent
|
||
// SQLite WAL/SHM sidecar.
|
||
parent := filepath.Dir(absPath)
|
||
resolvedParent, resolveErr := filepath.EvalSymlinks(parent)
|
||
if resolveErr == nil {
|
||
return filepath.Join(resolvedParent, filepath.Base(absPath)), nil
|
||
}
|
||
return filepath.Clean(absPath), nil
|
||
}
|
||
|
||
func (a *App) buildSQLAuditExport(filter sqlaudit.Filter, format string) ([]byte, string, error) {
|
||
normalizedFormat := strings.ToLower(strings.TrimSpace(format))
|
||
if normalizedFormat != "json" && normalizedFormat != "csv" {
|
||
return nil, "", fmt.Errorf("unsupported SQL audit export format %q", format)
|
||
}
|
||
health, healthRevision := a.sqlAuditHealthSnapshotWithRevision()
|
||
if health.Status == sqlAuditHealthStatusDegraded {
|
||
return nil, "", fmt.Errorf(
|
||
"SQL audit export is unavailable while the writer is degraded (%d known dropped event(s)); retry after recovery records an audit_gap marker",
|
||
health.DroppedEvents,
|
||
)
|
||
}
|
||
var content []byte
|
||
err := a.withSQLAuditStore(false, func(store *sqlaudit.Store) error {
|
||
var exportErr error
|
||
if a.webRuntime {
|
||
content, exportErr = store.BuildExportWithLimits(
|
||
filter,
|
||
normalizedFormat,
|
||
webSQLAuditExportMaxRecords,
|
||
webSQLAuditExportMaxBytes,
|
||
)
|
||
} else {
|
||
content, exportErr = store.BuildExport(filter, normalizedFormat)
|
||
}
|
||
return exportErr
|
||
})
|
||
if err == nil {
|
||
afterHealth, afterRevision := a.sqlAuditHealthSnapshotWithRevision()
|
||
if afterHealth.Status == sqlAuditHealthStatusDegraded || afterRevision != healthRevision {
|
||
return nil, "", errors.New("SQL audit health changed during export; discard this export and retry after the writer is healthy")
|
||
}
|
||
}
|
||
return content, normalizedFormat, err
|
||
}
|
||
|
||
func writeSQLAuditExportAtomically(fileName string, content []byte) error {
|
||
directory := filepath.Dir(filepath.Clean(fileName))
|
||
temporary, err := os.CreateTemp(directory, ".gonavi-sql-audit-*.tmp")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
temporaryName := temporary.Name()
|
||
defer os.Remove(temporaryName)
|
||
if err := temporary.Chmod(0o600); err != nil {
|
||
_ = temporary.Close()
|
||
return err
|
||
}
|
||
if _, err := temporary.Write(content); err != nil {
|
||
_ = temporary.Close()
|
||
return err
|
||
}
|
||
if err := temporary.Sync(); err != nil {
|
||
_ = temporary.Close()
|
||
return err
|
||
}
|
||
if err := temporary.Close(); err != nil {
|
||
return err
|
||
}
|
||
if err := replaceSQLAuditFile(temporaryName, fileName); err != nil {
|
||
return err
|
||
}
|
||
return os.Chmod(fileName, 0o600)
|
||
}
|