@@ -926,6 +926,187 @@ func TestSave_CreatesFileWithSecurePermissions(t *testing.T) {
926926 }
927927}
928928
929+ func TestSave_TightensWeakPermissions (t * testing.T ) {
930+ if runtime .GOOS == "windows" {
931+ t .Skip ("Unix file permissions not supported on Windows" )
932+ }
933+
934+ tmpDir := t .TempDir ()
935+ cfg := NewDefaultConfig ()
936+ cfg .HomeDir = tmpDir
937+
938+ // Pre-create config file with overly permissive mode
939+ path := cfg .ConfigFilePath ()
940+ if err := os .WriteFile (path , []byte ("" ), 0644 ); err != nil {
941+ t .Fatalf ("WriteFile: %v" , err )
942+ }
943+
944+ if err := cfg .Save (); err != nil {
945+ t .Fatalf ("Save() error = %v" , err )
946+ }
947+
948+ info , err := os .Stat (path )
949+ if err != nil {
950+ t .Fatalf ("Stat: %v" , err )
951+ }
952+ if info .Mode ().Perm ()& 0077 != 0 {
953+ t .Errorf ("Save should tighten perms: got %04o, want 0600" ,
954+ info .Mode ().Perm ())
955+ }
956+ }
957+
958+ func TestSave_FollowsSymlink (t * testing.T ) {
959+ if runtime .GOOS == "windows" {
960+ t .Skip ("symlinks require elevated privileges on Windows" )
961+ }
962+
963+ t .Run ("absolute target" , func (t * testing.T ) {
964+ tmpDir := t .TempDir ()
965+ targetDir := t .TempDir ()
966+ targetPath := filepath .Join (targetDir , "actual-config.toml" )
967+ linkPath := filepath .Join (tmpDir , "config.toml" )
968+
969+ if err := os .Symlink (targetPath , linkPath ); err != nil {
970+ t .Fatalf ("Symlink: %v" , err )
971+ }
972+
973+ cfg := NewDefaultConfig ()
974+ cfg .HomeDir = tmpDir
975+ cfg .Sync .RateLimitQPS = 77
976+
977+ if err := cfg .Save (); err != nil {
978+ t .Fatalf ("Save() error = %v" , err )
979+ }
980+
981+ linkTarget , err := os .Readlink (linkPath )
982+ if err != nil {
983+ t .Fatalf ("symlink was replaced: %v" , err )
984+ }
985+ if linkTarget != targetPath {
986+ t .Errorf ("symlink target = %q, want %q" , linkTarget , targetPath )
987+ }
988+
989+ loaded , err := Load (targetPath , "" )
990+ if err != nil {
991+ t .Fatalf ("Load target: %v" , err )
992+ }
993+ if loaded .Sync .RateLimitQPS != 77 {
994+ t .Errorf ("RateLimitQPS = %d, want 77" , loaded .Sync .RateLimitQPS )
995+ }
996+ })
997+
998+ t .Run ("relative target" , func (t * testing.T ) {
999+ tmpDir := t .TempDir ()
1000+ // Create subdir for the actual file
1001+ subDir := filepath .Join (tmpDir , "real" )
1002+ if err := os .Mkdir (subDir , 0700 ); err != nil {
1003+ t .Fatalf ("Mkdir: %v" , err )
1004+ }
1005+ targetPath := filepath .Join (subDir , "config.toml" )
1006+ linkPath := filepath .Join (tmpDir , "config.toml" )
1007+
1008+ // Relative symlink: config.toml → real/config.toml
1009+ if err := os .Symlink ("real/config.toml" , linkPath ); err != nil {
1010+ t .Fatalf ("Symlink: %v" , err )
1011+ }
1012+
1013+ cfg := NewDefaultConfig ()
1014+ cfg .HomeDir = tmpDir
1015+ cfg .Sync .RateLimitQPS = 88
1016+
1017+ if err := cfg .Save (); err != nil {
1018+ t .Fatalf ("Save() error = %v" , err )
1019+ }
1020+
1021+ // Symlink must still be intact
1022+ linkTarget , err := os .Readlink (linkPath )
1023+ if err != nil {
1024+ t .Fatalf ("symlink was replaced: %v" , err )
1025+ }
1026+ if linkTarget != "real/config.toml" {
1027+ t .Errorf ("symlink target = %q, want %q" ,
1028+ linkTarget , "real/config.toml" )
1029+ }
1030+
1031+ // Target file should contain the saved config
1032+ loaded , err := Load (targetPath , "" )
1033+ if err != nil {
1034+ t .Fatalf ("Load target: %v" , err )
1035+ }
1036+ if loaded .Sync .RateLimitQPS != 88 {
1037+ t .Errorf ("RateLimitQPS = %d, want 88" ,
1038+ loaded .Sync .RateLimitQPS )
1039+ }
1040+ })
1041+ }
1042+
1043+ func TestSave_FailurePreservesExisting (t * testing.T ) {
1044+ if runtime .GOOS == "windows" {
1045+ t .Skip ("cannot make directory unwritable on Windows" )
1046+ }
1047+
1048+ tmpDir := t .TempDir ()
1049+
1050+ // Save initial valid config
1051+ cfg := NewDefaultConfig ()
1052+ cfg .HomeDir = tmpDir
1053+ cfg .Sync .RateLimitQPS = 5
1054+ if err := cfg .Save (); err != nil {
1055+ t .Fatalf ("initial Save: %v" , err )
1056+ }
1057+
1058+ // Read back original content
1059+ originalBytes , err := os .ReadFile (cfg .ConfigFilePath ())
1060+ if err != nil {
1061+ t .Fatalf ("ReadFile: %v" , err )
1062+ }
1063+
1064+ // Make directory unwritable so CreateTemp fails
1065+ if err := os .Chmod (tmpDir , 0500 ); err != nil {
1066+ t .Fatalf ("Chmod: %v" , err )
1067+ }
1068+ t .Cleanup (func () { _ = os .Chmod (tmpDir , 0700 ) })
1069+
1070+ // Probe whether the restriction actually works
1071+ probe , probeErr := os .CreateTemp (tmpDir , "probe-*" )
1072+ if probeErr == nil {
1073+ probe .Close ()
1074+ os .Remove (probe .Name ())
1075+ t .Skip ("chmod 0500 did not restrict writes (running as root)" )
1076+ }
1077+
1078+ // Save should fail
1079+ cfg .Sync .RateLimitQPS = 99
1080+ if err := cfg .Save (); err == nil {
1081+ t .Fatal ("Save should fail when directory is unwritable" )
1082+ }
1083+
1084+ // Restore permissions to verify state
1085+ if err := os .Chmod (tmpDir , 0700 ); err != nil {
1086+ t .Fatalf ("Chmod restore: %v" , err )
1087+ }
1088+
1089+ // Original config should be intact
1090+ currentBytes , err := os .ReadFile (cfg .ConfigFilePath ())
1091+ if err != nil {
1092+ t .Fatalf ("ReadFile: %v" , err )
1093+ }
1094+ if string (currentBytes ) != string (originalBytes ) {
1095+ t .Error ("config file was corrupted after failed Save" )
1096+ }
1097+
1098+ // No temp files should be left behind
1099+ entries , err := os .ReadDir (tmpDir )
1100+ if err != nil {
1101+ t .Fatalf ("ReadDir: %v" , err )
1102+ }
1103+ for _ , e := range entries {
1104+ if strings .HasPrefix (e .Name (), ".config-" ) {
1105+ t .Errorf ("leftover temp file: %s" , e .Name ())
1106+ }
1107+ }
1108+ }
1109+
9291110func TestSave_OverwritesExisting (t * testing.T ) {
9301111 tmpDir := t .TempDir ()
9311112
0 commit comments