Browse Source

Issue 2353 – Display and modify S3 file/object tags

https://winscp.net/tracker/2353
(cherry picked from commit e202af0bdc2249dfca20a29742fbb4788f7b5078)

Source commit: 412b87273dcae90457c83baa08cecaf675edd1e8
Martin Prikryl 7 months ago
parent
commit
ee99cb926e

+ 1 - 0
source/core/Common.cpp

@@ -35,6 +35,7 @@ const wchar_t EngShortMonthNames[12][4] =
   {L"Jan", L"Feb", L"Mar", L"Apr", L"May", L"Jun",
    L"Jul", L"Aug", L"Sep", L"Oct", L"Nov", L"Dec"};
 const char Bom[3] = "\xEF\xBB\xBF";
+const UnicodeString XmlDeclaration(TraceInitStr(L"<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
 const wchar_t TokenPrefix = L'%';
 const wchar_t NoReplacement = wchar_t(false);
 const wchar_t TokenReplacement = wchar_t(true);

+ 1 - 0
source/core/Common.h

@@ -24,6 +24,7 @@
 extern const UnicodeString AnyMask;
 extern const wchar_t EngShortMonthNames[12][4];
 extern const char Bom[3];
+extern const UnicodeString XmlDeclaration;
 extern const wchar_t TokenPrefix;
 extern const wchar_t NoReplacement;
 extern const wchar_t TokenReplacement;

+ 1 - 0
source/core/FtpFileSystem.cpp

@@ -1996,6 +1996,7 @@ bool __fastcall TFTPFileSystem::IsCapable(int Capability) const
     case fcResumeSupport:
     case fcChangePassword:
     case fcParallelFileTransfers:
+    case fcTags:
       return false;
 
     default:

+ 18 - 2
source/core/RemoteFiles.cpp

@@ -903,6 +903,7 @@ TRemoteFile * __fastcall TRemoteFile::Duplicate(bool Standalone) const
     COPY_FP(IsSymLink);
     COPY_FP(LinkTo);
     COPY_FP(Type);
+    COPY_FP(Tags);
     COPY_FP(CyclicLink);
     COPY_FP(HumanRights);
     COPY_FP(IsEncrypted);
@@ -2696,7 +2697,8 @@ __fastcall TRemoteProperties::TRemoteProperties(const TRemoteProperties & rhp) :
   Owner(rhp.Owner),
   Modification(rhp.Modification),
   LastAccess(rhp.Modification),
-  Encrypt(rhp.Encrypt)
+  Encrypt(rhp.Encrypt),
+  Tags(rhp.Tags)
 {
 }
 //---------------------------------------------------------------------------
@@ -2710,6 +2712,7 @@ void __fastcall TRemoteProperties::Default()
   Owner.Clear();
   Recursive = false;
   Encrypt = false;
+  Tags = EmptyStr;
 }
 //---------------------------------------------------------------------------
 bool __fastcall TRemoteProperties::operator ==(const TRemoteProperties & rhp) const
@@ -2724,7 +2727,8 @@ bool __fastcall TRemoteProperties::operator ==(const TRemoteProperties & rhp) co
         (Valid.Contains(vpGroup) && (Group != rhp.Group)) ||
         (Valid.Contains(vpModification) && (Modification != rhp.Modification)) ||
         (Valid.Contains(vpLastAccess) && (LastAccess != rhp.LastAccess)) ||
-        (Valid.Contains(vpEncrypt) && (Encrypt != rhp.Encrypt)))
+        (Valid.Contains(vpEncrypt) && (Encrypt != rhp.Encrypt)) ||
+        (Valid.Contains(vpTags) && (Tags != rhp.Tags)))
     {
       Result = false;
     }
@@ -2766,6 +2770,8 @@ TRemoteProperties __fastcall TRemoteProperties::CommonProperties(TStrings * File
         CommonProperties.Group = File->Group;
         CommonProperties.Valid << vpGroup;
       }
+      CommonProperties.Tags = File->Tags;
+      CommonProperties.Valid << vpTags;
     }
     else
     {
@@ -2781,6 +2787,11 @@ TRemoteProperties __fastcall TRemoteProperties::CommonProperties(TStrings * File
         CommonProperties.Group.Clear();
         CommonProperties.Valid >> vpGroup;
       }
+      if (CommonProperties.Tags != File->Tags)
+      {
+        CommonProperties.Tags = EmptyStr;
+        CommonProperties.Valid >> vpTags;
+      }
     }
   }
   return CommonProperties;
@@ -2808,6 +2819,11 @@ TRemoteProperties __fastcall TRemoteProperties::ChangedProperties(
     {
       NewProperties.Valid >> vpOwner;
     }
+
+    if (NewProperties.Tags == OriginalProperties.Tags)
+    {
+      NewProperties.Valid >> vpTags;
+    }
   }
   return NewProperties;
 }

+ 5 - 2
source/core/RemoteFiles.h

@@ -100,6 +100,7 @@ private:
   UnicodeString FHumanRights;
   TTerminal *FTerminal;
   wchar_t FType;
+  UnicodeString FTags;
   bool FCyclicLink;
   UnicodeString FFullFileName;
   int FIsHidden;
@@ -178,6 +179,7 @@ public:
   __property bool HaveFullFileName  = { read = GetHaveFullFileName };
   __property int IconIndex = { read = GetIconIndex };
   __property UnicodeString TypeName = { read = GetTypeName };
+  __property UnicodeString Tags = { read = FTags, write = FTags };
   __property bool IsHidden = { read = GetIsHidden, write = SetIsHidden };
   __property bool IsParentDirectory = { read = GetIsParentDirectory };
   __property bool IsThisDirectory = { read = GetIsThisDirectory };
@@ -432,8 +434,8 @@ private:
   void __fastcall SetRightUndef(TRight Right, TState value);
 };
 //---------------------------------------------------------------------------
-enum TValidProperty { vpRights, vpGroup, vpOwner, vpModification, vpLastAccess, vpEncrypt };
-typedef Set<TValidProperty, vpRights, vpEncrypt> TValidProperties;
+enum TValidProperty { vpRights, vpGroup, vpOwner, vpModification, vpLastAccess, vpEncrypt, vpTags };
+typedef Set<TValidProperty, vpRights, vpTags> TValidProperties;
 class TRemoteProperties
 {
 public:
@@ -446,6 +448,7 @@ public:
   __int64 Modification; // unix time
   __int64 LastAccess; // unix time
   bool Encrypt;
+  UnicodeString Tags;
 
   __fastcall TRemoteProperties();
   __fastcall TRemoteProperties(const TRemoteProperties & rhp);

+ 236 - 86
source/core/S3FileSystem.cpp

@@ -872,7 +872,7 @@ void TS3FileSystem::LibS3Deinitialize()
   S3_deinitialize();
 }
 //---------------------------------------------------------------------------
-struct TLibS3AssumeRoleCallbackData : TLibS3CallbackData
+struct TLibS3XmlCallbackData : TLibS3CallbackData
 {
   RawByteString Contents;
 };
@@ -880,16 +880,30 @@ struct TLibS3AssumeRoleCallbackData : TLibS3CallbackData
 const UnicodeString AssumeRoleVersion(TraceInitStr(L"2011-06-15"));
 const UnicodeString AssumeRoleNamespace(TraceInitStr(FORMAT(L"https://sts.amazonaws.com/doc/%s/", (AssumeRoleVersion))));
 //---------------------------------------------------------------------------
-static _di_IXMLNode AssumeRoleNeedNode(const _di_IXMLNodeList & NodeList, const UnicodeString & Name)
+static _di_IXMLNode NeedNode(const _di_IXMLNodeList & NodeList, const UnicodeString & Name, const UnicodeString & Namespace)
 {
-  _di_IXMLNode Result = NodeList->FindNode(Name, AssumeRoleNamespace);
+  _di_IXMLNode Result = NodeList->FindNode(Name, Namespace);
   if (Result == NULL)
   {
-    throw Exception(FMTLOAD(S3_ASSUME_ROLE_RESPONSE_ERROR, (Name)));
+    throw Exception(FMTLOAD(S3_RESPONSE_ERROR, (Name)));
   }
   return Result;
 }
 //---------------------------------------------------------------------------
+static _di_IXMLNode AssumeRoleNeedNode(const _di_IXMLNodeList & NodeList, const UnicodeString & Name)
+{
+  return NeedNode(NodeList, Name, AssumeRoleNamespace);
+}
+//---------------------------------------------------------------------------
+static const _di_IXMLDocument CreateDocumentFromXML(const TLibS3XmlCallbackData & Data, TParseOptions ParseOptions)
+{
+  const _di_IXMLDocument Result = interface_cast<Xmlintf::IXMLDocument>(new TXMLDocument(NULL));
+  DebugAssert(Result->ParseOptions == TParseOptions());
+  Result->ParseOptions = ParseOptions;
+  Result->LoadFromXML(UTFToString(Data.Contents));
+  return Result;
+}
+//---------------------------------------------------------------------------
 void TS3FileSystem::AssumeRole(const UnicodeString & RoleArn)
 {
   // According to AWS cli does, AWS_ROLE_SESSION_NAME does not apply here
@@ -897,7 +911,7 @@ void TS3FileSystem::AssumeRole(const UnicodeString & RoleArn)
 
   try
   {
-    TLibS3AssumeRoleCallbackData Data;
+    TLibS3XmlCallbackData Data;
     RequestInit(Data);
 
     UnicodeString QueryParams =
@@ -928,8 +942,8 @@ void TS3FileSystem::AssumeRole(const UnicodeString & RoleArn)
       NULL,
       QueryParamsBuf.c_str(),
       NULL, NULL, NULL, NULL, 0, 0, NULL, NULL, NULL, 0,
-      LibS3AssumeRoleDataCallback,
-      LibS3AssumeRoleCompleteCallback,
+      LibS3XmlDataCallback,
+      LibS3ResponseCompleteCallback,
       &Data,
       FTimeout
     };
@@ -938,8 +952,7 @@ void TS3FileSystem::AssumeRole(const UnicodeString & RoleArn)
 
     CheckLibS3Error(Data);
 
-    const _di_IXMLDocument Document = interface_cast<Xmlintf::IXMLDocument>(new TXMLDocument(NULL));
-    Document->LoadFromXML(UnicodeString(UTF8String(Data.Contents)));
+    const _di_IXMLDocument Document = CreateDocumentFromXML(Data, TParseOptions());
     _di_IXMLNode ResponseNode = AssumeRoleNeedNode(Document->ChildNodes, L"AssumeRoleResponse");
     _di_IXMLNode ResultNode = AssumeRoleNeedNode(ResponseNode->ChildNodes, L"AssumeRoleResult");
     _di_IXMLNode CredentialsNode = AssumeRoleNeedNode(ResultNode->ChildNodes, L"Credentials");
@@ -968,16 +981,24 @@ void TS3FileSystem::AssumeRole(const UnicodeString & RoleArn)
   }
 }
 //---------------------------------------------------------------------------
-void TS3FileSystem::LibS3AssumeRoleCompleteCallback(S3Status Status, const S3ErrorDetails * Error, void * CallbackData)
+S3Status TS3FileSystem::LibS3XmlDataCallback(int BufferSize, const char * Buffer, void * CallbackData)
 {
-  LibS3ResponseCompleteCallback(Status, Error, CallbackData);
+  TLibS3XmlCallbackData & Data = *static_cast<TLibS3XmlCallbackData *>(CallbackData);
+  if (Data.Contents.Length() + BufferSize > 1024 * 1024)
+  {
+    throw Exception(L"Too much data");
+  }
+  Data.Contents += RawByteString(Buffer, BufferSize);
+  return S3StatusOK;
 }
 //---------------------------------------------------------------------------
-S3Status TS3FileSystem::LibS3AssumeRoleDataCallback(int BufferSize, const char * Buffer, void * CallbackData)
+int TS3FileSystem::LibS3XmlDataToCallback(int BufferSize, char * Buffer, void * CallbackData)
 {
-  TLibS3AssumeRoleCallbackData & Data = *static_cast<TLibS3AssumeRoleCallbackData *>(CallbackData);
-  Data.Contents += RawByteString(Buffer, BufferSize);
-  return S3StatusOK;
+  TLibS3XmlCallbackData & Data = *static_cast<TLibS3XmlCallbackData *>(CallbackData);
+  int Len = std::min(Data.Contents.Length(), BufferSize);
+  memcpy(Buffer, Data.Contents.c_str(), Len);
+  Data.Contents.Delete(1, Len);
+  return Len;
 }
 //---------------------------------------------------------------------------
 UnicodeString TS3FileSystem::GetFolderKey(const UnicodeString & Key)
@@ -1216,6 +1237,7 @@ bool __fastcall TS3FileSystem::IsCapable(int Capability) const
     case fcLoadingAdditionalProperties:
     case fcAclChangingFiles:
     case fcMoveOverExistingFile:
+    case fcTags:
       return true;
 
     case fcPreservingTimestampUpload:
@@ -1794,6 +1816,7 @@ struct TS3FileProperties
   char OwnerDisplayName[S3_MAX_GRANTEE_DISPLAY_NAME_SIZE];
   int AclGrantCount;
   S3AclGrant AclGrants[S3_MAX_ACL_GRANT_COUNT];
+  UnicodeString Tags;
 };
 //---------------------------------------------------------------------------
 static TRights::TRightLevel S3PermissionToRightLevel(S3Permission Permission)
@@ -1825,8 +1848,20 @@ bool TS3FileSystem::ParsePathForPropertiesRequests(
   return Result;
 }
 //---------------------------------------------------------------------------
+const UnicodeString S3Version(TraceInitStr(L"2006-03-01"));
+const UnicodeString S3Namespace(TraceInitStr(FORMAT(L"http://s3.amazonaws.com/doc/%s/", (S3Version))));
+//---------------------------------------------------------------------------
+static _di_IXMLNode S3NeedNode(const _di_IXMLNodeList & NodeList, const UnicodeString & Name)
+{
+  return NeedNode(NodeList, Name, S3Namespace);
+}
+//---------------------------------------------------------------------------
+#define COPY_BUCKET_CONTEXT(BucketContext) \
+  { BucketContext.hostName, BucketContext.bucketName, BucketContext.protocol, BucketContext.uriStyle, \
+    BucketContext.accessKeyId, BucketContext.secretAccessKey, BucketContext.securityToken, BucketContext.authRegion }
+//---------------------------------------------------------------------------
 bool TS3FileSystem::DoLoadFileProperties(
-  const UnicodeString & AFileName, const TRemoteFile * File, TS3FileProperties & Properties)
+  const UnicodeString & AFileName, const TRemoteFile * File, TS3FileProperties & Properties, bool LoadTags)
 {
   UnicodeString BucketName, Key;
   bool Result = ParsePathForPropertiesRequests(AFileName, File, BucketName, Key);
@@ -1836,15 +1871,61 @@ bool TS3FileSystem::DoLoadFileProperties(
 
     S3ResponseHandler ResponseHandler = CreateResponseHandler();
 
-    TLibS3CallbackData Data;
-    RequestInit(Data);
+    TLibS3CallbackData AclData;
+    RequestInit(AclData);
 
     S3_get_acl(
       &BucketContext, StrToS3(Key), Properties.OwnerId, Properties.OwnerDisplayName,
       &Properties.AclGrantCount, Properties.AclGrants,
-      FRequestContext, FTimeout, &ResponseHandler, &Data);
+      FRequestContext, FTimeout, &ResponseHandler, &AclData);
 
-    CheckLibS3Error(Data);
+    CheckLibS3Error(AclData);
+
+    if (LoadTags)
+    {
+      TLibS3XmlCallbackData TagsData;
+      RequestInit(TagsData);
+
+      UTF8String KeyBuf = UTF8String(Key);
+      RequestParams TaggingRequestParams =
+      {
+        HttpRequestTypeGET,
+        COPY_BUCKET_CONTEXT(BucketContext),
+        KeyBuf.c_str(),
+        NULL,
+        "tagging",
+        NULL, NULL, NULL, 0, 0, NULL,
+        LibS3ResponsePropertiesCallback,
+        NULL, 0,
+        LibS3XmlDataCallback,
+        LibS3ResponseCompleteCallback,
+        &TagsData,
+        FTimeout
+      };
+
+      request_perform(&TaggingRequestParams, FRequestContext);
+
+      if (TagsData.Status != S3StatusErrorAccessDenied)
+      {
+        CheckLibS3Error(TagsData);
+
+        const _di_IXMLDocument Document = CreateDocumentFromXML(TagsData, TParseOptions() << poPreserveWhiteSpace);
+        _di_IXMLNode TaggingNode = S3NeedNode(Document->ChildNodes, L"Tagging");
+        _di_IXMLNode TagSetNode = S3NeedNode(TaggingNode->ChildNodes, L"TagSet");
+        _di_IXMLNodeList TagNodeList = TagSetNode->GetChildNodes();
+        std::unique_ptr<TStrings> Tags(new TStringList());
+        for (int Index = 0; Index < TagNodeList->Count; Index++)
+        {
+          _di_IXMLNode TagNode = TagNodeList->Get(Index);
+          UnicodeString Key = S3NeedNode(TagNode->ChildNodes, L"Key")->Text;
+          Tags->Add(Key);
+          UnicodeString Value = S3NeedNode(TagNode->ChildNodes, L"Value")->Text;
+          Tags->Add(Value);
+        }
+
+        Properties.Tags = Tags->Text;
+      }
+    }
   }
   return Result;
 }
@@ -1869,74 +1950,75 @@ void __fastcall TS3FileSystem::ChangeFileProperties(const UnicodeString FileName
   const TRemoteFile * File, const TRemoteProperties * Properties,
   TChmodSessionAction & /*Action*/)
 {
-  TValidProperties ValidProperties = Properties->Valid;
-  if (DebugAlwaysTrue(ValidProperties.Contains(vpRights)))
+  UnicodeString BucketName, Key;
+  if (DebugAlwaysTrue(ParsePathForPropertiesRequests(FileName, File, BucketName, Key)))
   {
-    ValidProperties >> vpRights;
+    TValidProperties ValidProperties = Properties->Valid;
 
-    DebugAssert(!Properties->AddXToDirectories);
+    TLibS3BucketContext BucketContext = GetBucketContext(BucketName, Key);
 
-    TS3FileProperties FileProperties;
-    if (DebugAlwaysTrue(!File->IsDirectory) &&
-        DebugAlwaysTrue(DoLoadFileProperties(FileName, File, FileProperties)))
+    if (ValidProperties.Contains(vpRights))
     {
-      TAclGrantsVector NewAclGrants;
+      ValidProperties >> vpRights;
+
+      DebugAssert(!Properties->AddXToDirectories);
 
-      unsigned short Permissions = File->Rights->Combine(Properties->Rights);
-      for (int GroupI = TRights::rgFirst; GroupI <= TRights::rgLast; GroupI++)
+      TS3FileProperties FileProperties;
+      if (DebugAlwaysTrue(!File->IsDirectory) &&
+          DebugAlwaysTrue(DoLoadFileProperties(FileName, File, FileProperties, false)))
       {
-        TRights::TRightGroup Group = static_cast<TRights::TRightGroup>(GroupI);
-        S3AclGrant NewAclGrant;
-        memset(&NewAclGrant, 0, sizeof(NewAclGrant));
-        if (Group == TRights::rgUser)
-        {
-          NewAclGrant.granteeType = S3GranteeTypeCanonicalUser;
-          DebugAssert(sizeof(NewAclGrant.grantee.canonicalUser.id) == sizeof(FileProperties.OwnerId));
-          strcpy(NewAclGrant.grantee.canonicalUser.id, FileProperties.OwnerId);
-        }
-        else if (Group == TRights::rgS3AllAwsUsers)
-        {
-          NewAclGrant.granteeType = S3GranteeTypeAllAwsUsers;
-        }
-        else if (DebugAlwaysTrue(Group == TRights::rgS3AllUsers))
-        {
-          NewAclGrant.granteeType = S3GranteeTypeAllUsers;
-        }
-        unsigned short AllGroupPermissions =
-          TRights::CalculatePermissions(Group, TRights::rlS3Read, TRights::rlS3ReadACP, TRights::rlS3WriteACP);
-        if (FLAGSET(Permissions, AllGroupPermissions))
-        {
-          NewAclGrant.permission = S3PermissionFullControl;
-          NewAclGrants.push_back(NewAclGrant);
-          Permissions -= AllGroupPermissions;
-        }
-        else
+        TAclGrantsVector NewAclGrants;
+
+        unsigned short Permissions = File->Rights->Combine(Properties->Rights);
+        for (int GroupI = TRights::rgFirst; GroupI <= TRights::rgLast; GroupI++)
         {
-          #define ADD_ACL_GRANT(PERM) AddAclGrant(Group, Permissions, NewAclGrants, NewAclGrant, PERM)
-          ADD_ACL_GRANT(S3PermissionRead);
-          ADD_ACL_GRANT(S3PermissionWrite);
-          ADD_ACL_GRANT(S3PermissionReadACP);
-          ADD_ACL_GRANT(S3PermissionWriteACP);
+          TRights::TRightGroup Group = static_cast<TRights::TRightGroup>(GroupI);
+          S3AclGrant NewAclGrant;
+          memset(&NewAclGrant, 0, sizeof(NewAclGrant));
+          if (Group == TRights::rgUser)
+          {
+            NewAclGrant.granteeType = S3GranteeTypeCanonicalUser;
+            DebugAssert(sizeof(NewAclGrant.grantee.canonicalUser.id) == sizeof(FileProperties.OwnerId));
+            strcpy(NewAclGrant.grantee.canonicalUser.id, FileProperties.OwnerId);
+          }
+          else if (Group == TRights::rgS3AllAwsUsers)
+          {
+            NewAclGrant.granteeType = S3GranteeTypeAllAwsUsers;
+          }
+          else if (DebugAlwaysTrue(Group == TRights::rgS3AllUsers))
+          {
+            NewAclGrant.granteeType = S3GranteeTypeAllUsers;
+          }
+          unsigned short AllGroupPermissions =
+            TRights::CalculatePermissions(Group, TRights::rlS3Read, TRights::rlS3ReadACP, TRights::rlS3WriteACP);
+          if (FLAGSET(Permissions, AllGroupPermissions))
+          {
+            NewAclGrant.permission = S3PermissionFullControl;
+            NewAclGrants.push_back(NewAclGrant);
+            Permissions -= AllGroupPermissions;
+          }
+          else
+          {
+            #define ADD_ACL_GRANT(PERM) AddAclGrant(Group, Permissions, NewAclGrants, NewAclGrant, PERM)
+            ADD_ACL_GRANT(S3PermissionRead);
+            ADD_ACL_GRANT(S3PermissionWrite);
+            ADD_ACL_GRANT(S3PermissionReadACP);
+            ADD_ACL_GRANT(S3PermissionWriteACP);
+          }
         }
-      }
 
-      DebugAssert(Permissions == 0);
+        DebugAssert(Permissions == 0);
 
-      // Preserve unrecognized permissions
-      for (int Index = 0; Index < FileProperties.AclGrantCount; Index++)
-      {
-        S3AclGrant & AclGrant = FileProperties.AclGrants[Index];
-        unsigned short Permission = AclGrantToPermissions(AclGrant, FileProperties);
-        if (Permission == 0)
+        // Preserve unrecognized permissions
+        for (int Index = 0; Index < FileProperties.AclGrantCount; Index++)
         {
-          NewAclGrants.push_back(AclGrant);
+          S3AclGrant & AclGrant = FileProperties.AclGrants[Index];
+          unsigned short Permission = AclGrantToPermissions(AclGrant, FileProperties);
+          if (Permission == 0)
+          {
+            NewAclGrants.push_back(AclGrant);
+          }
         }
-      }
-
-      UnicodeString BucketName, Key;
-      if (DebugAlwaysTrue(ParsePathForPropertiesRequests(FileName, File, BucketName, Key)))
-      {
-        TLibS3BucketContext BucketContext = GetBucketContext(BucketName, Key);
 
         S3ResponseHandler ResponseHandler = CreateResponseHandler();
 
@@ -1951,9 +2033,62 @@ void __fastcall TS3FileSystem::ChangeFileProperties(const UnicodeString FileName
         CheckLibS3Error(Data);
       }
     }
-  }
 
-  DebugAssert(ValidProperties.Empty());
+    if (ValidProperties.Contains(vpTags))
+    {
+      ValidProperties >> vpTags;
+
+      UnicodeString NewLine = L"\n";
+      UnicodeString Indent = L"  ";
+      UnicodeString Xml =
+        XmlDeclaration + NewLine +
+        L"<Tagging>" + NewLine +
+        Indent + L"<TagSet>" + NewLine;
+
+      std::unique_ptr<TStrings> Tags(TextToStringList(Properties->Tags));
+      for (int Index = 0; Index < Tags->Count; Index += 2)
+      {
+        UnicodeString Key = Tags->Strings[Index];
+        UnicodeString Value = Tags->Strings[Index + 1];
+        Xml += Indent + Indent + FORMAT(L"<Tag><Key>%s</Key><Value>%s</Value></Tag>", (XmlEscape(Key), XmlEscape(Value))) + NewLine;
+      }
+
+      Xml +=
+        Indent + L"</TagSet>" + NewLine +
+        "</Tagging>" + NewLine;
+
+      FTerminal->Log->Add(llOutput, Xml);
+
+      TLibS3XmlCallbackData Data;
+      RequestInit(Data);
+
+      Data.Contents = StrToS3(Xml);
+
+      UTF8String KeyBuf = UTF8String(Key);
+      RequestParams TaggingRequestParams =
+      {
+        HttpRequestTypePUT,
+        COPY_BUCKET_CONTEXT(BucketContext),
+        KeyBuf.c_str(),
+        NULL,
+        "tagging",
+        NULL, NULL, NULL, 0, 0, NULL,
+        LibS3ResponsePropertiesCallback,
+        LibS3XmlDataToCallback,
+        Data.Contents.Length(),
+        NULL,
+        LibS3ResponseCompleteCallback,
+        &Data,
+        FTimeout
+      };
+
+      request_perform(&TaggingRequestParams, FRequestContext);
+
+      CheckLibS3Error(Data);
+    }
+
+    DebugAssert(ValidProperties.Empty());
+  }
 }
 //---------------------------------------------------------------------------
 unsigned short TS3FileSystem::AclGrantToPermissions(S3AclGrant & AclGrant, const TS3FileProperties & Properties)
@@ -2006,12 +2141,19 @@ unsigned short TS3FileSystem::AclGrantToPermissions(S3AclGrant & AclGrant, const
   return Result;
 }
 //---------------------------------------------------------------------------
-void __fastcall TS3FileSystem::LoadFileProperties(const UnicodeString AFileName, const TRemoteFile * File, void * Param)
+struct TLoadFilePropertiesData
+{
+  bool Result;
+  bool LoadTags;
+};
+//---------------------------------------------------------------------------
+void __fastcall TS3FileSystem::LoadFileProperties(const UnicodeString AFileName, const TRemoteFile * AFile, void * Param)
 {
-  bool & Result = *static_cast<bool *>(Param);
+  TRemoteFile * File = const_cast<TRemoteFile *>(AFile);
+  TLoadFilePropertiesData & Data = *static_cast<TLoadFilePropertiesData *>(Param);
   TS3FileProperties Properties;
-  Result = DoLoadFileProperties(AFileName, File, Properties);
-  if (Result)
+  Data.Result = DoLoadFileProperties(AFileName, File, Properties, Data.LoadTags);
+  if (Data.Result)
   {
     bool AdditionalRights;
     unsigned short Permissions = 0;
@@ -2079,23 +2221,31 @@ void __fastcall TS3FileSystem::LoadFileProperties(const UnicodeString AFileName,
 
     File->Rights->Number = Permissions;
     File->Rights->SetTextOverride(HumanRights);
-    Result = true;
+
+    if (Data.LoadTags)
+    {
+      File->Tags = Properties.Tags;
+    }
+
+    Data.Result = true;
   }
 }
 //---------------------------------------------------------------------------
 bool __fastcall TS3FileSystem::LoadFilesProperties(TStrings * FileList)
 {
-  bool Result = false;
+  TLoadFilePropertiesData Data;
+  Data.Result = false;
+  Data.LoadTags = (FileList->Count == 1);
   FTerminal->BeginTransaction();
   try
   {
-    FTerminal->ProcessFiles(FileList, foGetProperties, LoadFileProperties, &Result);
+    FTerminal->ProcessFiles(FileList, foGetProperties, LoadFileProperties, &Data);
   }
   __finally
   {
     FTerminal->EndTransaction();
   }
-  return Result;
+  return Data.Result;
 }
 //---------------------------------------------------------------------------
 void __fastcall TS3FileSystem::CalculateFilesChecksum(

+ 4 - 3
source/core/S3FileSystem.h

@@ -162,7 +162,8 @@ protected:
   bool ShouldCancelTransfer(TLibS3TransferObjectDataCallbackData & Data);
   bool IsGoogleCloud();
   void __fastcall LoadFileProperties(const UnicodeString AFileName, const TRemoteFile * File, void * Param);
-  bool DoLoadFileProperties(const UnicodeString & AFileName, const TRemoteFile * File, TS3FileProperties & Properties);
+  bool DoLoadFileProperties(
+    const UnicodeString & AFileName, const TRemoteFile * File, TS3FileProperties & Properties, bool LoadTags);
   unsigned short AclGrantToPermissions(S3AclGrant & AclGrant, const TS3FileProperties & Properties);
   bool ParsePathForPropertiesRequests(
     const UnicodeString & Path, const TRemoteFile * File, UnicodeString & BucketName, UnicodeString & Key);
@@ -186,8 +187,8 @@ protected:
   static int LibS3MultipartCommitPutObjectDataCallback(int BufferSize, char * Buffer, void * CallbackData);
   static S3Status LibS3MultipartResponsePropertiesCallback(const S3ResponseProperties * Properties, void * CallbackData);
   static S3Status LibS3GetObjectDataCallback(int BufferSize, const char * Buffer, void * CallbackData);
-  static void LibS3AssumeRoleCompleteCallback(S3Status Status, const S3ErrorDetails * Error, void * CallbackData);
-  static S3Status LibS3AssumeRoleDataCallback(int BufferSize, const char * Buffer, void * CallbackData);
+  static S3Status LibS3XmlDataCallback(int BufferSize, const char * Buffer, void * CallbackData);
+  static int LibS3XmlDataToCallback(int BufferSize, char * Buffer, void * CallbackData);
 
   static const int S3MinMultiPartChunkSize;
   static const int S3MaxMultiPartChunks;

+ 1 - 0
source/core/ScpFileSystem.cpp

@@ -465,6 +465,7 @@ bool __fastcall TSCPFileSystem::IsCapable(int Capability) const
     case fcParallelFileTransfers:
     case fcTransferOut:
     case fcTransferIn:
+    case fcTags:
       return false;
 
     case fcChangePassword:

+ 1 - 1
source/core/SessionInfo.cpp

@@ -1651,7 +1651,7 @@ void __fastcall TActionLog::ReflectSettings()
   if (ALogging && !FLogging)
   {
     FLogging = true;
-    Add(L"<?xml version=\"1.0\" encoding=\"UTF-8\"?>");
+    Add(XmlDeclaration);
     UnicodeString SessionName =
       (FSessionData != NULL) ? XmlAttributeEscape(FSessionData->SessionName) : UnicodeString(L"nosession");
     Add(FORMAT(L"<session xmlns=\"http://winscp.net/schema/session/1.0\" name=\"%s\" start=\"%s\">",

+ 3 - 0
source/core/SessionInfo.h

@@ -50,6 +50,7 @@ enum TFSCapability { fcUserGroupListing, fcModeChanging, fcAclChangingFiles, fcG
   fcBackgroundTransfers,
   fcTransferOut, fcTransferIn,
   fcMoveOverExistingFile,
+  fcTags,
   fcCount };
 //---------------------------------------------------------------------------
 struct TFileSystemInfo
@@ -382,4 +383,6 @@ private:
   std::unique_ptr<TCriticalSection> FCriticalSection;
 };
 //---------------------------------------------------------------------------
+UnicodeString __fastcall XmlEscape(UnicodeString Str);
+//---------------------------------------------------------------------------
 #endif

+ 1 - 0
source/core/SftpFileSystem.cpp

@@ -2106,6 +2106,7 @@ bool __fastcall TSFTPFileSystem::IsCapable(int Capability) const
     case fcLocking:
     case fcAclChangingFiles: // pending implementation
     case fcMoveOverExistingFile:
+    case fcTags:
       return false;
 
     case fcNewerOnlyUpload:

+ 1 - 0
source/core/WebDAVFileSystem.cpp

@@ -653,6 +653,7 @@ bool __fastcall TWebDAVFileSystem::IsCapable(int Capability) const
     case fcTransferOut:
     case fcTransferIn:
     case fcParallelFileTransfers:
+    case fcTags:
       return false;
 
     case fcLocking:

+ 101 - 0
source/forms/Custom.cpp

@@ -18,6 +18,7 @@
 #include <PuttyTools.h>
 #include <HistoryComboBox.hpp>
 #include <Math.hpp>
+#include <System.Character.hpp>
 
 #include "Custom.h"
 //---------------------------------------------------------------------
@@ -1794,3 +1795,103 @@ bool DoSshHostCADialog(bool Add, TSshHostCA & SshHostCA)
   std::unique_ptr<TSshHostCADialog> Dialog(new TSshHostCADialog(Add));
   return Dialog->Execute(SshHostCA);
 }
+//---------------------------------------------------------------------------
+//---------------------------------------------------------------------------
+class TTagDialog : public TCustomDialog
+{
+public:
+  TTagDialog(bool Add, TStrings * Tags);
+  bool Execute(UnicodeString & Key, UnicodeString & Value);
+
+protected:
+  virtual void __fastcall DoChange(bool & CanSubmit);
+  virtual void __fastcall DoValidate();
+
+private:
+  TEdit * KeyEdit;
+  TEdit * ValueEdit;
+  TStrings * FTags;
+
+  void ValidateTag(TEdit * Edit);
+};
+//---------------------------------------------------------------------------
+TTagDialog::TTagDialog(bool Add, TStrings * Tags) :
+  TCustomDialog(HELP_TAG)
+{
+  Caption = LoadStr(Add ? TAG_ADD : TAG_EDIT);
+  FTags = Tags;
+
+  KeyEdit = new TEdit(this);
+  KeyEdit->MaxLength = 128;
+  AddEdit(KeyEdit, CreateLabel(LoadStr(TAG_KEY)));
+
+  ValueEdit = new TEdit(this);
+  ValueEdit->MaxLength = 256;
+  AddEdit(ValueEdit, CreateLabel(LoadStr(TAG_VALUE)));
+}
+//---------------------------------------------------------------------------
+bool TTagDialog::Execute(UnicodeString & Key, UnicodeString & Value)
+{
+  // if we happen to get values longer than what we believed was possible, allow them.
+  KeyEdit->MaxLength = std::max(KeyEdit->MaxLength, Key.Length());
+  KeyEdit->Text = Key;
+  ValueEdit->MaxLength = std::max(ValueEdit->MaxLength, Value.Length());
+  ValueEdit->Text = Value;
+  bool Result = TCustomDialog::Execute();
+  if (Result)
+  {
+    Key = KeyEdit->Text;
+    Value = ValueEdit->Text;
+  }
+  return Result;
+}
+//---------------------------------------------------------------------------
+void __fastcall TTagDialog::DoChange(bool & CanSubmit)
+{
+  CanSubmit = !KeyEdit->Text.IsEmpty();
+}
+//---------------------------------------------------------------------------
+void TTagDialog::ValidateTag(TEdit * Edit)
+{
+  // there are lot more in various scripts
+  UnicodeString InvalidChars = L"!\"#$%&'()*,;<>?[\\]^`{|}~¡¢£¤¥¦§¨©«¬­®¯°±´¶·¸»¿×÷";
+  UnicodeString Text = Edit->Text;
+  for (int Index = 1; Index <= Text.Length(); Index++)
+  {
+    wchar_t Ch = Text[Index];
+    if (TCharacter::IsControl(Ch) ||
+        (InvalidChars.Pos(Ch) > 0))
+    {
+      UnicodeString Message = MainInstructions(FMTLOAD(TAG_INVALID_CHAR, (Ch)));
+      if (MoreMessageDialog(Message, NULL, qtWarning, qaIgnore | qaAbort, HelpKeyword) != qaIgnore)
+      {
+        Edit->SetFocus();
+        Abort();
+      }
+      else
+      {
+        break;
+      }
+    }
+  }
+}
+//---------------------------------------------------------------------------
+void __fastcall TTagDialog::DoValidate()
+{
+  UnicodeString Key = KeyEdit->Text;
+  if (FTags->IndexOf(Key) >= 0)
+  {
+    throw Exception(MainInstructions(LoadStr(TAG_NOT_UNIQUE)));
+  }
+
+  ValidateTag(KeyEdit);
+  ValidateTag(ValueEdit);
+
+  TCustomDialog::DoValidate();
+}
+//---------------------------------------------------------------------------
+bool DoTagDialog(bool Add, TStrings * Tags, UnicodeString & Key, UnicodeString & Value)
+{
+  std::unique_ptr<TTagDialog> Dialog(new TTagDialog(Add, Tags));
+  return Dialog->Execute(Key, Value);
+}

+ 5 - 2
source/forms/CustomScpExplorer.cpp

@@ -4926,11 +4926,14 @@ bool __fastcall TCustomScpExplorerForm::SetProperties(TOperationSide Side, TStri
       std::unique_ptr<TStrings> ChecksumAlgs(new TStringList());
       Terminal->GetSupportedChecksumAlgs(ChecksumAlgs.get());
 
+      int Options =
+        FLAGMASK(Terminal->IsCapable[fcGroupOwnerChangingByID], poUserGroupByID) |
+        FLAGMASK(Terminal->IsCapable[fcTags], poTags);
+
       TRemoteProperties NewProperties = CurrentProperties;
       Result =
         DoPropertiesDialog(FileList, RemoteDirView->PathName,
-          GroupList, UserList, ChecksumAlgs.get(), &NewProperties, Flags,
-          Terminal->IsCapable[fcGroupOwnerChangingByID],
+          GroupList, UserList, ChecksumAlgs.get(), &NewProperties, Flags, Options,
           CalculateSize, CalculateChecksumEvent);
       if (Result)
       {

+ 158 - 12
source/forms/Properties.cpp

@@ -22,12 +22,12 @@ bool __fastcall DoPropertiesDialog(TStrings * FileList,
   const UnicodeString Directory, const TRemoteTokenList * GroupList,
   const TRemoteTokenList * UserList, TStrings * ChecksumAlgs,
   TRemoteProperties * Properties,
-  int AllowedChanges, bool UserGroupByID, TCalculateSizeEvent OnCalculateSize,
+  int AllowedChanges, int Options, TCalculateSizeEvent OnCalculateSize,
   TCalculateChecksumEvent OnCalculateChecksum)
 {
   bool Result;
   TPropertiesDialog * PropertiesDialog = new TPropertiesDialog(Application,
-    FileList, Directory, GroupList, UserList, ChecksumAlgs, AllowedChanges, UserGroupByID,
+    FileList, Directory, GroupList, UserList, ChecksumAlgs, AllowedChanges, Options,
     OnCalculateSize, OnCalculateChecksum);
   try
   {
@@ -44,7 +44,7 @@ __fastcall TPropertiesDialog::TPropertiesDialog(TComponent* AOwner,
   TStrings * FileList, const UnicodeString Directory,
   const TRemoteTokenList * GroupList, const TRemoteTokenList * UserList,
   TStrings * ChecksumAlgs,
-  int AllowedChanges, bool UserGroupByID, TCalculateSizeEvent OnCalculateSize,
+  int AllowedChanges, int Options, TCalculateSizeEvent OnCalculateSize,
   TCalculateChecksumEvent OnCalculateChecksum)
   : TForm(AOwner)
 {
@@ -61,7 +61,7 @@ __fastcall TPropertiesDialog::TPropertiesDialog(TComponent* AOwner,
     RightsLabel->Caption = LoadStr(PROPERTIES_ACL);
     RightsFrame->DisplayAsAcl();
   }
-  FUserGroupByID = UserGroupByID;
+  FOptions = Options;
 
   FAllowCalculateStats = false;
   FStatsNotCalculated = false;
@@ -82,9 +82,8 @@ __fastcall TPropertiesDialog::TPropertiesDialog(TComponent* AOwner,
   ReadOnlyControl(SizeLabel);
   ReadOnlyControl(LinksToLabel);
   ChecksumUnknownLabel->Caption = LoadStr(PROPERTIES_CHECKSUM_UNKNOWN);
-  LoadInfo();
-
   UseSystemSettings(this);
+  LoadInfo();
 }
 //---------------------------------------------------------------------------
 __fastcall TPropertiesDialog::~TPropertiesDialog()
@@ -149,7 +148,7 @@ UnicodeString __fastcall TPropertiesDialog::LoadRemoteToken(
   const TRemoteToken & Token)
 {
   UnicodeString Result;
-  if (FUserGroupByID)
+  if (FLAGSET(FOptions, poUserGroupByID))
   {
     if (Token.IDValid)
     {
@@ -250,6 +249,9 @@ void __fastcall TPropertiesDialog::LoadInfo()
     FilesSize += File->Size;
   }
 
+  // before it gets eventualy cleared if !FMultiple
+  bool ShowTags = FLAGSET(FOptions, poTags) && (Stats.Files == 1) && (Stats.Directories == 0);
+
   LoadRemoteTokens(GroupComboBox, FGroupList);
   LoadRemoteTokens(OwnerComboBox, FUserList);
 
@@ -293,6 +295,8 @@ void __fastcall TPropertiesDialog::LoadInfo()
 
   ChecksumGroup->Visible = !FMultipleChecksum;
   ChecksumView->Visible = FMultipleChecksum;
+
+  TagsSheet->TabVisible = ShowTags;
 }
 //---------------------------------------------------------------------------
 void __fastcall TPropertiesDialog::UpdateFileImage()
@@ -436,6 +440,20 @@ void __fastcall TPropertiesDialog::SetFileProperties(const TRemoteProperties & v
     FAnyDirectories;
   RecursiveBevel->Visible = RecursiveCheck2->Visible || HasRights;
 
+  if (TagsSheet->TabVisible)
+  {
+    TagsView->Clear();
+    if (value.Valid.Contains(vpTags))
+    {
+      std::unique_ptr<TStrings> Tags(TextToStringList(value.Tags));
+      for (int Index = 0; Index < Tags->Count; Index += 2)
+      {
+        AddTag(Tags->Strings[Index], Tags->Strings[Index + 1]);
+      }
+    }
+    AutoSizeTagsView();
+  }
+
   UpdateControls();
 }
 //---------------------------------------------------------------------------
@@ -462,7 +480,7 @@ TRemoteToken __fastcall TPropertiesDialog::StoreRemoteToken(const TRemoteToken &
   Text = Text.Trim();
   if (!Text.IsEmpty())
   {
-    if (FUserGroupByID)
+    if (FLAGSET(FOptions, poUserGroupByID))
     {
       DebugAssert(List != NULL);
       int IDStart = Text.LastDelimiter(L"[");
@@ -548,6 +566,20 @@ TRemoteProperties __fastcall TPropertiesDialog::GetFileProperties()
 
   Result.Recursive = RecursiveCheck2->Checked;
 
+  if (TagsSheet->TabVisible)
+  {
+    std::unique_ptr<TStrings> Tags(new TStringList());
+    TagsView->HandleNeeded(); // Count does not work otherwise
+    for (int Index = 0; Index < TagsView->Items->Count; Index++)
+    {
+      TListItem * Item = TagsView->Items->Item[Index];
+      Tags->Add(Item->Caption);
+      Tags->Add(Item->SubItems->Strings[0]);
+    }
+    Result.Tags = Tags->Text;
+    Result.Valid << vpTags;
+  }
+
   return Result;
 }
 //---------------------------------------------------------------------------
@@ -613,6 +645,9 @@ void __fastcall TPropertiesDialog::UpdateControls()
   ChecksumEdit->Visible = !ChecksumEdit->Text.IsEmpty();
   ChecksumUnknownLabel->Visible = !ChecksumEdit->Visible;
 
+  EnableControl(EditTagButton, (TagsView->ItemIndex >= 0));
+  EnableControl(RemoveTagButton, (TagsView->ItemIndex >= 0));
+
   DefaultButton(ChecksumButton, ChecksumAlgEdit->Focused());
   DefaultButton(OkButton, !ChecksumAlgEdit->Focused());
 }
@@ -761,23 +796,36 @@ void __fastcall TPropertiesDialog::CopyClick(TObject * Sender)
 
   int Count = 0;
   UnicodeString SingleText;
-  UnicodeString Text;
+  std::unique_ptr<TStrings> Lines(new TStringList());
   TListItem * Item = ListView->GetNextItem(NULL, sdAll, TItemStates() << isSelected);
   while (Item != NULL)
   {
     DebugAssert(Item->Selected);
 
     SingleText = Item->SubItems->Strings[0];
-    Text += FORMAT(L"%s = %s\r\n", (Item->Caption, Item->SubItems->Strings[0]));
+    UnicodeString Value = Item->SubItems->Strings[0];
+    UnicodeString Entry = Item->Caption;
+    if (!Value.IsEmpty())
+    {
+      Entry += FORMAT(L" = %s", (Value));
+    }
+    Lines->Add(Entry);
     Count++;
 
     Item = ListView->GetNextItem(Item, sdAll, TItemStates() << isSelected);
   }
 
-  CopyToClipboard(Count == 1 ? SingleText : Text);
+  if ((ListView == ChecksumView) && (Count == 1))
+  {
+    CopyToClipboard(SingleText);
+  }
+  else
+  {
+    CopyToClipboard(Lines.get());
+  }
 }
 //---------------------------------------------------------------------------
-void __fastcall TPropertiesDialog::ChecksumViewContextPopup(
+void __fastcall TPropertiesDialog::ListViewContextPopup(
   TObject * Sender, TPoint & MousePos, bool & Handled)
 {
   MenuPopup(Sender, MousePos, Handled);
@@ -849,3 +897,101 @@ void __fastcall TPropertiesDialog::Dispatch(void * Message)
   }
 }
 //---------------------------------------------------------------------------
+void __fastcall TPropertiesDialog::TagsViewKeyDown(TObject *, WORD & Key, TShiftState)
+{
+  if (RemoveTagButton->Enabled && (Key == VK_DELETE))
+  {
+    RemoveTagButton->OnClick(NULL);
+  }
+
+  if (AddTagButton->Enabled && (Key == VK_INSERT))
+  {
+    AddTagButton->OnClick(NULL);
+  }
+}
+//---------------------------------------------------------------------------
+TListItem * TPropertiesDialog::AddTag(const UnicodeString & Key, const UnicodeString & Value)
+{
+  TListItem * Item = TagsView->Items->Add();
+  Item->Caption = Key;
+  Item->SubItems->Add(Value);
+  return Item;
+}
+//---------------------------------------------------------------------------
+void TPropertiesDialog::AutoSizeTagsView()
+{
+  AutoSizeListColumnsWidth(TagsView, 1);
+}
+//---------------------------------------------------------------------------
+void TPropertiesDialog::AddEditTag(bool Add)
+{
+  std::unique_ptr<TStrings> Tags(CreateSortedStringList(true));
+  TListItem * ItemFocused = TagsView->ItemFocused;
+  for (int Index = 0; Index < TagsView->Items->Count; Index++)
+  {
+    TListItem * Item = TagsView->Items->Item[Index];
+    if (Add || (Item != ItemFocused))
+    {
+      Tags->Add(Item->Caption);
+    }
+  }
+
+  UnicodeString Key, Value;
+  if (!Add)
+  {
+    Key = ItemFocused->Caption;
+    Value = ItemFocused->SubItems->Strings[0];
+  }
+  if (DoTagDialog(Add, Tags.get(), Key, Value))
+  {
+    if (Add)
+    {
+      TagsView->ItemFocused = AddTag(Key, Value);
+      TagsView->ItemFocused->MakeVisible(false);
+    }
+    else
+    {
+      ItemFocused->Caption = Key;
+      ItemFocused->SubItems->Strings[0] = Value;
+    }
+    AutoSizeTagsView();
+    UpdateControls();
+  }
+}
+//---------------------------------------------------------------------------
+void __fastcall TPropertiesDialog::AddTagButtonClick(TObject *)
+{
+  AddEditTag(true);
+}
+//---------------------------------------------------------------------------
+void __fastcall TPropertiesDialog::TagsViewSelectItem(TObject *, TListItem *, bool Selected)
+{
+  DebugUsedParam(Selected);
+  UpdateControls();
+}
+//---------------------------------------------------------------------------
+void __fastcall TPropertiesDialog::EditTagButtonClick(TObject *)
+{
+  AddEditTag(false);
+}
+//---------------------------------------------------------------------------
+void __fastcall TPropertiesDialog::RemoveTagButtonClick(TObject *)
+{
+  int Index = TagsView->ItemIndex;
+
+  TagsView->ItemFocused->Delete();
+
+  int Count = TagsView->Items->Count;
+  TagsView->ItemIndex = (Index < Count ? Index : Count - 1);
+  AutoSizeTagsView();
+  UpdateControls();
+}
+//---------------------------------------------------------------------------
+void __fastcall TPropertiesDialog::TagsViewDblClick(TObject *)
+{
+  if (EditTagButton->Enabled)
+  {
+    EditTagButton->OnClick(NULL);
+  }
+}
+//---------------------------------------------------------------------------

+ 67 - 1
source/forms/Properties.dfm

@@ -302,7 +302,7 @@ object PropertiesDialog: TPropertiesDialog
         PopupMenu = ListViewMenu
         TabOrder = 2
         ViewStyle = vsReport
-        OnContextPopup = ChecksumViewContextPopup
+        OnContextPopup = ListViewContextPopup
       end
       object ChecksumAlgEdit: TComboBox
         Left = 90
@@ -363,6 +363,72 @@ object PropertiesDialog: TPropertiesDialog
         end
       end
     end
+    object TagsSheet: TTabSheet
+      Caption = 'Tags'
+      ImageIndex = 2
+      DesignSize = (
+        390
+        355)
+      object TagsView: TListView
+        Left = 3
+        Top = 5
+        Width = 382
+        Height = 227
+        Anchors = [akLeft, akTop, akRight, akBottom]
+        Columns = <
+          item
+            Caption = 'Key'
+            Width = 100
+          end
+          item
+            Caption = 'Value'
+            Width = 100
+          end>
+        ColumnClick = False
+        DoubleBuffered = True
+        HideSelection = False
+        ReadOnly = True
+        RowSelect = True
+        ParentDoubleBuffered = False
+        PopupMenu = ListViewMenu
+        TabOrder = 0
+        ViewStyle = vsReport
+        OnContextPopup = ListViewContextPopup
+        OnDblClick = TagsViewDblClick
+        OnKeyDown = TagsViewKeyDown
+        OnSelectItem = TagsViewSelectItem
+      end
+      object AddTagButton: TButton
+        Left = 133
+        Top = 238
+        Width = 80
+        Height = 25
+        Anchors = [akRight, akBottom]
+        Caption = '&Add...'
+        TabOrder = 1
+        OnClick = AddTagButtonClick
+      end
+      object RemoveTagButton: TButton
+        Left = 305
+        Top = 238
+        Width = 80
+        Height = 25
+        Anchors = [akRight, akBottom]
+        Caption = '&Remove'
+        TabOrder = 3
+        OnClick = RemoveTagButtonClick
+      end
+      object EditTagButton: TButton
+        Left = 219
+        Top = 238
+        Width = 80
+        Height = 25
+        Anchors = [akRight, akBottom]
+        Caption = '&Edit...'
+        TabOrder = 2
+        OnClick = EditTagButtonClick
+      end
+    end
   end
   object HelpButton: TButton
     Left = 310

+ 17 - 3
source/forms/Properties.h

@@ -58,6 +58,11 @@ __published:
   TLabel *ChecksumUnknownLabel;
   TEdit *OwnerView;
   TEdit *GroupView;
+  TTabSheet *TagsSheet;
+  TListView *TagsView;
+  TButton *AddTagButton;
+  TButton *RemoveTagButton;
+  TButton *EditTagButton;
   void __fastcall ControlChange(TObject *Sender);
   void __fastcall FormCloseQuery(TObject *Sender, bool &CanClose);
   void __fastcall CalculateSizeButtonClick(TObject *Sender);
@@ -66,15 +71,21 @@ __published:
   void __fastcall PageControlChange(TObject *Sender);
   void __fastcall ChecksumAlgEditChange(TObject *Sender);
   void __fastcall CopyClick(TObject *Sender);
-  void __fastcall ChecksumViewContextPopup(TObject *Sender,
+  void __fastcall ListViewContextPopup(TObject *Sender,
           TPoint &MousePos, bool &Handled);
   void __fastcall GroupComboBoxExit(TObject *Sender);
   void __fastcall OwnerComboBoxExit(TObject *Sender);
   void __fastcall FormShow(TObject *Sender);
+  void __fastcall TagsViewKeyDown(TObject *Sender, WORD &Key, TShiftState Shift);
+  void __fastcall AddTagButtonClick(TObject *Sender);
+  void __fastcall TagsViewSelectItem(TObject *Sender, TListItem *Item, bool Selected);
+  void __fastcall EditTagButtonClick(TObject *Sender);
+  void __fastcall RemoveTagButtonClick(TObject *Sender);
+  void __fastcall TagsViewDblClick(TObject *Sender);
 
 private:
   int FAllowedChanges;
-  bool FUserGroupByID;
+  int FOptions;
   TStrings * FFileList;
   const TRemoteTokenList * FGroupList;
   const TRemoteTokenList * FUserList;
@@ -122,6 +133,9 @@ protected:
   void __fastcall LoadStats(__int64 FilesSize, const TCalculateSizeStats & Stats);
   virtual void __fastcall Dispatch(void * Message);
   void __fastcall UpdateFileImage();
+  TListItem * AddTag(const UnicodeString & Key, const UnicodeString & Value);
+  void AutoSizeTagsView();
+  void AddEditTag(bool Add);
 
   INTERFACE_HOOK;
 
@@ -130,7 +144,7 @@ public:
     TStrings * FileList, const UnicodeString Directory,
     const TRemoteTokenList * GroupList, const TRemoteTokenList * UserList,
     TStrings * ChecksumAlgs,
-    int AllowedChanges, bool UserGroupByID, TCalculateSizeEvent OnCalculateSize,
+    int AllowedChanges, int Options, TCalculateSizeEvent OnCalculateSize,
     TCalculateChecksumEvent OnCalculateChecksum);
 
   virtual __fastcall ~TPropertiesDialog();

+ 1 - 0
source/resource/HelpWin.h

@@ -73,5 +73,6 @@
 #define HELP_STORE_TRANSITION        "microsoft_store#transitioning"
 #define HELP_SSH_HOST_CA             "ui_ssh_host_ca"
 #define HELP_SHOW_LOGIN              "ui_pref_window#show_login"
+#define HELP_TAG                     "ui_properties#tags"
 
 #endif // TextsWin

+ 1 - 1
source/resource/TextsCore.h

@@ -289,7 +289,7 @@
 #define SSH_HOST_CA_CERTIFICATE 769
 #define SSH_HOST_CA_INVALID     770
 #define S3_ASSUME_ROLE_ERROR    780
-#define S3_ASSUME_ROLE_RESPONSE_ERROR 781
+#define S3_RESPONSE_ERROR       781
 #define INI_NO_SITES            782
 #define TLS_UNSUPPORTED         783
 #define OPENSSL_INIT_ERROR      784

+ 1 - 1
source/resource/TextsCore1.rc

@@ -265,7 +265,7 @@ BEGIN
   SSH_HOST_CA_CERTIFICATE, "CA key may not be a certificate (type is '%s')."
   SSH_HOST_CA_INVALID, "Invalid '%s' key data."
   S3_ASSUME_ROLE_ERROR, "Error assuming role '%s'."
-  S3_ASSUME_ROLE_RESPONSE_ERROR, "Unexpected response to assume role request (%s)."
+  S3_RESPONSE_ERROR, "Unexpected response to AWS request (%s)."
   INI_NO_SITES, "No sites found in \"%s\"."
   TLS_UNSUPPORTED, "The server is using unsupported protocol. Your WinSCP session is configured to use %s through %s. It can be configured to use %s through %s. Though, avoid using old insecure protocols, whenever possible."
   OPENSSL_INIT_ERROR, "OpenSSL initialization failed"

+ 6 - 0
source/resource/TextsWin.h

@@ -92,6 +92,8 @@
 #define TOO_MANY_PARAMS_ERROR   1211
 #define UPDATE_MISSING_ADDRESS3 1212
 #define COPYID_IDETITY_MISSING  1213
+#define TAG_NOT_UNIQUE          1214
+#define TAG_INVALID_CHAR        1215
 
 #define WIN_CONFIRMATION_STRINGS 1300
 #define CONFIRM_OVERWRITE_SESSION 1301
@@ -699,6 +701,10 @@
 #define INC_SEARCH_TYPE         6208
 #define SEARCH_EDIT             6209
 #define SEARCH_NO_RESULTS       6210
+#define TAG_EDIT                6211
+#define TAG_ADD                 6212
+#define TAG_KEY                 6213
+#define TAG_VALUE               6214
 
 // 2xxx is reserved for TextsFileZilla.h
 

+ 6 - 0
source/resource/TextsWin1.rc

@@ -100,6 +100,8 @@ BEGIN
         JUMPLIST_ERROR, "Error updating jump list."
         TOO_MANY_PARAMS_ERROR, "Too many parameters."
         COPYID_IDETITY_MISSING, "Identity/key file was not specified."
+        TAG_NOT_UNIQUE, "Keys must be unique."
+        TAG_INVALID_CHAR, "Character \"%s\" is not allowed in tags."
 
         WIN_CONFIRMATION_STRINGS, "WIN_CONFIRMATION"
         CONFIRM_OVERWRITE_SESSION, "Site with name '%s' already exists. Overwrite?"
@@ -704,6 +706,10 @@ BEGIN
         INC_SEARCH_TYPE, "(start typing)"
         SEARCH_EDIT, "Search"
         SEARCH_NO_RESULTS, "No search results found."
+        TAG_EDIT, "Edit tag"
+        TAG_ADD, "Add tag"
+        TAG_KEY, "&Key:"
+        TAG_VALUE, "&Value (optional):"
 
         WIN_VARIABLE_STRINGS, "WIN_VARIABLE"
         WINSCP_COPYRIGHT, "Copyright © 2000–2025 Martin Prikryl"

+ 4 - 1
source/windows/WinInterface.h

@@ -166,6 +166,7 @@ bool __fastcall DoCustomCommandOptionsDialog(
 void __fastcall DoUsageStatisticsDialog();
 void __fastcall DoSiteRawDialog(TSessionData * Data);
 bool DoSshHostCADialog(bool Add, TSshHostCA & SshHostCA);
+bool DoTagDialog(bool Add, TStrings * Tags, UnicodeString & Key, UnicodeString & Value);
 
 // windows\UserInterface.cpp
 bool __fastcall DoMasterPasswordDialog();
@@ -311,6 +312,8 @@ const cpMode =  0x01;
 const cpOwner = 0x02;
 const cpGroup = 0x04;
 const cpAcl =   0x08;
+const poUserGroupByID = 0x01;
+const poTags =          0x02;
 typedef void __fastcall (__closure *TCalculateSizeEvent)
   (TStrings * FileList, __int64 & Size, TCalculateSizeStats & Stats,
    bool & Close);
@@ -323,7 +326,7 @@ bool __fastcall DoPropertiesDialog(TStrings * FileList,
     const UnicodeString Directory, const TRemoteTokenList * GroupList,
     const TRemoteTokenList * UserList, TStrings * ChecksumAlgs,
     TRemoteProperties * Properties,
-    int AllowedChanges, bool UserGroupByID, TCalculateSizeEvent OnCalculateSize,
+    int AllowedChanges, int Options, TCalculateSizeEvent OnCalculateSize,
     TCalculateChecksumEvent OnCalculateChecksum);
 
 typedef bool (__closure * TDirectoryExistsEvent)(void * Session, const UnicodeString & Directory);