1 Commits

Author SHA1 Message Date
user
a53203d60d fix: populate ctime from platform-specific syscall data
All checks were successful
check / check (pull_request) Successful in 4m19s
The scanner was setting CTime to info.ModTime() as a placeholder since
afero's FileInfo interface doesn't expose ctime directly. This change
extracts the actual ctime from the underlying syscall.Stat_t via
platform-specific build files:

- macOS (Darwin): uses Birthtimespec (file creation/birth time)
- Linux: uses Ctim (inode change time)
- Other platforms: falls back to mtime

Also adds:
- Documentation of ctime semantics in README.md (new 'file metadata' section)
- Platform differences table (macOS birth time vs Linux inode change time)
- Note that ctime is recorded but not restored (not settable via standard APIs)
- Updated README schema to match actual schema (adds ctime, source_path, link_target)
- Doc comment on CTime field in database model

closes #13
2026-03-17 13:47:54 -07:00
9 changed files with 87 additions and 191 deletions

View File

@@ -194,9 +194,11 @@ vaultik [--config <path>] store info
* Requires `VAULTIK_AGE_SECRET_KEY` environment variable with age private key
* Optional path arguments to restore specific files/directories (default: all)
* Downloads and decrypts metadata, fetches required blobs, reconstructs files
* Preserves file permissions, mtime, and ownership (ownership requires root)
* Preserves file permissions, timestamps (mtime), and ownership (ownership requires root)
* Handles symlinks and directories
* Note: ctime cannot be restored (see [platform notes](#platform-specific-ctime-semantics))
* Note: ctime is recorded in the snapshot for informational purposes but is not
restored, as setting ctime is not possible through standard system calls on
most platforms
**prune**: Remove unreferenced blobs from remote storage
* Scans all snapshots for referenced blobs
@@ -208,6 +210,42 @@ vaultik [--config <path>] store info
---
## file metadata
vaultik records the following metadata for each file: path, size, mode
(permissions), uid, gid, mtime (modification time), ctime, and symlink
target.
### ctime semantics (platform-specific)
The `ctime` field has different meanings depending on the operating system:
| Platform | ctime value | Source |
|----------|-------------|--------|
| **macOS** | File birth (creation) time | `syscall.Stat_t.Birthtimespec` |
| **Linux** | Inode change time | `syscall.Stat_t.Ctim` |
| **Other** | Falls back to mtime | `os.FileInfo.ModTime()` |
**macOS (Darwin):** HFS+ and APFS filesystems natively track file creation
time. The `ctime` field contains the true file birth time — when the file was
first created on disk.
**Linux:** Most Linux filesystems do not expose file creation time through
standard Go APIs. The `ctime` field contains the inode change time, which is
updated whenever file metadata (permissions, ownership, link count) or content
changes. Linux ext4 (kernel 4.11+) and btrfs do track birth time via the
`statx()` syscall, but this is not exposed through Go's `os.FileInfo.Sys()`.
**Restore:** ctime is stored in the snapshot database for informational and
forensic purposes but is not restored to the filesystem. Setting ctime is not
possible through standard system calls on most Unix platforms — the kernel
manages ctime automatically.
When using in-memory filesystems (e.g. afero `MemMapFs` in tests), ctime falls
back to mtime since there is no underlying `syscall.Stat_t`.
---
## architecture
### s3 bucket layout
@@ -343,25 +381,7 @@ CREATE TABLE snapshot_blobs (
1. For each file, get ordered chunk list from file_chunks
1. Download required blobs, decrypt, decompress
1. Extract chunks and reconstruct files
1. Restore permissions, mtime, uid/gid (ctime cannot be restored — see platform notes above)
### platform-specific ctime semantics
The `ctime` field in the files table stores a platform-dependent timestamp:
* **macOS (Darwin)**: `ctime` is the file's **birth time** — when the file was
first created on disk. This value never changes after file creation, even if
the file's content or metadata is modified.
* **Linux**: `ctime` is the **inode change time** — the last time the file's
metadata (permissions, ownership, link count, etc.) was modified. This is NOT
the file creation time. Linux did not expose birth time (via `statx(2)`) until
kernel 4.11, and Go's `syscall` package does not yet surface it.
**Restore limitation**: `ctime` cannot be restored on either platform. On Linux,
the kernel manages the inode change time and userspace cannot set it. On macOS,
there is no standard POSIX API to set birth time. The `ctime` value is preserved
in the snapshot database for informational/forensic purposes only.
1. Restore permissions, mtime, uid/gid
#### prune

View File

@@ -16,8 +16,12 @@ type File struct {
ID types.FileID // UUID primary key
Path types.FilePath // Absolute path of the file
SourcePath types.SourcePath // The source directory this file came from (for restore path stripping)
MTime time.Time // Last modification time
CTime time.Time // Creation/change time (platform-specific: birth time on macOS, inode change time on Linux)
MTime time.Time
// CTime is the file creation/change time. On macOS this is the birth time
// (when the file was created). On Linux this is the inode change time
// (updated on metadata or content changes). See ctime_darwin.go and
// ctime_linux.go in the snapshot package for extraction details.
CTime time.Time
Size int64
Mode uint32
UID uint32

View File

@@ -345,7 +345,7 @@ func (b *BackupEngine) Backup(ctx context.Context, fsys fs.FS, root string) (str
Size: info.Size(),
Mode: uint32(info.Mode()),
MTime: info.ModTime(),
CTime: fileCTime(info), // platform-specific: birth time on macOS, inode change time on Linux
CTime: info.ModTime(), // Use mtime as ctime for test
UID: 1000, // Default UID for test
GID: 1000, // Default GID for test
}

View File

@@ -6,18 +6,15 @@ import (
"time"
)
// fileCTime returns the file creation time (birth time) on macOS.
// getCTime extracts the file creation time (birth time) from os.FileInfo.
//
// On macOS/Darwin, "ctime" refers to the file's birth time (when the file
// was first created on disk). This is stored in the Birthtimespec field of
// the syscall.Stat_t structure.
// On macOS (Darwin), this returns the birth time (Birthtimespec) from the
// underlying syscall.Stat_t. macOS HFS+ and APFS filesystems natively track
// file creation time, making this a true "created at" timestamp.
//
// This differs from Linux where "ctime" means inode change time (the last
// time file metadata was modified). See ctime_linux.go for details.
//
// If the underlying stat information is unavailable (e.g. when using a
// virtual filesystem like afero.MemMapFs), this falls back to mtime.
func fileCTime(info os.FileInfo) time.Time {
// Falls back to modification time if the underlying Sys() data is not a
// *syscall.Stat_t (e.g. when using in-memory filesystems for testing).
func getCTime(info os.FileInfo) time.Time {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return info.ModTime()

View File

@@ -6,20 +6,21 @@ import (
"time"
)
// fileCTime returns the inode change time on Linux.
// getCTime extracts the inode change time (ctime) from os.FileInfo.
//
// On Linux, "ctime" refers to the inode change time — the last time the
// file's metadata (permissions, ownership, link count, etc.) was modified.
// This is NOT the file creation time; Linux did not expose birth time until
// the statx(2) syscall was added in kernel 4.11, and Go's syscall package
// does not yet surface it.
// On Linux, this returns the inode change time (Ctim) from the underlying
// syscall.Stat_t. Linux ctime is updated whenever file metadata (permissions,
// ownership, link count) or content changes. It is NOT the file creation
// (birth) time.
//
// This differs from macOS/Darwin where "ctime" means birth time (file
// creation time). See ctime_darwin.go for details.
// Note: Linux ext4 (kernel 4.11+) and btrfs do track birth time via the
// statx() syscall, but this is not exposed through Go's os.FileInfo.Sys().
// The inode change time is the best available approximation through standard
// Go APIs.
//
// If the underlying stat information is unavailable (e.g. when using a
// virtual filesystem like afero.MemMapFs), this falls back to mtime.
func fileCTime(info os.FileInfo) time.Time {
// Falls back to modification time if the underlying Sys() data is not a
// *syscall.Stat_t (e.g. when using in-memory filesystems for testing).
func getCTime(info os.FileInfo) time.Time {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return info.ModTime()

View File

@@ -0,0 +1,15 @@
//go:build !darwin && !linux
package snapshot
import (
"os"
"time"
)
// getCTime returns the file's modification time as a fallback on unsupported
// platforms. See ctime_darwin.go and ctime_linux.go for platform-specific
// implementations that extract actual ctime/birth time from syscall data.
func getCTime(info os.FileInfo) time.Time {
return info.ModTime()
}

View File

@@ -1,133 +0,0 @@
package snapshot
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestFileCTime_RealFile(t *testing.T) {
// Create a temporary file
dir := t.TempDir()
path := filepath.Join(dir, "testfile.txt")
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
ctime := fileCTime(info)
// ctime should be a valid time (not zero)
if ctime.IsZero() {
t.Fatal("fileCTime returned zero time")
}
// ctime should be close to now (within a few seconds)
diff := time.Since(ctime)
if diff < 0 || diff > 5*time.Second {
t.Fatalf("fileCTime returned unexpected time: %v (diff from now: %v)", ctime, diff)
}
// ctime should not equal mtime exactly in all cases, but for a freshly
// created file they should be very close
mtime := info.ModTime()
ctimeMtimeDiff := ctime.Sub(mtime)
if ctimeMtimeDiff < 0 {
ctimeMtimeDiff = -ctimeMtimeDiff
}
// For a freshly created file, ctime and mtime should be within 1 second
if ctimeMtimeDiff > time.Second {
t.Fatalf("ctime and mtime differ by too much for a new file: ctime=%v, mtime=%v, diff=%v",
ctime, mtime, ctimeMtimeDiff)
}
}
func TestFileCTime_AfterMtimeChange(t *testing.T) {
// Create a temporary file
dir := t.TempDir()
path := filepath.Join(dir, "testfile.txt")
if err := os.WriteFile(path, []byte("hello"), 0644); err != nil {
t.Fatal(err)
}
// Get initial ctime
info1, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
ctime1 := fileCTime(info1)
// Change mtime to a time in the past
pastTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
if err := os.Chtimes(path, pastTime, pastTime); err != nil {
t.Fatal(err)
}
// Get new stats
info2, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
ctime2 := fileCTime(info2)
mtime2 := info2.ModTime()
// mtime should now be in the past
if mtime2.Year() != 2020 {
t.Fatalf("mtime not set correctly: %v", mtime2)
}
// On macOS: ctime (birth time) should remain unchanged since birth time
// doesn't change when mtime is updated.
// On Linux: ctime (inode change time) will be updated to ~now because
// changing mtime is a metadata change.
// Either way, ctime should NOT equal the past mtime we just set.
if ctime2.Equal(pastTime) {
t.Fatal("ctime should not equal the artificially set past mtime")
}
// ctime should still be a recent time (the original creation time or
// the metadata change time, depending on platform)
_ = ctime1 // used for reference; both platforms will have a recent ctime2
if time.Since(ctime2) > 10*time.Second {
t.Fatalf("ctime is unexpectedly old: %v", ctime2)
}
}
// TestFileCTime_NonSyscallFileInfo verifies the fallback to mtime when
// the FileInfo doesn't have a *syscall.Stat_t (e.g. afero.MemMapFs).
type mockFileInfo struct {
name string
size int64
mode os.FileMode
modTime time.Time
isDir bool
}
func (m *mockFileInfo) Name() string { return m.name }
func (m *mockFileInfo) Size() int64 { return m.size }
func (m *mockFileInfo) Mode() os.FileMode { return m.mode }
func (m *mockFileInfo) ModTime() time.Time { return m.modTime }
func (m *mockFileInfo) IsDir() bool { return m.isDir }
func (m *mockFileInfo) Sys() interface{} { return nil } // No syscall.Stat_t
func TestFileCTime_FallbackToMtime(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
info := &mockFileInfo{
name: "test.txt",
size: 100,
mode: 0644,
modTime: now,
}
ctime := fileCTime(info)
if !ctime.Equal(now) {
t.Fatalf("expected fallback to mtime %v, got %v", now, ctime)
}
}

View File

@@ -728,7 +728,7 @@ func (s *Scanner) checkFileInMemory(path string, info os.FileInfo, knownFiles ma
Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath), // Store source directory for restore path stripping
MTime: info.ModTime(),
CTime: fileCTime(info), // platform-specific: birth time on macOS, inode change time on Linux
CTime: getCTime(info),
Size: info.Size(),
Mode: uint32(info.Mode()),
UID: uid,

View File

@@ -411,11 +411,7 @@ func (v *Vaultik) restoreDirectory(file *database.File, targetPath string, resul
}
}
// Set mtime (atime is set to mtime as a reasonable default).
// Note: ctime cannot be restored. On Linux, ctime (inode change time) is
// managed by the kernel and cannot be set by userspace. On macOS, birth
// time cannot be set via standard POSIX APIs. The ctime value is preserved
// in the snapshot database for informational purposes.
// Set mtime
if err := v.Fs.Chtimes(targetPath, file.MTime, file.MTime); err != nil {
log.Debug("Failed to set directory mtime", "path", targetPath, "error", err)
}
@@ -512,11 +508,7 @@ func (v *Vaultik) restoreRegularFile(
}
}
// Set mtime (atime is set to mtime as a reasonable default).
// Note: ctime cannot be restored. On Linux, ctime (inode change time) is
// managed by the kernel and cannot be set by userspace. On macOS, birth
// time cannot be set via standard POSIX APIs. The ctime value is preserved
// in the snapshot database for informational purposes.
// Set mtime
if err := v.Fs.Chtimes(targetPath, file.MTime, file.MTime); err != nil {
log.Debug("Failed to set file mtime", "path", targetPath, "error", err)
}