WinInetCache.cs source code in C# .NET

Source code for the .NET framework in C#

                        

Code:

/ DotNET / DotNET / 8.0 / untmp / whidbey / REDBITS / ndp / fx / src / Net / System / Net / Cache / WinInetCache.cs / 1 / WinInetCache.cs

                            /*++ 
Copyright (c) Microsoft Corporation

Module Name:
 
    _WinInetCache.cs
 
Abstract: 
    The class implements low-level object model for
    communications with the caching part of WinInet DLL 


Author:
 
    Alexei Vopilov    21-Dec-2002
 
Revision History: 

--*/ 
namespace System.Net.Cache {
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Security.Permissions; 
using System.Collections;
using System.Text; 
using System.Collections.Specialized; 
using System.Threading;
using System.Globalization; 

    //
    // WinInet OS Provider implementation (Caching part only)
    // 
    // Contains methods marked with unsafe keyword
    // 
    internal class _WinInetCache { 

        private const int  c_CharSz = 2; 

        private _WinInetCache() {
        }
 

        // 
        // DATA Definitions 
        //
 
        //  Cache Entry declarations
        [Flags]
        internal enum EntryType {
            NormalEntry     = 0x00000041, // ored with HTTP_1_1_CACHE_ENTRY 
            StickyEntry     = 0x00000044, // ored with HTTP_1_1_CACHE_ENTRY
            Edited          = 0x00000008, 
            TrackOffline    = 0x00000010, 
            TrackOnline     = 0x00000020,
            Sparse          = 0x00010000, 
            Cookie          = 0x00100000,
            UrlHistory      = 0x00200000,
//            FindDefaultFilter   = NormalEntry|StickyEntry|Cookie|UrlHistory|TrackOffline|TrackOnline
        } 
        /*
            Some More IE private entry types 
        HTTP_1_1_CACHE_ENTRY            0x00000040 
        STATIC_CACHE_ENTRY              0x00000080
        MUST_REVALIDATE_CACHE_ENTRY     0x00000100 
        COOKIE_ACCEPTED_CACHE_ENTRY     0x00001000
        COOKIE_LEASHED_CACHE_ENTRY      0x00002000
        COOKIE_DOWNGRADED_CACHE_ENTRY   0x00004000
        COOKIE_REJECTED_CACHE_ENTRY     0x00008000 
        PENDING_DELETE_CACHE_ENTRY      0x00400000
        OTHER_USER_CACHE_ENTRY          0x00800000 
        PRIVACY_IMPACTED_CACHE_ENTRY    0x02000000 
        POST_RESPONSE_CACHE_ENTRY       0x04000000
        INSTALLED_CACHE_ENTRY           0x10000000 
        POST_


 

 
 

 



 

 
 
*/
 

        //  Some supported Entry fields references
        [Flags]
        internal enum Entry_FC { 
            None            = 0x0,
            Attribute       = 0x00000004, 
            Hitrate         = 0x00000010, 
            Modtime         = 0x00000040,
            Exptime         = 0x00000080, 
            Acctime         = 0x00000100,
            Synctime        = 0x00000200,
            Headerinfo      = 0x00000400,
            ExemptDelta     = 0x00000800 
        }
 
        //  Error status codes, some are mapped to native ones 
        internal enum Status {
            Success                 = 0, 
            InsufficientBuffer      = 122,
            FileNotFound            = 2,
            NoMoreItems             = 259,
            NotEnoughStorage        = 8, 
            SharingViolation        = 32,
            InvalidParameter        = 87, 
 
            // Below are extensions of native errors (no real errors exist)
            Warnings                = 0x1000000, 

            FatalErrors             = Warnings + 0x1000,
            CorruptedHeaders        = (int)FatalErrors+1,
            InternalError           = (int)FatalErrors+2 
        }
 
        [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)] 
        internal struct FILETIME {
            public uint Low; 
            public uint High;

            public static readonly FILETIME Zero = new FILETIME(0L);
 
            public FILETIME(long time) {
                unchecked { 
                    Low  = (uint)time; 
                    High = (uint)(time>>32);
                } 
            }

            public long ToLong() {
                return ((long)High<<32) | Low; 
            }
 
            public bool IsNull { 
                get {return Low == 0 && High == 0;}
            } 
        }
        //
        // It's an unmanamged layout of WinInet cache Entry Info
        // The pointer on this guy will represent the entry info for wininet 
        // native calls
        // 
        [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Auto)] 
        internal struct EntryBuffer {
            public static int MarshalSize = Marshal.SizeOf(typeof(EntryBuffer)); 

            public  int         StructSize;        // version of cache system MUST BE == sizeof(this)
            // We replace this with an offset from the struct start
            public  IntPtr      _OffsetSourceUrlName; 
            // We replace this with an offset from the struct start
            public  IntPtr      _OffsetFileName;   // embedded pointer to the local file name. 
            public  EntryType   EntryType;         // cache type bit mask. 
            public  int         UseCount;          // current users count of the cache entry.
            public  int         HitRate;           // num of times the cache entry was retrieved. 
            public  int         SizeLow;           // low DWORD of the file size.
            public  int         SizeHigh;          // high DWORD of the file size.
            public  FILETIME    LastModifiedTime;  // last modified time of the file in GMT format.
            public  FILETIME    ExpireTime;        // expire time of the file in GMT format 
            public  FILETIME    LastAccessTime;    // last accessed time in GMT format
            public  FILETIME    LastSyncTime;      // last time the URL was synchronized with the source 
            // We replace this with an offset from the struct start 
            public  IntPtr      _OffsetHeaderInfo; // embedded pointer to the header info.
            public  int         HeaderInfoChars;   // size of the above header. 
            // We replace this with an offset from the struct start
            public  IntPtr     _OffsetExtension;   // File extension used to retrive the urldata as a file.

            [StructLayout(LayoutKind.Explicit)] 
            public struct Rsv {
                [FieldOffset(0)] public  int         ExemptDelta;       // Exemption delta from last access 
                [FieldOffset(0)] public  int         Reserved;          // To keep the unmanaged layout 
            }
            public Rsv U; 

        }

        // 
        // This class holds a manged version of native WinInet buffer
        // 
        internal class Entry { 
            public const int    DefaultBufferSize = 2048;
 
            public Status       Error;
            public string       Key;
            public string       Filename;           // filled by Create() or returned by LookupXXX()
            public string       FileExt;            // filled by Create() or returned by LookupXXX() 
            public int          OptionalLength;     // should be always null
            public string       OriginalUrl;        // should be always null 
            public string       MetaInfo;           // referenced to by Entry_FC.Headerinfo 
            public int          MaxBufferBytes;     // contains the buffer size in bytes on input and
                                                    // copied bytes count in the buffer on output 
            // The tail represents the entry info in its unmanaged layout;
            public EntryBuffer  Info;

            public Entry(string key, int maxHeadersSize) { 
                Key = key;
                MaxBufferBytes = maxHeadersSize; 
                if (maxHeadersSize != Int32.MaxValue && (Int32.MaxValue - (key.Length + EntryBuffer.MarshalSize + 1024)*2) > maxHeadersSize) { 
                    //
                    // The buffer size is restricted mostly by headers, we reserve 1k more CHARS for additional 
                    // metadata, otherwise user has to play with the maxHeadersSize parameter
                    //
                    //
                    MaxBufferBytes += (key.Length + EntryBuffer.MarshalSize + 1024)*2; 
                }
                Info.EntryType = EntryType.NormalEntry; 
            } 
        }
 
        //
        // Method Definitions
        //
 

        // 
        // Looks up an entry based on url string key. 
        // Parses Headers and strings into entry mamabers.
        // Parses the raw output into entry.Info member. 
        //
        unsafe internal static Status LookupInfo(Entry entry) {

            byte[] entryBuffer = new byte[Entry.DefaultBufferSize]; 
            int size      = entryBuffer.Length;
            byte[] buffer = entryBuffer; 
 
            //We may need to adjust the buffer size (using 64 attempts although I would rather try to death)
            for (int k = 0; k < 64; ++k) { 
                fixed (byte* entryPtr = buffer) {

                    bool found = UnsafeNclNativeMethods.UnsafeWinInetCache.GetUrlCacheEntryInfoW(entry.Key, entryPtr, ref size);
 
                    if (found) {
                        entryBuffer = buffer; 
                        entry.MaxBufferBytes = size; 
                        EntryFixup(entry, (EntryBuffer*) entryPtr, buffer);
                        entry.Error = Status.Success; 
                        return entry.Error;
                    }

                    entry.Error = (Status)Marshal.GetLastWin32Error(); 
                    if (entry.Error == Status.InsufficientBuffer) {
                        if ((object) buffer == (object)entryBuffer) { 
                            // did not reallocate yet. 
                            if (size <= entry.MaxBufferBytes) {
                                buffer = new byte[size]; 
                                continue;
                            }
                        }
                    } 
                    //some error has occured
                    break; 
                } 
            }
            return entry.Error; 
        }

        //
        // Lookups an entry based on url string key. 
        // If exists, locks the entry and hands out a managed handle representing a locked entry.
        // 
        unsafe internal static SafeUnlockUrlCacheEntryFile LookupFile(Entry entry) { 

            byte[] buffer       = new byte[Entry.DefaultBufferSize]; 
            int size            = buffer.Length;
            SafeUnlockUrlCacheEntryFile handle = null;

            try { 
                while (true) {
                    fixed (byte* entryPtr = buffer) { 
                        //We may need to adjust the buffer size 
                        entry.Error = SafeUnlockUrlCacheEntryFile.GetAndLockFile(entry.Key, entryPtr, ref size, out handle);
 
                        if (entry.Error  == Status.Success) {
                            entry.MaxBufferBytes = size;
                            EntryFixup(entry, (EntryBuffer*) entryPtr, buffer);
                            //The method is available in TRAVE 
                            return handle;
                        } 
 

                        if (entry.Error == Status.InsufficientBuffer) { 
                            if (size <= entry.MaxBufferBytes) {
                                buffer = new byte[size];
                                continue;
                            } 
                        }
                        //some error has occured 
                        break; 
                    }
 
                }
            }
            catch(Exception e) {
                if (handle != null) 
                    handle.Close();
 
                if (e is ThreadAbortException || e is StackOverflowException || e is OutOfMemoryException) 
                    throw;
 
                if (entry.Error == Status.Success) {
                    entry.Error = Status.InternalError;
                }
            } 
            catch {
                if (handle != null) 
                    handle.Close(); 

                if (entry.Error == Status.Success) { 
                    entry.Error = Status.InternalError;
                }
            }
            return null; 
        }
 
 
        //
        // Does the fixup of the returned buffer by converting internal pointer to offsets 
        // it also does copying of non-string values from unmanaged buffer to Entry.Buffer members
        //
        unsafe private static Status EntryFixup(Entry entry, EntryBuffer* bufferPtr, byte[] buffer) {
            unchecked { 
                bufferPtr->_OffsetExtension     = bufferPtr->_OffsetExtension == IntPtr.Zero? IntPtr.Zero: (IntPtr)((byte*)bufferPtr->_OffsetExtension - (byte*)bufferPtr);
                bufferPtr->_OffsetFileName      = bufferPtr->_OffsetFileName == IntPtr.Zero? IntPtr.Zero: (IntPtr)((byte*)bufferPtr->_OffsetFileName - (byte*)bufferPtr); 
                bufferPtr->_OffsetHeaderInfo    = bufferPtr->_OffsetHeaderInfo == IntPtr.Zero? IntPtr.Zero: (IntPtr)((byte*)bufferPtr->_OffsetHeaderInfo - (byte*)bufferPtr); 
                bufferPtr->_OffsetSourceUrlName = bufferPtr->_OffsetSourceUrlName == IntPtr.Zero? IntPtr.Zero: (IntPtr)((byte*)bufferPtr->_OffsetSourceUrlName - (byte*)bufferPtr);
 
                // Get a managed EntryBuffer copy out of byte[]
                entry.Info = *bufferPtr;
                entry.OriginalUrl   = GetEntryBufferString(bufferPtr, (int)(bufferPtr->_OffsetSourceUrlName));
                entry.Filename      = GetEntryBufferString(bufferPtr, (int)(bufferPtr->_OffsetFileName)); 
                entry.FileExt       = GetEntryBufferString(bufferPtr, (int)(bufferPtr->_OffsetExtension));
            } 
            return GetEntryHeaders(entry, bufferPtr, buffer); 
        }
 
        //
        // Returns a filename for a cache entry to write to.
        //
        internal static Status CreateFileName(Entry entry) { 

            entry.Error = Status.Success; 
            StringBuilder sb = new StringBuilder(UnsafeNclNativeMethods.UnsafeWinInetCache.MAX_PATH); 
            if (UnsafeNclNativeMethods.UnsafeWinInetCache.CreateUrlCacheEntryW(entry.Key, entry.OptionalLength, entry.FileExt, sb, 0)) {
                entry.Filename = sb.ToString(); 
                return Status.Success;
            }
            entry.Error = (Status) Marshal.GetLastWin32Error();
            return entry.Error; 
        }
 
 
        //
        // Associates a file with a cache entry. 
        //
        internal static Status Commit(Entry entry) {
            string s = entry.MetaInfo;
            if (s == null) { 
                s = string.Empty;
            } 
            if ((s.Length + entry.Key.Length + entry.Filename.Length + (entry.OriginalUrl==null? 0: entry.OriginalUrl.Length)) > entry.MaxBufferBytes/c_CharSz) { 
                entry.Error = Status.InsufficientBuffer;
                return entry.Error; 
            }

            entry.Error = Status.Success;
            unsafe { 
                fixed (char *ptr = s) {
 
                    byte* realBytesPtr = s.Length == 0? null: (byte*)ptr; 
                    if (!UnsafeNclNativeMethods.UnsafeWinInetCache.CommitUrlCacheEntryW(
                                                                    entry.Key, 
                                                                    entry.Filename,
                                                                    entry.Info.ExpireTime,
                                                                    entry.Info.LastModifiedTime,
                                                                    entry.Info.EntryType, 
                                                                    realBytesPtr,
                                                                    s.Length, 
                                                                    null,               // FileExt is reserved, must be null 
                                                                    entry.OriginalUrl   // It's better to not play with redirections
                                                                                        // OrigianlUri should be nulled by the caller 
                                                                    ))
                    {
                        entry.Error = (Status)Marshal.GetLastWin32Error();
                    } 
                }
            } 
 
            return entry.Error;
        } 

        //
        // Updates a Cached Entry metadata according to attibutes flags.
        // 
        internal static Status Update(Entry newEntry, Entry_FC attributes) {
            // Currently WinInet does not support headers update, 
            // hence don't need space for them although we'll need recreate a cache entry 
            // if headers update is requested
 
            byte[]  buffer = new byte[EntryBuffer.MarshalSize];
            newEntry.Error = Status.Success;
            unsafe {
                fixed (byte *bytePtr = buffer) { 
                    EntryBuffer *ePtr = (EntryBuffer*) bytePtr;
                    *ePtr = newEntry.Info; 
                    //set the version just in case 
                    ePtr->StructSize =  EntryBuffer.MarshalSize;
 
                    if ((attributes & Entry_FC.Headerinfo) == 0) {
                        if (!UnsafeNclNativeMethods.UnsafeWinInetCache.SetUrlCacheEntryInfoW(newEntry.Key, bytePtr, attributes)) {
                            newEntry.Error = (Status)Marshal.GetLastWin32Error();
                        } 
                    }
                    else { 
                        // simulating headers update using Edited cache entry feature of WinInet 
                        Entry oldEntry = new Entry(newEntry.Key, newEntry.MaxBufferBytes);
 
                        SafeUnlockUrlCacheEntryFile handle = null;
                        bool wasEdited = false;
                        try {
                            // lock the entry and get the filename out. 
                            handle = LookupFile(oldEntry);
                            if (handle == null) { 
                                //The same error would happen on update attributes, return it. 
                                newEntry.Error = oldEntry.Error;
                                return newEntry.Error; 
                            }

                            //Copy strings from old entry that are not present in the method parameters
                            newEntry.Filename       = oldEntry.Filename; 
                            newEntry.OriginalUrl    = oldEntry.OriginalUrl;
                            newEntry.FileExt        = oldEntry.FileExt; 
 
                            // We don't need to update this and some other attributes since will replace entire entry
                            attributes &= ~Entry_FC.Headerinfo; 

                            //Copy attributes from an old entry that are not present in the method parameters
                            if ((attributes & Entry_FC.Exptime) == 0) {
                                newEntry.Info.ExpireTime = oldEntry.Info.ExpireTime; 
                            }
 
                            if ((attributes & Entry_FC.Modtime) == 0) { 
                                newEntry.Info.LastModifiedTime = oldEntry.Info.LastModifiedTime;
                            } 

                            if ((attributes & Entry_FC.Attribute) == 0) {
                                newEntry.Info.EntryType = oldEntry.Info.EntryType;
                                newEntry.Info.U.ExemptDelta = oldEntry.Info.U.ExemptDelta; 
                                if ((oldEntry.Info.EntryType & EntryType.StickyEntry) == EntryType.StickyEntry) {
                                    attributes |= (Entry_FC.Attribute | Entry_FC.ExemptDelta); 
                                } 
                            }
 
                            // Those attributes will be taken care of by Commit()
                            attributes &= ~(Entry_FC.Exptime|Entry_FC.Modtime);

                            wasEdited = (oldEntry.Info.EntryType & EntryType.Edited) != 0; 

                            if (!wasEdited) { 
                                // Prevent the file from being deleted on entry Remove (kinda hack) 
                                oldEntry.Info.EntryType |= EntryType.Edited;
                                // Recursion! 
                                if (Update(oldEntry, Entry_FC.Attribute) != Status.Success) {
                                    newEntry.Error = oldEntry.Error;
                                    return newEntry.Error;
                                } 
                            }
                        } 
                        finally { 
                            if (handle != null) {
                                handle.Close(); 
                            }
                        }

                        // At this point we try to delete the exisintg item and create a new one with the same 
                        // filename and the new headers.
                        //We wish to ignore any errors from Remove since are going to replace the entry. 
                        Remove(oldEntry); 
                        if (Commit(newEntry) != Status.Success) {
                            if (!wasEdited) { 
                                //revert back the original entry type
                                oldEntry.Info.EntryType &= ~EntryType.Edited;
                                Update(oldEntry, Entry_FC.Attribute);
                                // Being already in error mode, cannot do much if Update fails. 
                            }
                            return newEntry.Error; 
                        } 

                        // Now see what's left in attributes change request. 
                        if (attributes != Entry_FC.None) {
                            Update(newEntry, attributes);
                        }
                        //At this point newEntry.Error should contain the resulting status 
                        //and we replaced the entry in the cache with the same body
                        //but different headers. Some more attributes may have changed as well. 
                    } 
                }
            } 

            return newEntry.Error;
        }
 
        //
        // Updates a Cached Entry metadata according to attibutes flags. 
        // 
        internal static Status Remove(Entry entry) {
            entry.Error = Status.Success; 
            if (!UnsafeNclNativeMethods.UnsafeWinInetCache.DeleteUrlCacheEntryW(entry.Key)) {
                entry.Error = (Status)Marshal.GetLastWin32Error();
            }
            return entry.Error; 
        }
 
 
        //
        // Gets the managed copy of a null terminated string resided in the buffer 
        //
#if DEBUG
        /*
        // Consider removing. 
        private static unsafe string GetEntryBufferString(byte[] buffer, int offset) {
            fixed (void* bufferPtr = buffer) { 
                return GetEntryBufferString(bufferPtr, offset); 
            }
        } 
        */
#endif
        private static unsafe string GetEntryBufferString(void* bufferPtr, int offset) {
            if (offset == 0) { 
                return null;
            } 
            IntPtr pointer = new IntPtr((byte*)bufferPtr + offset); 
            return Marshal.PtrToStringUni(pointer);
        } 

        //
        // Gets the headers and optionally other meta data out of a cached entry
        // 
        // Whenever an empty line found in the buffer, the resulting array of
        // collections will grow in size 
        // 
        private static unsafe Status GetEntryHeaders(Entry entry, EntryBuffer* bufferPtr, byte[] buffer) {
            entry.Error = Status.Success; 
            entry.MetaInfo = null;

            //
            if (bufferPtr->_OffsetHeaderInfo == IntPtr.Zero || bufferPtr->HeaderInfoChars == 0 || (bufferPtr->EntryType & EntryType.UrlHistory) != 0) { 
                return Status.Success;
            } 
 
            int bufferCharLength = bufferPtr->HeaderInfoChars + ((int)(bufferPtr->_OffsetHeaderInfo))/c_CharSz;
            if (bufferCharLength*c_CharSz > entry.MaxBufferBytes) { 
                // WinInet bug? They may report offset+HeaderInfoChars as a greater value than MaxBufferBytes as total buffer size.
                // Actually, the last one seems to be accurate based on the data we have provided for Commit.
                bufferCharLength = entry.MaxBufferBytes/c_CharSz;
            } 
            //WinInet may put terminating nulls at the end of the buffer, remove them.
            while (((char*)bufferPtr)[bufferCharLength-1] == 0) 
                {--bufferCharLength;} 
            entry.MetaInfo = Encoding.Unicode.GetString(buffer, (int)bufferPtr->_OffsetHeaderInfo, (bufferCharLength-(int)bufferPtr->_OffsetHeaderInfo/2)*2);
            return entry.Error; 
        }
/********************
            ArrayList result = new ArrayList();
            NameValueCollection collection = new NameValueCollection(); 
            int offset = (int)bufferPtr->_OffsetHeaderInfo/c_CharSz;
            char *charPtr = (char*)bufferPtr; 
            { 
                int i = offset+1;
                for (; i < entry.MaxBufferBytes/c_CharSz; ++i) { 
                    if ((charPtr[i] == ':' || (charPtr[i] == '\n' && charPtr[(i-1)] == '\r'))) {
                        break;
                    }
                } 
                if (i < entry.MaxBufferBytes/c_CharSz) {
                    //If this looks like a status line 
                    if (charPtr[i] == '\n' && i > offset+1) { 
                        string s = Encoding.Unicode.GetString(buffer, offset*2, (i-offset-1)*2);
                        offset = i+1; 
                        collection[string.Empty] = s;
                    }
                }
            } 
            int bufferCharLength = bufferPtr->HeaderInfoChars + ((int)(bufferPtr->_OffsetHeaderInfo))/c_CharSz;
            if (bufferCharLength*c_CharSz > entry.MaxBufferBytes) { 
                // WinInet bug? They may report offset+HeaderInfoChars as a greater value than total buffer size. 
                // Actually, the last one seems to be accurate based on the data we have provided for Commit.
                bufferCharLength = entry.MaxBufferBytes/c_CharSz; 
            }

            while (true) {
                int totalLength = 0; 
                DataParseStatus status = WebHeaderCollection.ParseHeaders(collection, false, buffer, bufferCharLength,
                                        ref offset, 
                                        ref totalLength, 
                                        entry.MaxBufferBytes/c_CharSz);
 
                if (status != DataParseStatus.Done) {
                    if (status == DataParseStatus.NeedMoreData) {
                        //WinInet puts terminating null at the end of the buffer, accept that as a "normal" case.
                        if ((offset+1 == bufferCharLength) && charPtr[offset] == 0) { 
                             // accept as the last metainfo block
                             if (collection.Count != 0) { 
                                 result.Add(collection); 
                             }
                             break; 
                        }
                    }
                    entry.Error = Status.CorruptedHeaders;
                    //throw new InvalidOperationException("Cannot convert Cache Entry metadata into a NameValueCollection instance"); 
                    break;
                } 
 
                result.Add(collection);
                // do we have more meta data? 
                if (offset >= bufferCharLength) {
                    break;
                }
                // continue parsing next collection 
                collection = new NameValueCollection();
            } 
            entry.MetaInfo = (result.Count == 0? null: (NameValueCollection[])result.ToArray(typeof(NameValueCollection))); 
            return entry.Error;
        } 
*********************/
#if DEBUG

        /* 
        // Consider removing.
        // 
        // For debugging will return a readbale representation of a cached entry info 
        //
        private static string DebugEntryBuffer(byte[] buffer, int entryBufSize) { 

            EntryBuffer Info;
            if (entryBufSize < EntryBuffer.MarshalSize) {
                throw new ArgumentOutOfRangeException("size"); 
            }
            unsafe { 
                fixed(void* vptr = buffer) { 
                    IntPtr ptr = new IntPtr(vptr);
                    Info = (EntryBuffer)Marshal.PtrToStructure(ptr,typeof(EntryBuffer)); 
                }
            }

            string allHeaders = null; 
            //
 
 

 



 

 
 

 



 

 
 

 



 

 
*/ 
#endif  //TRAVE
 
    } //END WinInet class
}
                        

Link Menu

Network programming in C#, Network Programming in VB.NET, Network Programming in .NET
This book is available now!
Buy at Amazon US or
Buy at Amazon UK