package command import ( "bufio" "fmt" "os" "path/filepath" "strings" "time" "github.com/qsfera/server/qsfera/pkg/register" "github.com/qsfera/server/pkg/config" "github.com/pkg/xattr" "github.com/spf13/cobra" "github.com/theckman/yacspin" "github.com/vmihailenco/msgpack/v5" ) // Define the names of the extended attributes we are working with. const ( parentIDAttrName = "user.oc.parentid" idAttrName = "user.oc.id" spaceIDAttrName = "user.oc.space.id" ownerIDAttrName = "user.oc.owner.id" ) var ( spinner *yacspin.Spinner restartRequired = false ) // EntryInfo holds information about a directory entry. type EntryInfo struct { Path string ModTime time.Time ParentID string } // PosixfsCommand is the entrypoint for the posixfs command. func PosixfsCommand(cfg *config.Config) *cobra.Command { posixCmd := &cobra.Command{ Use: "posixfs", Short: `cli tools to inspect and manipulate a posixfs storage.`, GroupID: CommandGroupStorage, } posixCmd.AddCommand(consistencyCmd(cfg)) return posixCmd } func init() { register.AddCommand(PosixfsCommand) } // consistencyCmd returns a command to check the consistency of the posixfs storage. func consistencyCmd(cfg *config.Config) *cobra.Command { consCmd := &cobra.Command{ Use: "consistency", Short: "check the consistency of the posixfs storage", RunE: func(cmd *cobra.Command, args []string) error { return checkPosixfsConsistency(cmd, cfg) }, } consCmd.Flags().StringP("root", "r", "", "Path to the root directory of the posixfs storage") _ = consCmd.MarkFlagRequired("root") return consCmd } // checkPosixfsConsistency checks the consistency of the posixfs storage. func checkPosixfsConsistency(cmd *cobra.Command, cfg *config.Config) error { rootPath, _ := cmd.Flags().GetString("root") indexesPath := filepath.Join(rootPath, "indexes") _, err := os.Stat(indexesPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("consistency check failed: '%s' is not a posixfs root", rootPath) } return fmt.Errorf("error accessing '%s': %w", indexesPath, err) } spinnerCfg := yacspin.Config{ Frequency: 100 * time.Millisecond, CharSet: yacspin.CharSets[11], StopCharacter: "✓", StopColors: []string{"fgGreen"}, StopFailCharacter: "✗", StopFailColors: []string{"fgRed"}, } spinner, err = yacspin.New(spinnerCfg) err = spinner.Start() if err != nil { return fmt.Errorf("error creating spinner: %w", err) } checkSpaces(filepath.Join(rootPath, "users")) spinner.Suffix(" Personal spaces check ") spinner.StopMessage("completed\n") spinner.Stop() checkSpaces(filepath.Join(rootPath, "projects")) spinner.Suffix(" Project spaces check ") spinner.StopMessage("completed") spinner.Stop() if restartRequired { fmt.Println("\n\n ⚠️ Please restart your qsfera instance to apply changes.") } return nil } func checkSpaces(basePath string) { dirEntries, err := os.ReadDir(basePath) if err != nil { spinner.Message(fmt.Sprintf("Error reading spaces directory '%s'\n", basePath)) spinner.StopFail() return } for _, entry := range dirEntries { if entry.IsDir() { fullPath := filepath.Join(basePath, entry.Name()) checkSpace(fullPath) } } } func checkSpace(spacePath string) { spinner.Suffix(fmt.Sprintf(" Checking space '%s'", spacePath)) info, err := os.Stat(spacePath) if err != nil { logFailure("Error accessing path '%s': %v", spacePath, err) return } if !info.IsDir() { logFailure("Error: The provided path '%s' is not a directory\n", spacePath) return } spaceID, err := xattr.Get(spacePath, spaceIDAttrName) if err != nil || len(spaceID) == 0 { logFailure("Error: The directory '%s' does not seem to be a space root, it's missing the '%s' attribute\n", spacePath, spaceIDAttrName) return } checkSpaceID(spacePath) } func checkSpaceID(spacePath string) { spinner.Message("checking space ID uniqueness") entries, uniqueIDs, oldestEntry, err := gatherAttributes(spacePath) if err != nil { logFailure("Failed to gather attributes: %v", err) return } if len(entries) == 0 { logSuccess("(empty space)") return } if len(uniqueIDs) > 1 { spinner.Pause() fmt.Println("\n ⚠ Multiple space IDs found:") for id := range uniqueIDs { fmt.Printf(" - %s\n", id) } fmt.Printf("\n ⏳ Oldest entry is '%s' (modified on %s).\n", filepath.Base(oldestEntry.Path), oldestEntry.ModTime.Format(time.RFC1123)) targetID := oldestEntry.ParentID fmt.Printf(" ✅ Proposed target Parent ID: %s\n", targetID) fmt.Printf("\n Do you want to unify all parent IDs to '%s'? This will modify %d entries, the directory, and the user index. (y/N): ", targetID, len(entries)) reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') input = strings.TrimSpace(strings.ToLower(input)) if input != "y" { spinner.Unpause() logFailure("Operation cancelled by user.") return } restartRequired = true obsoleteIDs := []string{} for id := range uniqueIDs { if id != targetID { obsoleteIDs = append(obsoleteIDs, id) } } fixSpaceID(spacePath, obsoleteIDs, targetID, entries) spinner.Unpause() } else { logSuccess("") } } func fixSpaceID(spacePath string, obsoleteIDs []string, targetID string, entries []EntryInfo) { // Set all parentid attributes to the proper space ID err := setAllParentIDAttributes(entries, targetID) if err != nil { logFailure("an error occurred during file attribute update: %v", err) return } // Update space ID itself fmt.Printf(" Updating directory '%s' with attribute '%s' -> %s\n", filepath.Base(spacePath), idAttrName, targetID) err = xattr.Set(spacePath, idAttrName, []byte(targetID)) if err != nil { logFailure("Failed to set attribute on directory '%s': %v", spacePath, err) return } err = xattr.Set(spacePath, spaceIDAttrName, []byte(targetID)) if err != nil { logFailure("Failed to set attribute on directory '%s': %v", spacePath, err) return } // update the index err = updateOwnerIndexFile(spacePath, obsoleteIDs) if err != nil { logFailure("Could not update the owner index file: %v", err) } } func gatherAttributes(path string) ([]EntryInfo, map[string]struct{}, EntryInfo, error) { dirEntries, err := os.ReadDir(path) if err != nil { return nil, nil, EntryInfo{}, fmt.Errorf("failed to read directory: %w", err) } var allEntries []EntryInfo uniqueIDs := make(map[string]struct{}) var oldestEntry EntryInfo oldestTime := time.Now().Add(100 * 365 * 24 * time.Hour) // Set to a future date to find the oldest entry for _, entry := range dirEntries { fullPath := filepath.Join(path, entry.Name()) info, err := os.Stat(fullPath) if err != nil { fmt.Printf(" - Warning: could not stat %s: %v\n", entry.Name(), err) continue } parentID, err := xattr.Get(fullPath, parentIDAttrName) if err != nil { continue // Skip if attribute doesn't exist or can't be read } entryInfo := EntryInfo{ Path: fullPath, ModTime: info.ModTime(), ParentID: string(parentID), } allEntries = append(allEntries, entryInfo) uniqueIDs[string(parentID)] = struct{}{} if entryInfo.ModTime.Before(oldestTime) { oldestTime = entryInfo.ModTime oldestEntry = entryInfo } } return allEntries, uniqueIDs, oldestEntry, nil } func setAllParentIDAttributes(entries []EntryInfo, targetID string) error { fmt.Printf(" Setting all parent IDs to '%s':\n", targetID) for _, entry := range entries { if entry.ParentID == targetID { fmt.Printf(" - Skipping '%s' (already has target ID).\n", filepath.Base(entry.Path)) continue } fmt.Printf(" - Removing all attributes from '%s'. It will be re-assimilated\n", filepath.Base(entry.Path)) filepath.WalkDir(entry.Path, func(path string, d os.DirEntry, err error) error { if err != nil { return fmt.Errorf("error walking path '%s': %w", path, err) } // Remove all attributes from the file. if err := removeAttributes(path); err != nil { fmt.Printf("failed to remove attributes from '%s': %v", path, err) } return nil }) } return nil } // updateOwnerIndexFile handles the logic of reading, modifying, and writing the MessagePack index file. func updateOwnerIndexFile(basePath string, obsoleteIDs []string) error { fmt.Printf(" Rewriting index file '%s'\n", basePath) ownerID, err := xattr.Get(basePath, ownerIDAttrName) if err != nil { return fmt.Errorf("could not get owner ID from oldest entry '%s' to find index: %w", basePath, err) } indexPath := filepath.Join(basePath, "../../indexes/by-user-id", string(ownerID)+".mpk") indexPath = filepath.Clean(indexPath) // Read the MessagePack file fileData, err := os.ReadFile(indexPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("index file does not exist, skipping update") } return fmt.Errorf("could not read index file: %w", err) } var indexMap map[string]string if err := msgpack.Unmarshal(fileData, &indexMap); err != nil { return fmt.Errorf("failed to parse MessagePack index file (is it corrupt?): %w", err) } // Remove obsolete IDs from the map itemsRemoved := 0 for _, id := range obsoleteIDs { if _, exists := indexMap[id]; exists { fmt.Printf(" - Removing obsolete ID '%s' from index.\n", id) delete(indexMap, id) itemsRemoved++ } else { fmt.Printf(" - Obsolete ID '%s' not found in index\n", id) } } if itemsRemoved == 0 { return nil } // Write the data back to the file updatedData, err := msgpack.Marshal(&indexMap) if err != nil { return fmt.Errorf("failed to marshal updated index map: %w", err) } if err := os.WriteFile(indexPath, updatedData, 0644); err != nil { return fmt.Errorf("failed to write updated index file: %w", err) } logSuccess("Successfully removed %d item(s) and saved index file.\n", itemsRemoved) return nil } func removeAttributes(path string) error { attrNames, err := xattr.List(path) if err != nil { return fmt.Errorf("failed to list attributes for '%s': %w", path, err) } for _, attrName := range attrNames { if err := xattr.Remove(path, attrName); err != nil { return fmt.Errorf("failed to remove attribute '%s' from '%s': %w", attrName, path, err) } } return nil } func logFailure(message string, args ...any) { spinner.StopFailMessage(fmt.Sprintf(message, args...)) spinner.StopFail() spinner.Start() } func logSuccess(message string, args ...any) { spinner.StopMessage(fmt.Sprintf(message, args...)) spinner.Stop() spinner.Start() }